From 3ccddce7fd7d9c864e8f5b6bbf27a1f0e088f575 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 10 Feb 2026 12:56:05 +0300 Subject: [PATCH] treewide: fix various UI bugs; optimize crypto dependencies & format Signed-off-by: NotAShelf Change-Id: If8fe8b38c1d9c4fecd40ff71f88d2ae06a6a6964 --- .clippy.toml | 8 + .rustfmt.toml | 27 + Cargo.lock | 57 + Cargo.toml | 11 +- crates/pinakes-core/src/analytics.rs | 77 +- crates/pinakes-core/src/audit.rs | 32 +- crates/pinakes-core/src/books.rs | 278 +- crates/pinakes-core/src/cache.rs | 722 +- crates/pinakes-core/src/collections.rs | 118 +- crates/pinakes-core/src/config.rs | 1854 +-- crates/pinakes-core/src/enrichment/books.rs | 408 +- .../src/enrichment/googlebooks.rs | 396 +- crates/pinakes-core/src/enrichment/lastfm.rs | 175 +- crates/pinakes-core/src/enrichment/mod.rs | 84 +- .../src/enrichment/musicbrainz.rs | 213 +- .../src/enrichment/openlibrary.rs | 387 +- crates/pinakes-core/src/enrichment/tmdb.rs | 175 +- crates/pinakes-core/src/error.rs | 156 +- crates/pinakes-core/src/events.rs | 311 +- crates/pinakes-core/src/export.rs | 109 +- crates/pinakes-core/src/hash.rs | 41 +- crates/pinakes-core/src/import.rs | 747 +- crates/pinakes-core/src/integrity.rs | 565 +- crates/pinakes-core/src/jobs.rs | 444 +- crates/pinakes-core/src/links.rs | 821 +- crates/pinakes-core/src/managed_storage.rs | 655 +- crates/pinakes-core/src/media_type/builtin.rs | 436 +- crates/pinakes-core/src/media_type/mod.rs | 396 +- .../pinakes-core/src/media_type/registry.rs | 483 +- crates/pinakes-core/src/metadata/audio.rs | 144 +- crates/pinakes-core/src/metadata/document.rs | 619 +- crates/pinakes-core/src/metadata/image.rs | 480 +- crates/pinakes-core/src/metadata/markdown.rs | 64 +- crates/pinakes-core/src/metadata/mod.rs | 76 +- crates/pinakes-core/src/metadata/video.rs | 192 +- crates/pinakes-core/src/model.rs | 745 +- crates/pinakes-core/src/opener.rs | 97 +- crates/pinakes-core/src/path_validation.rs | 369 +- crates/pinakes-core/src/playlists.rs | 29 +- crates/pinakes-core/src/plugin/loader.rs | 651 +- crates/pinakes-core/src/plugin/mod.rs | 681 +- crates/pinakes-core/src/plugin/registry.rs | 428 +- crates/pinakes-core/src/plugin/runtime.rs | 1045 +- crates/pinakes-core/src/plugin/security.rs | 570 +- crates/pinakes-core/src/scan.rs | 625 +- crates/pinakes-core/src/scheduler.rs | 929 +- crates/pinakes-core/src/search.rs | 897 +- crates/pinakes-core/src/sharing.rs | 583 +- crates/pinakes-core/src/social.rs | 51 +- crates/pinakes-core/src/storage/migrations.rs | 30 +- crates/pinakes-core/src/storage/mod.rs | 1776 +- crates/pinakes-core/src/storage/postgres.rs | 12729 ++++++++------- crates/pinakes-core/src/storage/sqlite.rs | 13487 +++++++++------- crates/pinakes-core/src/subtitles.rs | 68 +- crates/pinakes-core/src/sync/chunked.rs | 484 +- crates/pinakes-core/src/sync/conflict.rs | 202 +- crates/pinakes-core/src/sync/models.rs | 502 +- crates/pinakes-core/src/sync/protocol.rs | 273 +- crates/pinakes-core/src/tags.rs | 67 +- crates/pinakes-core/src/thumbnail.rs | 858 +- crates/pinakes-core/src/transcode.rs | 962 +- crates/pinakes-core/src/upload.rs | 367 +- crates/pinakes-core/src/users.rs | 250 +- crates/pinakes-core/tests/book_metadata.rs | 259 +- crates/pinakes-core/tests/common/mod.rs | 232 +- crates/pinakes-core/tests/integration.rs | 1496 +- crates/pinakes-core/tests/integrity.rs | 206 +- .../tests/markdown_links_atomicity.rs | 300 +- .../pinakes-core/tests/session_persistence.rs | 468 +- crates/pinakes-plugin-api/src/lib.rs | 377 +- crates/pinakes-plugin-api/src/manifest.rs | 337 +- crates/pinakes-plugin-api/src/types.rs | 191 +- crates/pinakes-plugin-api/src/wasm.rs | 226 +- crates/pinakes-plugin-api/tests/api.rs | 841 +- crates/pinakes-plugin-api/tests/validate.rs | 102 +- crates/pinakes-server/src/app.rs | 234 +- crates/pinakes-server/src/auth.rs | 378 +- crates/pinakes-server/src/dto.rs | 1672 +- crates/pinakes-server/src/error.rs | 160 +- crates/pinakes-server/src/main.rs | 1405 +- crates/pinakes-server/src/routes/analytics.rs | 142 +- crates/pinakes-server/src/routes/audit.rs | 35 +- crates/pinakes-server/src/routes/auth.rs | 461 +- crates/pinakes-server/src/routes/books.rs | 389 +- .../pinakes-server/src/routes/collections.rs | 147 +- crates/pinakes-server/src/routes/config.rs | 357 +- crates/pinakes-server/src/routes/database.rs | 39 +- .../pinakes-server/src/routes/duplicates.rs | 47 +- .../pinakes-server/src/routes/enrichment.rs | 68 +- crates/pinakes-server/src/routes/export.rs | 56 +- crates/pinakes-server/src/routes/health.rs | 324 +- crates/pinakes-server/src/routes/integrity.rs | 129 +- crates/pinakes-server/src/routes/jobs.rs | 48 +- crates/pinakes-server/src/routes/media.rs | 1827 ++- crates/pinakes-server/src/routes/notes.rs | 347 +- crates/pinakes-server/src/routes/photos.rs | 270 +- crates/pinakes-server/src/routes/playlists.rs | 311 +- crates/pinakes-server/src/routes/plugins.rs | 212 +- .../src/routes/saved_searches.rs | 109 +- crates/pinakes-server/src/routes/scan.rs | 43 +- .../src/routes/scheduled_tasks.rs | 94 +- crates/pinakes-server/src/routes/search.rs | 135 +- crates/pinakes-server/src/routes/shares.rs | 861 +- crates/pinakes-server/src/routes/social.rs | 295 +- .../pinakes-server/src/routes/statistics.rs | 13 +- crates/pinakes-server/src/routes/streaming.rs | 403 +- crates/pinakes-server/src/routes/subtitles.rs | 200 +- crates/pinakes-server/src/routes/sync.rs | 1339 +- crates/pinakes-server/src/routes/tags.rs | 89 +- crates/pinakes-server/src/routes/transcode.rs | 93 +- crates/pinakes-server/src/routes/upload.rs | 252 +- crates/pinakes-server/src/routes/users.rs | 262 +- crates/pinakes-server/src/routes/webhooks.rs | 52 +- crates/pinakes-server/src/state.rs | 48 +- crates/pinakes-server/tests/api.rs | 1400 +- crates/pinakes-server/tests/plugin.rs | 346 +- crates/pinakes-tui/src/app.rs | 2572 +-- crates/pinakes-tui/src/client.rs | 755 +- crates/pinakes-tui/src/event.rs | 98 +- crates/pinakes-tui/src/input.rs | 311 +- crates/pinakes-tui/src/main.rs | 64 +- crates/pinakes-tui/src/ui/audit.rs | 135 +- crates/pinakes-tui/src/ui/collections.rs | 104 +- crates/pinakes-tui/src/ui/database.rs | 86 +- crates/pinakes-tui/src/ui/detail.rs | 384 +- crates/pinakes-tui/src/ui/duplicates.rs | 107 +- crates/pinakes-tui/src/ui/import.rs | 124 +- crates/pinakes-tui/src/ui/library.rs | 162 +- crates/pinakes-tui/src/ui/metadata_edit.rs | 136 +- crates/pinakes-tui/src/ui/mod.rs | 292 +- crates/pinakes-tui/src/ui/queue.rs | 121 +- crates/pinakes-tui/src/ui/search.rs | 189 +- crates/pinakes-tui/src/ui/settings.rs | 148 +- crates/pinakes-tui/src/ui/statistics.rs | 340 +- crates/pinakes-tui/src/ui/tags.rs | 99 +- crates/pinakes-tui/src/ui/tasks.rs | 120 +- crates/pinakes-ui/Cargo.toml | 3 +- crates/pinakes-ui/Dioxus.toml | 21 + crates/pinakes-ui/assets/css/main.css | 1 + crates/pinakes-ui/assets/styles/_audit.scss | 493 + crates/pinakes-ui/assets/styles/_base.scss | 210 + .../pinakes-ui/assets/styles/_components.scss | 1132 ++ crates/pinakes-ui/assets/styles/_graph.scss | 310 + crates/pinakes-ui/assets/styles/_layout.scss | 344 + crates/pinakes-ui/assets/styles/_media.scss | 715 + crates/pinakes-ui/assets/styles/_mixins.scss | 300 + .../pinakes-ui/assets/styles/_sections.scss | 508 + crates/pinakes-ui/assets/styles/_themes.scss | 281 + .../pinakes-ui/assets/styles/_variables.scss | 256 + crates/pinakes-ui/assets/styles/main.scss | 13 + crates/pinakes-ui/build.rs | 27 + crates/pinakes-ui/src/app.rs | 4958 +++--- crates/pinakes-ui/src/client.rs | 2202 +-- crates/pinakes-ui/src/components/audit.rs | 216 +- .../src/components/backlinks_panel.rs | 696 +- .../pinakes-ui/src/components/breadcrumb.rs | 64 +- .../pinakes-ui/src/components/collections.rs | 551 +- crates/pinakes-ui/src/components/database.rs | 358 +- crates/pinakes-ui/src/components/detail.rs | 1604 +- .../pinakes-ui/src/components/duplicates.rs | 303 +- .../pinakes-ui/src/components/graph_view.rs | 1236 +- .../pinakes-ui/src/components/image_viewer.rs | 452 +- crates/pinakes-ui/src/components/import.rs | 1542 +- crates/pinakes-ui/src/components/library.rs | 1648 +- crates/pinakes-ui/src/components/loading.rs | 76 +- crates/pinakes-ui/src/components/login.rs | 142 +- .../src/components/markdown_viewer.rs | 620 +- .../pinakes-ui/src/components/media_player.rs | 1235 +- .../pinakes-ui/src/components/pagination.rs | 162 +- .../pinakes-ui/src/components/pdf_viewer.rs | 202 +- crates/pinakes-ui/src/components/search.rs | 840 +- crates/pinakes-ui/src/components/settings.rs | 1090 +- .../pinakes-ui/src/components/statistics.rs | 506 +- crates/pinakes-ui/src/components/tags.rs | 534 +- crates/pinakes-ui/src/components/tasks.rs | 307 +- crates/pinakes-ui/src/components/utils.rs | 104 +- crates/pinakes-ui/src/main.rs | 44 +- crates/pinakes-ui/src/styles.rs | 4175 +---- 178 files changed, 56286 insertions(+), 52185 deletions(-) create mode 100644 .clippy.toml create mode 100644 .rustfmt.toml create mode 100644 crates/pinakes-ui/Dioxus.toml create mode 100644 crates/pinakes-ui/assets/css/main.css create mode 100644 crates/pinakes-ui/assets/styles/_audit.scss create mode 100644 crates/pinakes-ui/assets/styles/_base.scss create mode 100644 crates/pinakes-ui/assets/styles/_components.scss create mode 100644 crates/pinakes-ui/assets/styles/_graph.scss create mode 100644 crates/pinakes-ui/assets/styles/_layout.scss create mode 100644 crates/pinakes-ui/assets/styles/_media.scss create mode 100644 crates/pinakes-ui/assets/styles/_mixins.scss create mode 100644 crates/pinakes-ui/assets/styles/_sections.scss create mode 100644 crates/pinakes-ui/assets/styles/_themes.scss create mode 100644 crates/pinakes-ui/assets/styles/_variables.scss create mode 100644 crates/pinakes-ui/assets/styles/main.scss create mode 100644 crates/pinakes-ui/build.rs diff --git a/.clippy.toml b/.clippy.toml new file mode 100644 index 0000000..20d3251 --- /dev/null +++ b/.clippy.toml @@ -0,0 +1,8 @@ +await-holding-invalid-types = [ + "generational_box::GenerationalRef", + { path = "generational_box::GenerationalRef", reason = "Reads should not be held over an await point. This will cause any writes to fail while the await is pending since the read borrow is still active." }, + "generational_box::GenerationalRefMut", + { path = "generational_box::GenerationalRefMut", reason = "Write should not be held over an await point. This will cause any reads or writes to fail while the await is pending since the write borrow is still active." }, + "dioxus_signals::WriteLock", + { path = "dioxus_signals::WriteLock", reason = "Write should not be held over an await point. This will cause any reads or writes to fail while the await is pending since the write borrow is still active." }, +] diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000..8af4b10 --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1,27 @@ +condense_wildcard_suffixes = true +doc_comment_code_block_width = 80 +edition = "2024" # Keep in sync with Cargo.toml. +enum_discrim_align_threshold = 60 +force_explicit_abi = false +force_multiline_blocks = true +format_code_in_doc_comments = true +format_macro_matchers = true +format_strings = true +group_imports = "StdExternalCrate" +hex_literal_case = "Upper" +imports_granularity = "Crate" +imports_layout = "HorizontalVertical" +inline_attribute_width = 60 +match_block_trailing_comma = true +max_width = 80 +newline_style = "Unix" +normalize_comments = true +normalize_doc_attributes = true +overflow_delimited_expr = true +struct_field_align_threshold = 60 +tab_spaces = 2 +unstable_features = true +use_field_init_shorthand = true +use_try_shorthand = true +wrap_comments = true + diff --git a/Cargo.lock b/Cargo.lock index e312ea7..e859685 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,6 +28,18 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -799,6 +811,12 @@ dependencies = [ "objc", ] +[[package]] +name = "codemap" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e769b5c8c8283982a987c6e948e540254f1058d5a74b8794914d4ef5fc2a24" + [[package]] name = "color_quant" version = "1.1.0" @@ -3071,6 +3089,31 @@ dependencies = [ "web-time", ] +[[package]] +name = "grass" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7a68216437ef68f0738e48d6c7bb9e6e6a92237e001b03d838314b068f33c94" +dependencies = [ + "clap", + "getrandom 0.2.17", + "grass_compiler", +] + +[[package]] +name = "grass_compiler" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d9e3df7f0222ce5184154973d247c591d9aadc28ce7a73c6cd31100c9facff6" +dependencies = [ + "codemap", + "indexmap", + "lasso", + "once_cell", + "phf 0.11.3", + "rand 0.8.5", +] + [[package]] name = "gray_matter" version = "0.3.2" @@ -3169,6 +3212,10 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] [[package]] name = "hashbrown" @@ -3911,6 +3958,15 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" +[[package]] +name = "lasso" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e14eda50a3494b3bf7b9ce51c52434a761e383d7238ce1dd5dcec2fbc13e9fb" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "lazy-js-bundle" version = "0.7.3" @@ -5370,6 +5426,7 @@ dependencies = [ "dioxus-free-icons", "futures", "gloo-timers", + "grass", "gray_matter", "pulldown-cmark", "rand 0.10.0", diff --git a/Cargo.toml b/Cargo.toml index cb064af..6ac09bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "3" [workspace.package] -edition = "2024" # keep in sync with .rustfmt.toml +edition = "2024" # keep in sync with .rustfmt.toml version = "0.2.0-dev" license = "EUPL-1.2" readme = true @@ -135,3 +135,12 @@ http = "1.4.0" # WASM runtime for plugins wasmtime = { version = "41.0.3", features = ["component-model"] } wit-bindgen = "0.52.0" + +[profile.dev.package] +blake3 = { opt-level = 3 } +image = { opt-level = 3 } +regex = { opt-level = 3 } +argon2 = { opt-level = 3 } +matroska = { opt-level = 3 } +lopdf = { opt-level = 3 } +lofty = { opt-level = 3 } diff --git a/crates/pinakes-core/src/analytics.rs b/crates/pinakes-core/src/analytics.rs index bb605e2..e2867c0 100644 --- a/crates/pinakes-core/src/analytics.rs +++ b/crates/pinakes-core/src/analytics.rs @@ -4,66 +4,65 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::model::MediaId; -use crate::users::UserId; +use crate::{model::MediaId, users::UserId}; /// A tracked usage event for a media item. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UsageEvent { - pub id: Uuid, - pub media_id: Option, - pub user_id: Option, - pub event_type: UsageEventType, - pub timestamp: DateTime, - pub duration_secs: Option, - pub context_json: Option, + pub id: Uuid, + pub media_id: Option, + pub user_id: Option, + pub event_type: UsageEventType, + pub timestamp: DateTime, + pub duration_secs: Option, + pub context_json: Option, } /// Types of usage events that can be tracked. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum UsageEventType { - View, - Play, - Export, - Share, - Search, + View, + Play, + Export, + Share, + Search, } impl std::fmt::Display for UsageEventType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let s = match self { - Self::View => "view", - Self::Play => "play", - Self::Export => "export", - Self::Share => "share", - Self::Search => "search", - }; - write!(f, "{s}") - } + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + Self::View => "view", + Self::Play => "play", + Self::Export => "export", + Self::Share => "share", + Self::Search => "search", + }; + write!(f, "{s}") + } } impl std::str::FromStr for UsageEventType { - type Err = String; + type Err = String; - fn from_str(s: &str) -> std::result::Result { - match s { - "view" => Ok(Self::View), - "play" => Ok(Self::Play), - "export" => Ok(Self::Export), - "share" => Ok(Self::Share), - "search" => Ok(Self::Search), - _ => Err(format!("unknown usage event type: {s}")), - } + fn from_str(s: &str) -> std::result::Result { + match s { + "view" => Ok(Self::View), + "play" => Ok(Self::Play), + "export" => Ok(Self::Export), + "share" => Ok(Self::Share), + "search" => Ok(Self::Search), + _ => Err(format!("unknown usage event type: {s}")), } + } } /// Watch history entry tracking progress through media. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WatchHistory { - pub id: Uuid, - pub user_id: UserId, - pub media_id: MediaId, - pub progress_secs: f64, - pub last_watched: DateTime, + pub id: Uuid, + pub user_id: UserId, + pub media_id: MediaId, + pub progress_secs: f64, + pub last_watched: DateTime, } diff --git a/crates/pinakes-core/src/audit.rs b/crates/pinakes-core/src/audit.rs index 6fe0f7d..d241946 100644 --- a/crates/pinakes-core/src/audit.rs +++ b/crates/pinakes-core/src/audit.rs @@ -1,21 +1,23 @@ use uuid::Uuid; -use crate::error::Result; -use crate::model::{AuditAction, AuditEntry, MediaId}; -use crate::storage::DynStorageBackend; +use crate::{ + error::Result, + model::{AuditAction, AuditEntry, MediaId}, + storage::DynStorageBackend, +}; pub async fn record_action( - storage: &DynStorageBackend, - media_id: Option, - action: AuditAction, - details: Option, + storage: &DynStorageBackend, + media_id: Option, + action: AuditAction, + details: Option, ) -> Result<()> { - let entry = AuditEntry { - id: Uuid::now_v7(), - media_id, - action, - details, - timestamp: chrono::Utc::now(), - }; - storage.record_audit(&entry).await + let entry = AuditEntry { + id: Uuid::now_v7(), + media_id, + action, + details, + timestamp: chrono::Utc::now(), + }; + storage.record_audit(&entry).await } diff --git a/crates/pinakes-core/src/books.rs b/crates/pinakes-core/src/books.rs index 0e1d80f..c287a1c 100644 --- a/crates/pinakes-core/src/books.rs +++ b/crates/pinakes-core/src/books.rs @@ -2,184 +2,192 @@ use crate::error::{PinakesError, Result}; /// Normalize ISBN to ISBN-13 format pub fn normalize_isbn(isbn: &str) -> Result { - // Remove hyphens, spaces, and any non-numeric characters (except X for ISBN-10) - let clean: String = isbn - .chars() - .filter(|c| c.is_ascii_digit() || *c == 'X' || *c == 'x') - .collect(); + // Remove hyphens, spaces, and any non-numeric characters (except X for + // ISBN-10) + let clean: String = isbn + .chars() + .filter(|c| c.is_ascii_digit() || *c == 'X' || *c == 'x') + .collect(); - match clean.len() { - 10 => isbn10_to_isbn13(&clean), - 13 => { - if is_valid_isbn13(&clean) { - Ok(clean) - } else { - Err(PinakesError::InvalidData(format!( - "Invalid ISBN-13 checksum: {}", - isbn - ))) - } - } - _ => Err(PinakesError::InvalidData(format!( - "Invalid ISBN length: {}", - isbn - ))), - } + match clean.len() { + 10 => isbn10_to_isbn13(&clean), + 13 => { + if is_valid_isbn13(&clean) { + Ok(clean) + } else { + Err(PinakesError::InvalidData(format!( + "Invalid ISBN-13 checksum: {}", + isbn + ))) + } + }, + _ => { + Err(PinakesError::InvalidData(format!( + "Invalid ISBN length: {}", + isbn + ))) + }, + } } /// Convert ISBN-10 to ISBN-13 fn isbn10_to_isbn13(isbn10: &str) -> Result { - if isbn10.len() != 10 { - return Err(PinakesError::InvalidData(format!( - "ISBN-10 must be 10 characters: {}", - isbn10 - ))); - } + if isbn10.len() != 10 { + return Err(PinakesError::InvalidData(format!( + "ISBN-10 must be 10 characters: {}", + isbn10 + ))); + } - // Add 978 prefix - let mut isbn13 = format!("978{}", &isbn10[..9]); + // Add 978 prefix + let mut isbn13 = format!("978{}", &isbn10[..9]); - // Calculate check digit - let check_digit = calculate_isbn13_check_digit(&isbn13)?; - isbn13.push_str(&check_digit.to_string()); + // Calculate check digit + let check_digit = calculate_isbn13_check_digit(&isbn13)?; + isbn13.push_str(&check_digit.to_string()); - Ok(isbn13) + Ok(isbn13) } /// Calculate ISBN-13 check digit fn calculate_isbn13_check_digit(isbn_without_check: &str) -> Result { - if isbn_without_check.len() != 12 { - return Err(PinakesError::InvalidData( - "ISBN-13 without check digit must be 12 digits".to_string(), - )); - } + if isbn_without_check.len() != 12 { + return Err(PinakesError::InvalidData( + "ISBN-13 without check digit must be 12 digits".to_string(), + )); + } - let sum: u32 = isbn_without_check - .chars() - .enumerate() - .filter_map(|(i, c)| c.to_digit(10).map(|d| if i % 2 == 0 { d } else { d * 3 })) - .sum(); + let sum: u32 = isbn_without_check + .chars() + .enumerate() + .filter_map(|(i, c)| { + c.to_digit(10).map(|d| if i % 2 == 0 { d } else { d * 3 }) + }) + .sum(); - let check_digit = (10 - (sum % 10)) % 10; - Ok(check_digit) + let check_digit = (10 - (sum % 10)) % 10; + Ok(check_digit) } /// Validate ISBN-13 checksum fn is_valid_isbn13(isbn13: &str) -> bool { - if isbn13.len() != 13 { - return false; - } + if isbn13.len() != 13 { + return false; + } - let sum: u32 = isbn13 - .chars() - .enumerate() - .filter_map(|(i, c)| c.to_digit(10).map(|d| if i % 2 == 0 { d } else { d * 3 })) - .sum(); + let sum: u32 = isbn13 + .chars() + .enumerate() + .filter_map(|(i, c)| { + c.to_digit(10).map(|d| if i % 2 == 0 { d } else { d * 3 }) + }) + .sum(); - sum.is_multiple_of(10) + sum.is_multiple_of(10) } /// Extract ISBN from text (searches for ISBN-10 or ISBN-13 patterns) pub fn extract_isbn_from_text(text: &str) -> Option { - use regex::Regex; + use regex::Regex; - // Try different patterns in order of specificity - let patterns = vec![ - // ISBN followed by colon or "is" with hyphens (most specific) - r"ISBN(?:-13)?(?:\s+is|:)?\s*(\d{3}-\d{1,5}-\d{1,7}-\d{1,7}-\d)", - r"ISBN(?:-10)?(?:\s+is|:)?\s*(\d{1,5}-\d{1,7}-\d{1,7}-[\dXx])", - // ISBN with just whitespace - r"ISBN(?:-13)?\s+(\d{13})", - r"ISBN(?:-10)?\s+(\d{9}[\dXx])", - // Bare ISBN-13 with hyphens (in case "ISBN" is missing) - r"\b(\d{3}-\d{1,5}-\d{1,7}-\d{1,7}-\d)\b", - // Bare ISBN-10 with hyphens - r"\b(\d{1,5}-\d{1,7}-\d{1,7}-[\dXx])\b", - ]; + // Try different patterns in order of specificity + let patterns = vec![ + // ISBN followed by colon or "is" with hyphens (most specific) + r"ISBN(?:-13)?(?:\s+is|:)?\s*(\d{3}-\d{1,5}-\d{1,7}-\d{1,7}-\d)", + r"ISBN(?:-10)?(?:\s+is|:)?\s*(\d{1,5}-\d{1,7}-\d{1,7}-[\dXx])", + // ISBN with just whitespace + r"ISBN(?:-13)?\s+(\d{13})", + r"ISBN(?:-10)?\s+(\d{9}[\dXx])", + // Bare ISBN-13 with hyphens (in case "ISBN" is missing) + r"\b(\d{3}-\d{1,5}-\d{1,7}-\d{1,7}-\d)\b", + // Bare ISBN-10 with hyphens + r"\b(\d{1,5}-\d{1,7}-\d{1,7}-[\dXx])\b", + ]; - for pattern_str in patterns { - if let Ok(pattern) = Regex::new(pattern_str) - && let Some(captures) = pattern.captures(text) - && let Some(isbn) = captures.get(1) - && let Ok(normalized) = normalize_isbn(isbn.as_str()) - { - return Some(normalized); - } + for pattern_str in patterns { + if let Ok(pattern) = Regex::new(pattern_str) + && let Some(captures) = pattern.captures(text) + && let Some(isbn) = captures.get(1) + && let Ok(normalized) = normalize_isbn(isbn.as_str()) + { + return Some(normalized); } + } - None + None } /// Parse author name into "Last, First" format for sorting pub fn parse_author_file_as(name: &str) -> String { - // Simple heuristic: if already contains comma, use as-is - if name.contains(',') { - return name.to_string(); - } + // Simple heuristic: if already contains comma, use as-is + if name.contains(',') { + return name.to_string(); + } - // Split by whitespace - let parts: Vec<&str> = name.split_whitespace().collect(); + // Split by whitespace + let parts: Vec<&str> = name.split_whitespace().collect(); - match parts.len() { - 0 => String::new(), - 1 => parts[0].to_string(), - _ => { - // Last part is surname, rest is given names - let surname = parts.last().unwrap(); - let given_names = parts[..parts.len() - 1].join(" "); - format!("{}, {}", surname, given_names) - } - } + match parts.len() { + 0 => String::new(), + 1 => parts[0].to_string(), + _ => { + // Last part is surname, rest is given names + let surname = parts.last().unwrap(); + let given_names = parts[..parts.len() - 1].join(" "); + format!("{}, {}", surname, given_names) + }, + } } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn test_normalize_isbn10() { - assert_eq!(normalize_isbn("0-306-40615-2").unwrap(), "9780306406157"); - assert_eq!(normalize_isbn("0306406152").unwrap(), "9780306406157"); - } + #[test] + fn test_normalize_isbn10() { + assert_eq!(normalize_isbn("0-306-40615-2").unwrap(), "9780306406157"); + assert_eq!(normalize_isbn("0306406152").unwrap(), "9780306406157"); + } - #[test] - fn test_normalize_isbn13() { - assert_eq!( - normalize_isbn("978-0-306-40615-7").unwrap(), - "9780306406157" - ); - assert_eq!(normalize_isbn("9780306406157").unwrap(), "9780306406157"); - } + #[test] + fn test_normalize_isbn13() { + assert_eq!( + normalize_isbn("978-0-306-40615-7").unwrap(), + "9780306406157" + ); + assert_eq!(normalize_isbn("9780306406157").unwrap(), "9780306406157"); + } - #[test] - fn test_invalid_isbn() { - assert!(normalize_isbn("123").is_err()); - assert!(normalize_isbn("123456789012345").is_err()); - } + #[test] + fn test_invalid_isbn() { + assert!(normalize_isbn("123").is_err()); + assert!(normalize_isbn("123456789012345").is_err()); + } - #[test] - fn test_extract_isbn() { - let text = "This book's ISBN is 978-0-306-40615-7 and was published in 2020."; - assert_eq!( - extract_isbn_from_text(text), - Some("9780306406157".to_string()) - ); + #[test] + fn test_extract_isbn() { + let text = + "This book's ISBN is 978-0-306-40615-7 and was published in 2020."; + assert_eq!( + extract_isbn_from_text(text), + Some("9780306406157".to_string()) + ); - let text2 = "ISBN-10: 0-306-40615-2"; - assert_eq!( - extract_isbn_from_text(text2), - Some("9780306406157".to_string()) - ); + let text2 = "ISBN-10: 0-306-40615-2"; + assert_eq!( + extract_isbn_from_text(text2), + Some("9780306406157".to_string()) + ); - let text3 = "No ISBN here"; - assert_eq!(extract_isbn_from_text(text3), None); - } + let text3 = "No ISBN here"; + assert_eq!(extract_isbn_from_text(text3), None); + } - #[test] - fn test_parse_author_file_as() { - assert_eq!(parse_author_file_as("J.K. Rowling"), "Rowling, J.K."); - assert_eq!(parse_author_file_as("Neil Gaiman"), "Gaiman, Neil"); - assert_eq!(parse_author_file_as("Rowling, J.K."), "Rowling, J.K."); - assert_eq!(parse_author_file_as("Prince"), "Prince"); - } + #[test] + fn test_parse_author_file_as() { + assert_eq!(parse_author_file_as("J.K. Rowling"), "Rowling, J.K."); + assert_eq!(parse_author_file_as("Neil Gaiman"), "Gaiman, Neil"); + assert_eq!(parse_author_file_as("Rowling, J.K."), "Rowling, J.K."); + assert_eq!(parse_author_file_as("Prince"), "Prince"); + } } diff --git a/crates/pinakes-core/src/cache.rs b/crates/pinakes-core/src/cache.rs index 15f0db2..ab1bd75 100644 --- a/crates/pinakes-core/src/cache.rs +++ b/crates/pinakes-core/src/cache.rs @@ -7,10 +7,14 @@ //! - Metrics tracking (hit rate, size, evictions) //! - Specialized caches for different data types -use std::hash::Hash; -use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::Duration; +use std::{ + hash::Hash, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, + time::Duration, +}; use moka::future::Cache as MokaCache; @@ -19,483 +23,499 @@ use crate::model::MediaId; /// Cache statistics for monitoring and debugging. #[derive(Debug, Clone, Default)] pub struct CacheStats { - pub hits: u64, - pub misses: u64, - pub evictions: u64, - pub size: u64, + pub hits: u64, + pub misses: u64, + pub evictions: u64, + pub size: u64, } impl CacheStats { - pub fn hit_rate(&self) -> f64 { - let total = self.hits + self.misses; - if total == 0 { - 0.0 - } else { - self.hits as f64 / total as f64 - } + pub fn hit_rate(&self) -> f64 { + let total = self.hits + self.misses; + if total == 0 { + 0.0 + } else { + self.hits as f64 / total as f64 } + } } /// Atomic counters for cache metrics. struct CacheMetrics { - hits: AtomicU64, - misses: AtomicU64, + hits: AtomicU64, + misses: AtomicU64, } impl Default for CacheMetrics { - fn default() -> Self { - Self { - hits: AtomicU64::new(0), - misses: AtomicU64::new(0), - } + fn default() -> Self { + Self { + hits: AtomicU64::new(0), + misses: AtomicU64::new(0), } + } } impl CacheMetrics { - fn record_hit(&self) { - self.hits.fetch_add(1, Ordering::Relaxed); - } + fn record_hit(&self) { + self.hits.fetch_add(1, Ordering::Relaxed); + } - fn record_miss(&self) { - self.misses.fetch_add(1, Ordering::Relaxed); - } + fn record_miss(&self) { + self.misses.fetch_add(1, Ordering::Relaxed); + } - fn stats(&self) -> (u64, u64) { - ( - self.hits.load(Ordering::Relaxed), - self.misses.load(Ordering::Relaxed), - ) - } + fn stats(&self) -> (u64, u64) { + ( + self.hits.load(Ordering::Relaxed), + self.misses.load(Ordering::Relaxed), + ) + } } /// A high-performance cache with LRU eviction and TTL support. pub struct Cache where - K: Hash + Eq + Send + Sync + 'static, - V: Clone + Send + Sync + 'static, + K: Hash + Eq + Send + Sync + 'static, + V: Clone + Send + Sync + 'static, { - inner: MokaCache, - metrics: Arc, + inner: MokaCache, + metrics: Arc, } impl Cache where - K: Hash + Eq + Send + Sync + 'static, - V: Clone + Send + Sync + 'static, + K: Hash + Eq + Send + Sync + 'static, + V: Clone + Send + Sync + 'static, { - /// Create a new cache with the specified TTL and maximum capacity. - pub fn new(ttl: Duration, max_capacity: u64) -> Self { - let inner = MokaCache::builder() - .time_to_live(ttl) - .max_capacity(max_capacity) - .build(); + /// Create a new cache with the specified TTL and maximum capacity. + pub fn new(ttl: Duration, max_capacity: u64) -> Self { + let inner = MokaCache::builder() + .time_to_live(ttl) + .max_capacity(max_capacity) + .build(); - Self { - inner, - metrics: Arc::new(CacheMetrics::default()), - } + Self { + inner, + metrics: Arc::new(CacheMetrics::default()), } + } - /// Create a new cache with TTL, max capacity, and time-to-idle. - pub fn new_with_idle(ttl: Duration, tti: Duration, max_capacity: u64) -> Self { - let inner = MokaCache::builder() - .time_to_live(ttl) - .time_to_idle(tti) - .max_capacity(max_capacity) - .build(); + /// Create a new cache with TTL, max capacity, and time-to-idle. + pub fn new_with_idle( + ttl: Duration, + tti: Duration, + max_capacity: u64, + ) -> Self { + let inner = MokaCache::builder() + .time_to_live(ttl) + .time_to_idle(tti) + .max_capacity(max_capacity) + .build(); - Self { - inner, - metrics: Arc::new(CacheMetrics::default()), - } + Self { + inner, + metrics: Arc::new(CacheMetrics::default()), } + } - /// Get a value from the cache. - pub async fn get(&self, key: &K) -> Option { - match self.inner.get(key).await { - Some(value) => { - self.metrics.record_hit(); - Some(value) - } - None => { - self.metrics.record_miss(); - None - } - } + /// Get a value from the cache. + pub async fn get(&self, key: &K) -> Option { + match self.inner.get(key).await { + Some(value) => { + self.metrics.record_hit(); + Some(value) + }, + None => { + self.metrics.record_miss(); + None + }, } + } - /// Insert a value into the cache. - pub async fn insert(&self, key: K, value: V) { - self.inner.insert(key, value).await; - } + /// Insert a value into the cache. + pub async fn insert(&self, key: K, value: V) { + self.inner.insert(key, value).await; + } - /// Remove a specific key from the cache. - pub async fn invalidate(&self, key: &K) { - self.inner.invalidate(key).await; - } + /// Remove a specific key from the cache. + pub async fn invalidate(&self, key: &K) { + self.inner.invalidate(key).await; + } - /// Clear all entries from the cache. - pub async fn invalidate_all(&self) { - self.inner.invalidate_all(); - // Run pending tasks to ensure immediate invalidation - self.inner.run_pending_tasks().await; - } + /// Clear all entries from the cache. + pub async fn invalidate_all(&self) { + self.inner.invalidate_all(); + // Run pending tasks to ensure immediate invalidation + self.inner.run_pending_tasks().await; + } - /// Get the current number of entries in the cache. - pub fn entry_count(&self) -> u64 { - self.inner.entry_count() - } + /// Get the current number of entries in the cache. + pub fn entry_count(&self) -> u64 { + self.inner.entry_count() + } - /// Get cache statistics. - pub fn stats(&self) -> CacheStats { - let (hits, misses) = self.metrics.stats(); - CacheStats { - hits, - misses, - evictions: 0, // Moka doesn't expose this directly - size: self.entry_count(), - } + /// Get cache statistics. + pub fn stats(&self) -> CacheStats { + let (hits, misses) = self.metrics.stats(); + CacheStats { + hits, + misses, + evictions: 0, // Moka doesn't expose this directly + size: self.entry_count(), } + } } /// Specialized cache for search query results. pub struct QueryCache { - /// Cache keyed by (query_hash, offset, limit) - inner: Cache, + /// Cache keyed by (query_hash, offset, limit) + inner: Cache, } impl QueryCache { - pub fn new(ttl: Duration, max_capacity: u64) -> Self { - Self { - inner: Cache::new(ttl, max_capacity), - } + pub fn new(ttl: Duration, max_capacity: u64) -> Self { + Self { + inner: Cache::new(ttl, max_capacity), } + } - /// Generate a cache key from query parameters. - fn make_key(query: &str, offset: u64, limit: u64, sort: Option<&str>) -> String { - use std::hash::{DefaultHasher, Hasher}; - let mut hasher = DefaultHasher::new(); - hasher.write(query.as_bytes()); - hasher.write(&offset.to_le_bytes()); - hasher.write(&limit.to_le_bytes()); - if let Some(s) = sort { - hasher.write(s.as_bytes()); - } - format!("q:{:016x}", hasher.finish()) + /// Generate a cache key from query parameters. + fn make_key( + query: &str, + offset: u64, + limit: u64, + sort: Option<&str>, + ) -> String { + use std::hash::{DefaultHasher, Hasher}; + let mut hasher = DefaultHasher::new(); + hasher.write(query.as_bytes()); + hasher.write(&offset.to_le_bytes()); + hasher.write(&limit.to_le_bytes()); + if let Some(s) = sort { + hasher.write(s.as_bytes()); } + format!("q:{:016x}", hasher.finish()) + } - pub async fn get( - &self, - query: &str, - offset: u64, - limit: u64, - sort: Option<&str>, - ) -> Option { - let key = Self::make_key(query, offset, limit, sort); - self.inner.get(&key).await - } + pub async fn get( + &self, + query: &str, + offset: u64, + limit: u64, + sort: Option<&str>, + ) -> Option { + let key = Self::make_key(query, offset, limit, sort); + self.inner.get(&key).await + } - pub async fn insert( - &self, - query: &str, - offset: u64, - limit: u64, - sort: Option<&str>, - result: String, - ) { - let key = Self::make_key(query, offset, limit, sort); - self.inner.insert(key, result).await; - } + pub async fn insert( + &self, + query: &str, + offset: u64, + limit: u64, + sort: Option<&str>, + result: String, + ) { + let key = Self::make_key(query, offset, limit, sort); + self.inner.insert(key, result).await; + } - pub async fn invalidate_all(&self) { - self.inner.invalidate_all().await; - } + pub async fn invalidate_all(&self) { + self.inner.invalidate_all().await; + } - pub fn stats(&self) -> CacheStats { - self.inner.stats() - } + pub fn stats(&self) -> CacheStats { + self.inner.stats() + } } /// Specialized cache for metadata extraction results. pub struct MetadataCache { - /// Cache keyed by content hash - inner: Cache, + /// Cache keyed by content hash + inner: Cache, } impl MetadataCache { - pub fn new(ttl: Duration, max_capacity: u64) -> Self { - Self { - inner: Cache::new(ttl, max_capacity), - } + pub fn new(ttl: Duration, max_capacity: u64) -> Self { + Self { + inner: Cache::new(ttl, max_capacity), } + } - pub async fn get(&self, content_hash: &str) -> Option { - self.inner.get(&content_hash.to_string()).await - } + pub async fn get(&self, content_hash: &str) -> Option { + self.inner.get(&content_hash.to_string()).await + } - pub async fn insert(&self, content_hash: &str, metadata_json: String) { - self.inner - .insert(content_hash.to_string(), metadata_json) - .await; - } + pub async fn insert(&self, content_hash: &str, metadata_json: String) { + self + .inner + .insert(content_hash.to_string(), metadata_json) + .await; + } - pub async fn invalidate(&self, content_hash: &str) { - self.inner.invalidate(&content_hash.to_string()).await; - } + pub async fn invalidate(&self, content_hash: &str) { + self.inner.invalidate(&content_hash.to_string()).await; + } - pub fn stats(&self) -> CacheStats { - self.inner.stats() - } + pub fn stats(&self) -> CacheStats { + self.inner.stats() + } } /// Specialized cache for media item data. pub struct MediaCache { - inner: Cache, + inner: Cache, } impl MediaCache { - pub fn new(ttl: Duration, max_capacity: u64) -> Self { - Self { - inner: Cache::new(ttl, max_capacity), - } + pub fn new(ttl: Duration, max_capacity: u64) -> Self { + Self { + inner: Cache::new(ttl, max_capacity), } + } - pub async fn get(&self, media_id: MediaId) -> Option { - self.inner.get(&media_id.to_string()).await - } + pub async fn get(&self, media_id: MediaId) -> Option { + self.inner.get(&media_id.to_string()).await + } - pub async fn insert(&self, media_id: MediaId, item_json: String) { - self.inner.insert(media_id.to_string(), item_json).await; - } + pub async fn insert(&self, media_id: MediaId, item_json: String) { + self.inner.insert(media_id.to_string(), item_json).await; + } - pub async fn invalidate(&self, media_id: MediaId) { - self.inner.invalidate(&media_id.to_string()).await; - } + pub async fn invalidate(&self, media_id: MediaId) { + self.inner.invalidate(&media_id.to_string()).await; + } - pub async fn invalidate_all(&self) { - self.inner.invalidate_all().await; - } + pub async fn invalidate_all(&self) { + self.inner.invalidate_all().await; + } - pub fn stats(&self) -> CacheStats { - self.inner.stats() - } + pub fn stats(&self) -> CacheStats { + self.inner.stats() + } } /// Configuration for the cache layer. #[derive(Debug, Clone)] pub struct CacheConfig { - /// TTL for response cache in seconds - pub response_ttl_secs: u64, - /// Maximum number of cached responses - pub response_max_entries: u64, - /// TTL for query cache in seconds - pub query_ttl_secs: u64, - /// Maximum number of cached query results - pub query_max_entries: u64, - /// TTL for metadata cache in seconds - pub metadata_ttl_secs: u64, - /// Maximum number of cached metadata entries - pub metadata_max_entries: u64, - /// TTL for media cache in seconds - pub media_ttl_secs: u64, - /// Maximum number of cached media items - pub media_max_entries: u64, + /// TTL for response cache in seconds + pub response_ttl_secs: u64, + /// Maximum number of cached responses + pub response_max_entries: u64, + /// TTL for query cache in seconds + pub query_ttl_secs: u64, + /// Maximum number of cached query results + pub query_max_entries: u64, + /// TTL for metadata cache in seconds + pub metadata_ttl_secs: u64, + /// Maximum number of cached metadata entries + pub metadata_max_entries: u64, + /// TTL for media cache in seconds + pub media_ttl_secs: u64, + /// Maximum number of cached media items + pub media_max_entries: u64, } impl Default for CacheConfig { - fn default() -> Self { - Self { - response_ttl_secs: 60, - response_max_entries: 1000, - query_ttl_secs: 300, - query_max_entries: 500, - metadata_ttl_secs: 3600, - metadata_max_entries: 10000, - media_ttl_secs: 300, - media_max_entries: 5000, - } + fn default() -> Self { + Self { + response_ttl_secs: 60, + response_max_entries: 1000, + query_ttl_secs: 300, + query_max_entries: 500, + metadata_ttl_secs: 3600, + metadata_max_entries: 10000, + media_ttl_secs: 300, + media_max_entries: 5000, } + } } /// Application-level cache layer wrapping multiple specialized caches. pub struct CacheLayer { - /// Cache for serialized API responses - pub responses: Cache, - /// Cache for search query results - pub queries: QueryCache, - /// Cache for metadata extraction results - pub metadata: MetadataCache, - /// Cache for individual media items - pub media: MediaCache, - /// Configuration - config: CacheConfig, + /// Cache for serialized API responses + pub responses: Cache, + /// Cache for search query results + pub queries: QueryCache, + /// Cache for metadata extraction results + pub metadata: MetadataCache, + /// Cache for individual media items + pub media: MediaCache, + /// Configuration + config: CacheConfig, } impl CacheLayer { - /// Create a new cache layer with the specified TTL (using defaults for other settings). - pub fn new(ttl_secs: u64) -> Self { - let config = CacheConfig { - response_ttl_secs: ttl_secs, - ..Default::default() - }; - Self::with_config(config) - } + /// Create a new cache layer with the specified TTL (using defaults for other + /// settings). + pub fn new(ttl_secs: u64) -> Self { + let config = CacheConfig { + response_ttl_secs: ttl_secs, + ..Default::default() + }; + Self::with_config(config) + } - /// Create a new cache layer with full configuration. - pub fn with_config(config: CacheConfig) -> Self { - Self { - responses: Cache::new( - Duration::from_secs(config.response_ttl_secs), - config.response_max_entries, - ), - queries: QueryCache::new( - Duration::from_secs(config.query_ttl_secs), - config.query_max_entries, - ), - metadata: MetadataCache::new( - Duration::from_secs(config.metadata_ttl_secs), - config.metadata_max_entries, - ), - media: MediaCache::new( - Duration::from_secs(config.media_ttl_secs), - config.media_max_entries, - ), - config, - } + /// Create a new cache layer with full configuration. + pub fn with_config(config: CacheConfig) -> Self { + Self { + responses: Cache::new( + Duration::from_secs(config.response_ttl_secs), + config.response_max_entries, + ), + queries: QueryCache::new( + Duration::from_secs(config.query_ttl_secs), + config.query_max_entries, + ), + metadata: MetadataCache::new( + Duration::from_secs(config.metadata_ttl_secs), + config.metadata_max_entries, + ), + media: MediaCache::new( + Duration::from_secs(config.media_ttl_secs), + config.media_max_entries, + ), + config, } + } - /// Invalidate all caches related to a media item update. - pub async fn invalidate_for_media_update(&self, media_id: MediaId) { - self.media.invalidate(media_id).await; - // Query cache should be invalidated as search results may change - self.queries.invalidate_all().await; - } + /// Invalidate all caches related to a media item update. + pub async fn invalidate_for_media_update(&self, media_id: MediaId) { + self.media.invalidate(media_id).await; + // Query cache should be invalidated as search results may change + self.queries.invalidate_all().await; + } - /// Invalidate all caches related to a media item deletion. - pub async fn invalidate_for_media_delete(&self, media_id: MediaId) { - self.media.invalidate(media_id).await; - self.queries.invalidate_all().await; - } + /// Invalidate all caches related to a media item deletion. + pub async fn invalidate_for_media_delete(&self, media_id: MediaId) { + self.media.invalidate(media_id).await; + self.queries.invalidate_all().await; + } - /// Invalidate all caches (useful after bulk imports or major changes). - pub async fn invalidate_all(&self) { - self.responses.invalidate_all().await; - self.queries.invalidate_all().await; - self.media.invalidate_all().await; - // Keep metadata cache as it's keyed by content hash which doesn't change - } + /// Invalidate all caches (useful after bulk imports or major changes). + pub async fn invalidate_all(&self) { + self.responses.invalidate_all().await; + self.queries.invalidate_all().await; + self.media.invalidate_all().await; + // Keep metadata cache as it's keyed by content hash which doesn't change + } - /// Get aggregated statistics for all caches. - pub fn stats(&self) -> CacheLayerStats { - CacheLayerStats { - responses: self.responses.stats(), - queries: self.queries.stats(), - metadata: self.metadata.stats(), - media: self.media.stats(), - } + /// Get aggregated statistics for all caches. + pub fn stats(&self) -> CacheLayerStats { + CacheLayerStats { + responses: self.responses.stats(), + queries: self.queries.stats(), + metadata: self.metadata.stats(), + media: self.media.stats(), } + } - /// Get the current configuration. - pub fn config(&self) -> &CacheConfig { - &self.config - } + /// Get the current configuration. + pub fn config(&self) -> &CacheConfig { + &self.config + } } /// Aggregated statistics for the entire cache layer. #[derive(Debug, Clone)] pub struct CacheLayerStats { - pub responses: CacheStats, - pub queries: CacheStats, - pub metadata: CacheStats, - pub media: CacheStats, + pub responses: CacheStats, + pub queries: CacheStats, + pub metadata: CacheStats, + pub media: CacheStats, } impl CacheLayerStats { - /// Get the overall hit rate across all caches. - pub fn overall_hit_rate(&self) -> f64 { - let total_hits = - self.responses.hits + self.queries.hits + self.metadata.hits + self.media.hits; - let total_requests = total_hits - + self.responses.misses - + self.queries.misses - + self.metadata.misses - + self.media.misses; + /// Get the overall hit rate across all caches. + pub fn overall_hit_rate(&self) -> f64 { + let total_hits = self.responses.hits + + self.queries.hits + + self.metadata.hits + + self.media.hits; + let total_requests = total_hits + + self.responses.misses + + self.queries.misses + + self.metadata.misses + + self.media.misses; - if total_requests == 0 { - 0.0 - } else { - total_hits as f64 / total_requests as f64 - } + if total_requests == 0 { + 0.0 + } else { + total_hits as f64 / total_requests as f64 } + } - /// Get the total number of entries across all caches. - pub fn total_entries(&self) -> u64 { - self.responses.size + self.queries.size + self.metadata.size + self.media.size - } + /// Get the total number of entries across all caches. + pub fn total_entries(&self) -> u64 { + self.responses.size + + self.queries.size + + self.metadata.size + + self.media.size + } } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[tokio::test] - async fn test_cache_basic_operations() { - let cache: Cache = Cache::new(Duration::from_secs(60), 100); + #[tokio::test] + async fn test_cache_basic_operations() { + let cache: Cache = Cache::new(Duration::from_secs(60), 100); - // Insert and get - cache.insert("key1".to_string(), "value1".to_string()).await; - assert_eq!( - cache.get(&"key1".to_string()).await, - Some("value1".to_string()) - ); + // Insert and get + cache.insert("key1".to_string(), "value1".to_string()).await; + assert_eq!( + cache.get(&"key1".to_string()).await, + Some("value1".to_string()) + ); - // Miss - assert_eq!(cache.get(&"key2".to_string()).await, None); + // Miss + assert_eq!(cache.get(&"key2".to_string()).await, None); - // Invalidate - cache.invalidate(&"key1".to_string()).await; - assert_eq!(cache.get(&"key1".to_string()).await, None); - } + // Invalidate + cache.invalidate(&"key1".to_string()).await; + assert_eq!(cache.get(&"key1".to_string()).await, None); + } - #[tokio::test] - async fn test_cache_stats() { - let cache: Cache = Cache::new(Duration::from_secs(60), 100); + #[tokio::test] + async fn test_cache_stats() { + let cache: Cache = Cache::new(Duration::from_secs(60), 100); - cache.insert("key1".to_string(), "value1".to_string()).await; - let _ = cache.get(&"key1".to_string()).await; // hit - let _ = cache.get(&"key2".to_string()).await; // miss + cache.insert("key1".to_string(), "value1".to_string()).await; + let _ = cache.get(&"key1".to_string()).await; // hit + let _ = cache.get(&"key2".to_string()).await; // miss - let stats = cache.stats(); - assert_eq!(stats.hits, 1); - assert_eq!(stats.misses, 1); - assert!((stats.hit_rate() - 0.5).abs() < 0.01); - } + let stats = cache.stats(); + assert_eq!(stats.hits, 1); + assert_eq!(stats.misses, 1); + assert!((stats.hit_rate() - 0.5).abs() < 0.01); + } - #[tokio::test] - async fn test_query_cache() { - let cache = QueryCache::new(Duration::from_secs(60), 100); + #[tokio::test] + async fn test_query_cache() { + let cache = QueryCache::new(Duration::from_secs(60), 100); - cache - .insert("test query", 0, 10, Some("name"), "results".to_string()) - .await; - assert_eq!( - cache.get("test query", 0, 10, Some("name")).await, - Some("results".to_string()) - ); + cache + .insert("test query", 0, 10, Some("name"), "results".to_string()) + .await; + assert_eq!( + cache.get("test query", 0, 10, Some("name")).await, + Some("results".to_string()) + ); - // Different parameters should miss - assert_eq!(cache.get("test query", 10, 10, Some("name")).await, None); - } + // Different parameters should miss + assert_eq!(cache.get("test query", 10, 10, Some("name")).await, None); + } - #[tokio::test] - async fn test_cache_layer() { - let layer = CacheLayer::new(60); + #[tokio::test] + async fn test_cache_layer() { + let layer = CacheLayer::new(60); - let media_id = MediaId::new(); - layer.media.insert(media_id, "{}".to_string()).await; - assert!(layer.media.get(media_id).await.is_some()); + let media_id = MediaId::new(); + layer.media.insert(media_id, "{}".to_string()).await; + assert!(layer.media.get(media_id).await.is_some()); - layer.invalidate_for_media_delete(media_id).await; - assert!(layer.media.get(media_id).await.is_none()); - } + layer.invalidate_for_media_delete(media_id).await; + assert!(layer.media.get(media_id).await.is_none()); + } } diff --git a/crates/pinakes-core/src/collections.rs b/crates/pinakes-core/src/collections.rs index bab7e70..7c8758e 100644 --- a/crates/pinakes-core/src/collections.rs +++ b/crates/pinakes-core/src/collections.rs @@ -1,78 +1,78 @@ use uuid::Uuid; -use crate::error::Result; -use crate::model::*; -use crate::storage::DynStorageBackend; +use crate::{error::Result, model::*, storage::DynStorageBackend}; pub async fn create_collection( - storage: &DynStorageBackend, - name: &str, - kind: CollectionKind, - description: Option<&str>, - filter_query: Option<&str>, + storage: &DynStorageBackend, + name: &str, + kind: CollectionKind, + description: Option<&str>, + filter_query: Option<&str>, ) -> Result { - storage - .create_collection(name, kind, description, filter_query) - .await + storage + .create_collection(name, kind, description, filter_query) + .await } pub async fn add_member( - storage: &DynStorageBackend, - collection_id: Uuid, - media_id: MediaId, - position: i32, + storage: &DynStorageBackend, + collection_id: Uuid, + media_id: MediaId, + position: i32, ) -> Result<()> { - storage - .add_to_collection(collection_id, media_id, position) - .await?; - crate::audit::record_action( - storage, - Some(media_id), - AuditAction::AddedToCollection, - Some(format!("collection_id={collection_id}")), - ) - .await + storage + .add_to_collection(collection_id, media_id, position) + .await?; + crate::audit::record_action( + storage, + Some(media_id), + AuditAction::AddedToCollection, + Some(format!("collection_id={collection_id}")), + ) + .await } pub async fn remove_member( - storage: &DynStorageBackend, - collection_id: Uuid, - media_id: MediaId, + storage: &DynStorageBackend, + collection_id: Uuid, + media_id: MediaId, ) -> Result<()> { - storage - .remove_from_collection(collection_id, media_id) - .await?; - crate::audit::record_action( - storage, - Some(media_id), - AuditAction::RemovedFromCollection, - Some(format!("collection_id={collection_id}")), - ) - .await + storage + .remove_from_collection(collection_id, media_id) + .await?; + crate::audit::record_action( + storage, + Some(media_id), + AuditAction::RemovedFromCollection, + Some(format!("collection_id={collection_id}")), + ) + .await } pub async fn get_members( - storage: &DynStorageBackend, - collection_id: Uuid, + storage: &DynStorageBackend, + collection_id: Uuid, ) -> Result> { - let collection = storage.get_collection(collection_id).await?; + let collection = storage.get_collection(collection_id).await?; - match collection.kind { - CollectionKind::Virtual => { - // Virtual collections evaluate their filter_query dynamically - if let Some(ref query_str) = collection.filter_query { - let query = crate::search::parse_search_query(query_str)?; - let request = crate::search::SearchRequest { - query, - sort: crate::search::SortOrder::DateDesc, - pagination: Pagination::new(0, 10000, None), - }; - let results = storage.search(&request).await?; - Ok(results.items) - } else { - Ok(Vec::new()) - } - } - CollectionKind::Manual => storage.get_collection_members(collection_id).await, - } + match collection.kind { + CollectionKind::Virtual => { + // Virtual collections evaluate their filter_query dynamically + if let Some(ref query_str) = collection.filter_query { + let query = crate::search::parse_search_query(query_str)?; + let request = crate::search::SearchRequest { + query, + sort: crate::search::SortOrder::DateDesc, + pagination: Pagination::new(0, 10000, None), + }; + let results = storage.search(&request).await?; + Ok(results.items) + } else { + Ok(Vec::new()) + } + }, + CollectionKind::Manual => { + storage.get_collection_members(collection_id).await + }, + } } diff --git a/crates/pinakes-core/src/config.rs b/crates/pinakes-core/src/config.rs index 5e6cc37..521ef6a 100644 --- a/crates/pinakes-core/src/config.rs +++ b/crates/pinakes-core/src/config.rs @@ -6,609 +6,611 @@ use serde::{Deserialize, Serialize}; /// Supports both ${VAR_NAME} and $VAR_NAME syntax. /// Returns an error if a referenced variable is not set. fn expand_env_var_string(input: &str) -> crate::error::Result { - let mut result = String::new(); - let mut chars = input.chars().peekable(); + let mut result = String::new(); + let mut chars = input.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '$' { - // Check if it's ${VAR} or $VAR syntax - let use_braces = chars.peek() == Some(&'{'); - if use_braces { - chars.next(); // consume '{' - } + while let Some(ch) = chars.next() { + if ch == '$' { + // Check if it's ${VAR} or $VAR syntax + let use_braces = chars.peek() == Some(&'{'); + if use_braces { + chars.next(); // consume '{' + } - // Collect variable name - let mut var_name = String::new(); - while let Some(&next_ch) = chars.peek() { - if use_braces { - if next_ch == '}' { - chars.next(); // consume '}' - break; - } - var_name.push(next_ch); - chars.next(); - } else { - // For $VAR syntax, stop at non-alphanumeric/underscore - if next_ch.is_alphanumeric() || next_ch == '_' { - var_name.push(next_ch); - chars.next(); - } else { - break; - } - } - } - - if var_name.is_empty() { - return Err(crate::error::PinakesError::Config( - "empty environment variable name".to_string(), - )); - } - - // Look up the environment variable - match std::env::var(&var_name) { - Ok(value) => result.push_str(&value), - Err(_) => { - return Err(crate::error::PinakesError::Config(format!( - "environment variable not set: {}", - var_name - ))); - } - } - } else if ch == '\\' { - // Handle escaped characters - if let Some(&next_ch) = chars.peek() { - if next_ch == '$' { - chars.next(); // consume the escaped $ - result.push('$'); - } else { - result.push(ch); - } - } else { - result.push(ch); - } + // Collect variable name + let mut var_name = String::new(); + while let Some(&next_ch) = chars.peek() { + if use_braces { + if next_ch == '}' { + chars.next(); // consume '}' + break; + } + var_name.push(next_ch); + chars.next(); } else { - result.push(ch); + // For $VAR syntax, stop at non-alphanumeric/underscore + if next_ch.is_alphanumeric() || next_ch == '_' { + var_name.push(next_ch); + chars.next(); + } else { + break; + } } - } + } - Ok(result) + if var_name.is_empty() { + return Err(crate::error::PinakesError::Config( + "empty environment variable name".to_string(), + )); + } + + // Look up the environment variable + match std::env::var(&var_name) { + Ok(value) => result.push_str(&value), + Err(_) => { + return Err(crate::error::PinakesError::Config(format!( + "environment variable not set: {}", + var_name + ))); + }, + } + } else if ch == '\\' { + // Handle escaped characters + if let Some(&next_ch) = chars.peek() { + if next_ch == '$' { + chars.next(); // consume the escaped $ + result.push('$'); + } else { + result.push(ch); + } + } else { + result.push(ch); + } + } else { + result.push(ch); + } + } + + Ok(result) } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { - pub storage: StorageConfig, - pub directories: DirectoryConfig, - pub scanning: ScanningConfig, - pub server: ServerConfig, - #[serde(default)] - pub ui: UiConfig, - #[serde(default)] - pub accounts: AccountsConfig, - #[serde(default)] - pub jobs: JobsConfig, - #[serde(default)] - pub thumbnails: ThumbnailConfig, - #[serde(default)] - pub webhooks: Vec, - #[serde(default)] - pub scheduled_tasks: Vec, - #[serde(default)] - pub plugins: PluginsConfig, - #[serde(default)] - pub transcoding: TranscodingConfig, - #[serde(default)] - pub enrichment: EnrichmentConfig, - #[serde(default)] - pub cloud: CloudConfig, - #[serde(default)] - pub analytics: AnalyticsConfig, - #[serde(default)] - pub photos: PhotoConfig, - #[serde(default)] - pub managed_storage: ManagedStorageConfig, - #[serde(default)] - pub sync: SyncConfig, - #[serde(default)] - pub sharing: SharingConfig, + pub storage: StorageConfig, + pub directories: DirectoryConfig, + pub scanning: ScanningConfig, + pub server: ServerConfig, + #[serde(default)] + pub ui: UiConfig, + #[serde(default)] + pub accounts: AccountsConfig, + #[serde(default)] + pub jobs: JobsConfig, + #[serde(default)] + pub thumbnails: ThumbnailConfig, + #[serde(default)] + pub webhooks: Vec, + #[serde(default)] + pub scheduled_tasks: Vec, + #[serde(default)] + pub plugins: PluginsConfig, + #[serde(default)] + pub transcoding: TranscodingConfig, + #[serde(default)] + pub enrichment: EnrichmentConfig, + #[serde(default)] + pub cloud: CloudConfig, + #[serde(default)] + pub analytics: AnalyticsConfig, + #[serde(default)] + pub photos: PhotoConfig, + #[serde(default)] + pub managed_storage: ManagedStorageConfig, + #[serde(default)] + pub sync: SyncConfig, + #[serde(default)] + pub sharing: SharingConfig, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ScheduledTaskConfig { - pub id: String, - pub enabled: bool, - pub schedule: crate::scheduler::Schedule, - pub last_run: Option, + pub id: String, + pub enabled: bool, + pub schedule: crate::scheduler::Schedule, + pub last_run: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct JobsConfig { - #[serde(default = "default_worker_count")] - pub worker_count: usize, - #[serde(default = "default_cache_ttl")] - pub cache_ttl_secs: u64, + #[serde(default = "default_worker_count")] + pub worker_count: usize, + #[serde(default = "default_cache_ttl")] + pub cache_ttl_secs: u64, } fn default_worker_count() -> usize { - 2 + 2 } fn default_cache_ttl() -> u64 { - 60 + 60 } impl Default for JobsConfig { - fn default() -> Self { - Self { - worker_count: default_worker_count(), - cache_ttl_secs: default_cache_ttl(), - } + fn default() -> Self { + Self { + worker_count: default_worker_count(), + cache_ttl_secs: default_cache_ttl(), } + } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ThumbnailConfig { - #[serde(default = "default_thumb_size")] - pub size: u32, - #[serde(default = "default_thumb_quality")] - pub quality: u8, - #[serde(default)] - pub ffmpeg_path: Option, - #[serde(default = "default_video_seek")] - pub video_seek_secs: u32, + #[serde(default = "default_thumb_size")] + pub size: u32, + #[serde(default = "default_thumb_quality")] + pub quality: u8, + #[serde(default)] + pub ffmpeg_path: Option, + #[serde(default = "default_video_seek")] + pub video_seek_secs: u32, } fn default_thumb_size() -> u32 { - 320 + 320 } fn default_thumb_quality() -> u8 { - 80 + 80 } fn default_video_seek() -> u32 { - 2 + 2 } impl Default for ThumbnailConfig { - fn default() -> Self { - Self { - size: default_thumb_size(), - quality: default_thumb_quality(), - ffmpeg_path: None, - video_seek_secs: default_video_seek(), - } + fn default() -> Self { + Self { + size: default_thumb_size(), + quality: default_thumb_quality(), + ffmpeg_path: None, + video_seek_secs: default_video_seek(), } + } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebhookConfig { - pub url: String, - pub events: Vec, - #[serde(default)] - pub secret: Option, + pub url: String, + pub events: Vec, + #[serde(default)] + pub secret: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UiConfig { - #[serde(default = "default_theme")] - pub theme: String, - #[serde(default = "default_view")] - pub default_view: String, - #[serde(default = "default_page_size")] - pub default_page_size: usize, - #[serde(default = "default_view_mode")] - pub default_view_mode: String, - #[serde(default)] - pub auto_play_media: bool, - #[serde(default = "default_true")] - pub show_thumbnails: bool, - #[serde(default)] - pub sidebar_collapsed: bool, + #[serde(default = "default_theme")] + pub theme: String, + #[serde(default = "default_view")] + pub default_view: String, + #[serde(default = "default_page_size")] + pub default_page_size: usize, + #[serde(default = "default_view_mode")] + pub default_view_mode: String, + #[serde(default)] + pub auto_play_media: bool, + #[serde(default = "default_true")] + pub show_thumbnails: bool, + #[serde(default)] + pub sidebar_collapsed: bool, } fn default_theme() -> String { - "dark".to_string() + "dark".to_string() } fn default_view() -> String { - "library".to_string() + "library".to_string() } fn default_page_size() -> usize { - 48 + 48 } fn default_view_mode() -> String { - "grid".to_string() + "grid".to_string() } fn default_true() -> bool { - true + true } impl Default for UiConfig { - fn default() -> Self { - Self { - theme: default_theme(), - default_view: default_view(), - default_page_size: default_page_size(), - default_view_mode: default_view_mode(), - auto_play_media: false, - show_thumbnails: true, - sidebar_collapsed: false, - } + fn default() -> Self { + Self { + theme: default_theme(), + default_view: default_view(), + default_page_size: default_page_size(), + default_view_mode: default_view_mode(), + auto_play_media: false, + show_thumbnails: true, + sidebar_collapsed: false, } + } } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct AccountsConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub users: Vec, + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub users: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UserAccount { - pub username: String, - pub password_hash: String, - #[serde(default)] - pub role: UserRole, + pub username: String, + pub password_hash: String, + #[serde(default)] + pub role: UserRole, } -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, +)] #[serde(rename_all = "lowercase")] pub enum UserRole { - Admin, - Editor, - #[default] - Viewer, + Admin, + Editor, + #[default] + Viewer, } impl UserRole { - pub fn can_read(self) -> bool { - true - } + pub fn can_read(self) -> bool { + true + } - pub fn can_write(self) -> bool { - matches!(self, Self::Admin | Self::Editor) - } + pub fn can_write(self) -> bool { + matches!(self, Self::Admin | Self::Editor) + } - pub fn can_admin(self) -> bool { - matches!(self, Self::Admin) - } + pub fn can_admin(self) -> bool { + matches!(self, Self::Admin) + } } impl std::fmt::Display for UserRole { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Admin => write!(f, "admin"), - Self::Editor => write!(f, "editor"), - Self::Viewer => write!(f, "viewer"), - } + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Admin => write!(f, "admin"), + Self::Editor => write!(f, "editor"), + Self::Viewer => write!(f, "viewer"), } + } } // ===== Plugin Configuration ===== #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginsConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default = "default_plugin_data_dir")] - pub data_dir: PathBuf, - #[serde(default = "default_plugin_cache_dir")] - pub cache_dir: PathBuf, - #[serde(default)] - pub plugin_dirs: Vec, - #[serde(default)] - pub enable_hot_reload: bool, - #[serde(default)] - pub allow_unsigned: bool, - #[serde(default = "default_max_concurrent_ops")] - pub max_concurrent_ops: usize, - #[serde(default = "default_plugin_timeout")] - pub plugin_timeout_secs: u64, + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_plugin_data_dir")] + pub data_dir: PathBuf, + #[serde(default = "default_plugin_cache_dir")] + pub cache_dir: PathBuf, + #[serde(default)] + pub plugin_dirs: Vec, + #[serde(default)] + pub enable_hot_reload: bool, + #[serde(default)] + pub allow_unsigned: bool, + #[serde(default = "default_max_concurrent_ops")] + pub max_concurrent_ops: usize, + #[serde(default = "default_plugin_timeout")] + pub plugin_timeout_secs: u64, } fn default_plugin_data_dir() -> PathBuf { - Config::default_data_dir().join("plugins").join("data") + Config::default_data_dir().join("plugins").join("data") } fn default_plugin_cache_dir() -> PathBuf { - Config::default_data_dir().join("plugins").join("cache") + Config::default_data_dir().join("plugins").join("cache") } fn default_max_concurrent_ops() -> usize { - 4 + 4 } fn default_plugin_timeout() -> u64 { - 30 + 30 } impl Default for PluginsConfig { - fn default() -> Self { - Self { - enabled: false, - data_dir: default_plugin_data_dir(), - cache_dir: default_plugin_cache_dir(), - plugin_dirs: vec![], - enable_hot_reload: false, - allow_unsigned: false, - max_concurrent_ops: default_max_concurrent_ops(), - plugin_timeout_secs: default_plugin_timeout(), - } + fn default() -> Self { + Self { + enabled: false, + data_dir: default_plugin_data_dir(), + cache_dir: default_plugin_cache_dir(), + plugin_dirs: vec![], + enable_hot_reload: false, + allow_unsigned: false, + max_concurrent_ops: default_max_concurrent_ops(), + plugin_timeout_secs: default_plugin_timeout(), } + } } // ===== Transcoding Configuration ===== #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TranscodingConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub cache_dir: Option, - #[serde(default = "default_cache_ttl_hours")] - pub cache_ttl_hours: u64, - #[serde(default = "default_max_concurrent_transcodes")] - pub max_concurrent: usize, - #[serde(default)] - pub hardware_acceleration: Option, - #[serde(default)] - pub profiles: Vec, + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub cache_dir: Option, + #[serde(default = "default_cache_ttl_hours")] + pub cache_ttl_hours: u64, + #[serde(default = "default_max_concurrent_transcodes")] + pub max_concurrent: usize, + #[serde(default)] + pub hardware_acceleration: Option, + #[serde(default)] + pub profiles: Vec, } fn default_cache_ttl_hours() -> u64 { - 48 + 48 } fn default_max_concurrent_transcodes() -> usize { - 2 + 2 } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TranscodeProfile { - pub name: String, - pub video_codec: String, - pub audio_codec: String, - pub max_bitrate_kbps: u32, - pub max_resolution: String, + pub name: String, + pub video_codec: String, + pub audio_codec: String, + pub max_bitrate_kbps: u32, + pub max_resolution: String, } impl Default for TranscodingConfig { - fn default() -> Self { - Self { - enabled: false, - cache_dir: None, - cache_ttl_hours: default_cache_ttl_hours(), - max_concurrent: default_max_concurrent_transcodes(), - hardware_acceleration: None, - profiles: vec![ - TranscodeProfile { - name: "high".to_string(), - video_codec: "h264".to_string(), - audio_codec: "aac".to_string(), - max_bitrate_kbps: 8000, - max_resolution: "1080p".to_string(), - }, - TranscodeProfile { - name: "medium".to_string(), - video_codec: "h264".to_string(), - audio_codec: "aac".to_string(), - max_bitrate_kbps: 4000, - max_resolution: "720p".to_string(), - }, - ], - } + fn default() -> Self { + Self { + enabled: false, + cache_dir: None, + cache_ttl_hours: default_cache_ttl_hours(), + max_concurrent: default_max_concurrent_transcodes(), + hardware_acceleration: None, + profiles: vec![ + TranscodeProfile { + name: "high".to_string(), + video_codec: "h264".to_string(), + audio_codec: "aac".to_string(), + max_bitrate_kbps: 8000, + max_resolution: "1080p".to_string(), + }, + TranscodeProfile { + name: "medium".to_string(), + video_codec: "h264".to_string(), + audio_codec: "aac".to_string(), + max_bitrate_kbps: 4000, + max_resolution: "720p".to_string(), + }, + ], } + } } // ===== Enrichment Configuration ===== #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct EnrichmentConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub auto_enrich_on_import: bool, - #[serde(default)] - pub sources: EnrichmentSources, + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub auto_enrich_on_import: bool, + #[serde(default)] + pub sources: EnrichmentSources, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct EnrichmentSources { - #[serde(default)] - pub musicbrainz: EnrichmentSource, - #[serde(default)] - pub tmdb: EnrichmentSource, - #[serde(default)] - pub lastfm: EnrichmentSource, + #[serde(default)] + pub musicbrainz: EnrichmentSource, + #[serde(default)] + pub tmdb: EnrichmentSource, + #[serde(default)] + pub lastfm: EnrichmentSource, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct EnrichmentSource { - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub api_key: Option, - #[serde(default)] - pub api_endpoint: Option, + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub api_key: Option, + #[serde(default)] + pub api_endpoint: Option, } // ===== Cloud Configuration ===== #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CloudConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default = "default_auto_sync_interval")] - pub auto_sync_interval_mins: u64, - #[serde(default)] - pub accounts: Vec, + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_auto_sync_interval")] + pub auto_sync_interval_mins: u64, + #[serde(default)] + pub accounts: Vec, } fn default_auto_sync_interval() -> u64 { - 60 + 60 } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CloudAccount { - pub id: String, - pub provider: String, - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub sync_rules: Vec, + pub id: String, + pub provider: String, + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub sync_rules: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CloudSyncRule { - pub local_path: PathBuf, - pub remote_path: String, - pub direction: CloudSyncDirection, + pub local_path: PathBuf, + pub remote_path: String, + pub direction: CloudSyncDirection, } #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum CloudSyncDirection { - Upload, - Download, - Bidirectional, + Upload, + Download, + Bidirectional, } impl Default for CloudConfig { - fn default() -> Self { - Self { - enabled: false, - auto_sync_interval_mins: default_auto_sync_interval(), - accounts: vec![], - } + fn default() -> Self { + Self { + enabled: false, + auto_sync_interval_mins: default_auto_sync_interval(), + accounts: vec![], } + } } // ===== Analytics Configuration ===== #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AnalyticsConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default = "default_true")] - pub track_usage: bool, - #[serde(default = "default_retention_days")] - pub retention_days: u64, + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_true")] + pub track_usage: bool, + #[serde(default = "default_retention_days")] + pub retention_days: u64, } fn default_retention_days() -> u64 { - 90 + 90 } impl Default for AnalyticsConfig { - fn default() -> Self { - Self { - enabled: false, - track_usage: true, - retention_days: default_retention_days(), - } + fn default() -> Self { + Self { + enabled: false, + track_usage: true, + retention_days: default_retention_days(), } + } } // ===== Photo Management Configuration ===== #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PhotoConfig { - /// Generate perceptual hashes for image duplicate detection (CPU-intensive) - #[serde(default = "default_true")] - pub generate_perceptual_hash: bool, + /// Generate perceptual hashes for image duplicate detection (CPU-intensive) + #[serde(default = "default_true")] + pub generate_perceptual_hash: bool, - /// Automatically create tags from EXIF keywords - #[serde(default)] - pub auto_tag_from_exif: bool, + /// Automatically create tags from EXIF keywords + #[serde(default)] + pub auto_tag_from_exif: bool, - /// Generate multi-resolution thumbnails (tiny, grid, preview) - #[serde(default)] - pub multi_resolution_thumbnails: bool, + /// Generate multi-resolution thumbnails (tiny, grid, preview) + #[serde(default)] + pub multi_resolution_thumbnails: bool, - /// Auto-detect photo events/albums based on time and location - #[serde(default)] - pub enable_event_detection: bool, + /// Auto-detect photo events/albums based on time and location + #[serde(default)] + pub enable_event_detection: bool, - /// Minimum number of photos to form an event - #[serde(default = "default_min_event_photos")] - pub min_event_photos: usize, + /// Minimum number of photos to form an event + #[serde(default = "default_min_event_photos")] + pub min_event_photos: usize, - /// Maximum time gap between photos in the same event (in seconds) - #[serde(default = "default_event_time_gap")] - pub event_time_gap_secs: i64, + /// Maximum time gap between photos in the same event (in seconds) + #[serde(default = "default_event_time_gap")] + pub event_time_gap_secs: i64, - /// Maximum distance between photos in the same event (in kilometers) - #[serde(default = "default_event_distance")] - pub event_max_distance_km: f64, + /// Maximum distance between photos in the same event (in kilometers) + #[serde(default = "default_event_distance")] + pub event_max_distance_km: f64, } fn default_min_event_photos() -> usize { - 5 + 5 } fn default_event_time_gap() -> i64 { - 2 * 60 * 60 // 2 hours + 2 * 60 * 60 // 2 hours } fn default_event_distance() -> f64 { - 1.0 // 1 km + 1.0 // 1 km } impl Default for PhotoConfig { - fn default() -> Self { - Self { - generate_perceptual_hash: true, - auto_tag_from_exif: false, - multi_resolution_thumbnails: false, - enable_event_detection: false, - min_event_photos: default_min_event_photos(), - event_time_gap_secs: default_event_time_gap(), - event_max_distance_km: default_event_distance(), - } + fn default() -> Self { + Self { + generate_perceptual_hash: true, + auto_tag_from_exif: false, + multi_resolution_thumbnails: false, + enable_event_detection: false, + min_event_photos: default_min_event_photos(), + event_time_gap_secs: default_event_time_gap(), + event_max_distance_km: default_event_distance(), } + } } // ===== Managed Storage Configuration ===== #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ManagedStorageConfig { - /// Enable managed storage for file uploads - #[serde(default)] - pub enabled: bool, - /// Directory where managed files are stored - #[serde(default = "default_managed_storage_dir")] - pub storage_dir: PathBuf, - /// Maximum upload size in bytes (default: 10GB) - #[serde(default = "default_max_upload_size")] - pub max_upload_size: u64, - /// Allowed MIME types for uploads (empty = allow all) - #[serde(default)] - pub allowed_mime_types: Vec, - /// Automatically clean up orphaned blobs - #[serde(default = "default_true")] - pub auto_cleanup: bool, - /// Verify file integrity on read - #[serde(default)] - pub verify_on_read: bool, + /// Enable managed storage for file uploads + #[serde(default)] + pub enabled: bool, + /// Directory where managed files are stored + #[serde(default = "default_managed_storage_dir")] + pub storage_dir: PathBuf, + /// Maximum upload size in bytes (default: 10GB) + #[serde(default = "default_max_upload_size")] + pub max_upload_size: u64, + /// Allowed MIME types for uploads (empty = allow all) + #[serde(default)] + pub allowed_mime_types: Vec, + /// Automatically clean up orphaned blobs + #[serde(default = "default_true")] + pub auto_cleanup: bool, + /// Verify file integrity on read + #[serde(default)] + pub verify_on_read: bool, } fn default_managed_storage_dir() -> PathBuf { - Config::default_data_dir().join("managed") + Config::default_data_dir().join("managed") } fn default_max_upload_size() -> u64 { - 10 * 1024 * 1024 * 1024 // 10GB + 10 * 1024 * 1024 * 1024 // 10GB } impl Default for ManagedStorageConfig { - fn default() -> Self { - Self { - enabled: false, - storage_dir: default_managed_storage_dir(), - max_upload_size: default_max_upload_size(), - allowed_mime_types: vec![], - auto_cleanup: true, - verify_on_read: false, - } + fn default() -> Self { + Self { + enabled: false, + storage_dir: default_managed_storage_dir(), + max_upload_size: default_max_upload_size(), + allowed_mime_types: vec![], + auto_cleanup: true, + verify_on_read: false, } + } } // ===== Sync Configuration ===== @@ -616,688 +618,702 @@ impl Default for ManagedStorageConfig { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ConflictResolution { - ServerWins, - ClientWins, - KeepBoth, - Manual, + ServerWins, + ClientWins, + KeepBoth, + Manual, } impl Default for ConflictResolution { - fn default() -> Self { - Self::KeepBoth - } + fn default() -> Self { + Self::KeepBoth + } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SyncConfig { - /// Enable cross-device sync functionality - #[serde(default)] - pub enabled: bool, - /// Default conflict resolution strategy - #[serde(default)] - pub default_conflict_resolution: ConflictResolution, - /// Maximum file size for sync in MB - #[serde(default = "default_max_sync_file_size")] - pub max_file_size_mb: u64, - /// Chunk size for chunked uploads in KB - #[serde(default = "default_chunk_size")] - pub chunk_size_kb: u64, - /// Upload session timeout in hours - #[serde(default = "default_upload_timeout")] - pub upload_timeout_hours: u64, - /// Maximum concurrent uploads per device - #[serde(default = "default_max_concurrent_uploads")] - pub max_concurrent_uploads: usize, - /// Sync log retention in days - #[serde(default = "default_sync_log_retention")] - pub sync_log_retention_days: u64, - /// Temporary directory for chunked upload storage - #[serde(default = "default_temp_upload_dir")] - pub temp_upload_dir: PathBuf, + /// Enable cross-device sync functionality + #[serde(default)] + pub enabled: bool, + /// Default conflict resolution strategy + #[serde(default)] + pub default_conflict_resolution: ConflictResolution, + /// Maximum file size for sync in MB + #[serde(default = "default_max_sync_file_size")] + pub max_file_size_mb: u64, + /// Chunk size for chunked uploads in KB + #[serde(default = "default_chunk_size")] + pub chunk_size_kb: u64, + /// Upload session timeout in hours + #[serde(default = "default_upload_timeout")] + pub upload_timeout_hours: u64, + /// Maximum concurrent uploads per device + #[serde(default = "default_max_concurrent_uploads")] + pub max_concurrent_uploads: usize, + /// Sync log retention in days + #[serde(default = "default_sync_log_retention")] + pub sync_log_retention_days: u64, + /// Temporary directory for chunked upload storage + #[serde(default = "default_temp_upload_dir")] + pub temp_upload_dir: PathBuf, } fn default_max_sync_file_size() -> u64 { - 4096 // 4GB + 4096 // 4GB } fn default_chunk_size() -> u64 { - 4096 // 4MB + 4096 // 4MB } fn default_upload_timeout() -> u64 { - 24 // 24 hours + 24 // 24 hours } fn default_max_concurrent_uploads() -> usize { - 3 + 3 } fn default_sync_log_retention() -> u64 { - 90 // 90 days + 90 // 90 days } fn default_temp_upload_dir() -> PathBuf { - Config::default_data_dir().join("temp_uploads") + Config::default_data_dir().join("temp_uploads") } impl Default for SyncConfig { - fn default() -> Self { - Self { - enabled: false, - default_conflict_resolution: ConflictResolution::default(), - max_file_size_mb: default_max_sync_file_size(), - chunk_size_kb: default_chunk_size(), - upload_timeout_hours: default_upload_timeout(), - max_concurrent_uploads: default_max_concurrent_uploads(), - sync_log_retention_days: default_sync_log_retention(), - temp_upload_dir: default_temp_upload_dir(), - } + fn default() -> Self { + Self { + enabled: false, + default_conflict_resolution: ConflictResolution::default(), + max_file_size_mb: default_max_sync_file_size(), + chunk_size_kb: default_chunk_size(), + upload_timeout_hours: default_upload_timeout(), + max_concurrent_uploads: default_max_concurrent_uploads(), + sync_log_retention_days: default_sync_log_retention(), + temp_upload_dir: default_temp_upload_dir(), } + } } // ===== Sharing Configuration ===== #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SharingConfig { - /// Enable sharing functionality - #[serde(default = "default_true")] - pub enabled: bool, - /// Allow creating public share links - #[serde(default = "default_true")] - pub allow_public_links: bool, - /// Require password for public share links - #[serde(default)] - pub require_public_link_password: bool, - /// Maximum expiry time for public links in hours (0 = unlimited) - #[serde(default)] - pub max_public_link_expiry_hours: u64, - /// Allow users to reshare content shared with them - #[serde(default = "default_true")] - pub allow_reshare: bool, - /// Enable share notifications - #[serde(default = "default_true")] - pub notifications_enabled: bool, - /// Notification retention in days - #[serde(default = "default_notification_retention")] - pub notification_retention_days: u64, - /// Share activity log retention in days - #[serde(default = "default_activity_retention")] - pub activity_retention_days: u64, + /// Enable sharing functionality + #[serde(default = "default_true")] + pub enabled: bool, + /// Allow creating public share links + #[serde(default = "default_true")] + pub allow_public_links: bool, + /// Require password for public share links + #[serde(default)] + pub require_public_link_password: bool, + /// Maximum expiry time for public links in hours (0 = unlimited) + #[serde(default)] + pub max_public_link_expiry_hours: u64, + /// Allow users to reshare content shared with them + #[serde(default = "default_true")] + pub allow_reshare: bool, + /// Enable share notifications + #[serde(default = "default_true")] + pub notifications_enabled: bool, + /// Notification retention in days + #[serde(default = "default_notification_retention")] + pub notification_retention_days: u64, + /// Share activity log retention in days + #[serde(default = "default_activity_retention")] + pub activity_retention_days: u64, } fn default_notification_retention() -> u64 { - 30 + 30 } fn default_activity_retention() -> u64 { - 90 + 90 } impl Default for SharingConfig { - fn default() -> Self { - Self { - enabled: true, - allow_public_links: true, - require_public_link_password: false, - max_public_link_expiry_hours: 0, - allow_reshare: true, - notifications_enabled: true, - notification_retention_days: default_notification_retention(), - activity_retention_days: default_activity_retention(), - } + fn default() -> Self { + Self { + enabled: true, + allow_public_links: true, + require_public_link_password: false, + max_public_link_expiry_hours: 0, + allow_reshare: true, + notifications_enabled: true, + notification_retention_days: default_notification_retention(), + activity_retention_days: default_activity_retention(), } + } } // ===== Storage Configuration ===== #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StorageConfig { - pub backend: StorageBackendType, - pub sqlite: Option, - pub postgres: Option, + pub backend: StorageBackendType, + pub sqlite: Option, + pub postgres: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum StorageBackendType { - Sqlite, - Postgres, + Sqlite, + Postgres, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SqliteConfig { - pub path: PathBuf, + pub path: PathBuf, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PostgresConfig { - pub host: String, - pub port: u16, - pub database: String, - pub username: String, - pub password: String, - pub max_connections: usize, - /// Enable TLS for PostgreSQL connections - #[serde(default)] - pub tls_enabled: bool, - /// Verify TLS certificates (default: true) - #[serde(default = "default_true")] - pub tls_verify_ca: bool, - /// Path to custom CA certificate file (PEM format) - #[serde(default)] - pub tls_ca_cert_path: Option, + pub host: String, + pub port: u16, + pub database: String, + pub username: String, + pub password: String, + pub max_connections: usize, + /// Enable TLS for PostgreSQL connections + #[serde(default)] + pub tls_enabled: bool, + /// Verify TLS certificates (default: true) + #[serde(default = "default_true")] + pub tls_verify_ca: bool, + /// Path to custom CA certificate file (PEM format) + #[serde(default)] + pub tls_ca_cert_path: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DirectoryConfig { - pub roots: Vec, + pub roots: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ScanningConfig { - pub watch: bool, - pub poll_interval_secs: u64, - pub ignore_patterns: Vec, - #[serde(default = "default_import_concurrency")] - pub import_concurrency: usize, + pub watch: bool, + pub poll_interval_secs: u64, + pub ignore_patterns: Vec, + #[serde(default = "default_import_concurrency")] + pub import_concurrency: usize, } fn default_import_concurrency() -> usize { - 8 + 8 } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServerConfig { - pub host: String, - pub port: u16, - /// Optional API key for bearer token authentication. - /// If set, all requests (except /health) must include `Authorization: Bearer `. - /// Can also be set via `PINAKES_API_KEY` environment variable. - pub api_key: Option, - /// Explicitly disable authentication (INSECURE - use only for development). - /// When true, all requests are allowed without authentication. - /// This must be explicitly set to true; empty api_key alone is not sufficient. - #[serde(default)] - pub authentication_disabled: bool, - /// TLS/HTTPS configuration - #[serde(default)] - pub tls: TlsConfig, + pub host: String, + pub port: u16, + /// Optional API key for bearer token authentication. + /// If set, all requests (except /health) must include `Authorization: Bearer + /// `. Can also be set via `PINAKES_API_KEY` environment variable. + pub api_key: Option, + /// Explicitly disable authentication (INSECURE - use only for development). + /// When true, all requests are allowed without authentication. + /// This must be explicitly set to true; empty api_key alone is not + /// sufficient. + #[serde(default)] + pub authentication_disabled: bool, + /// TLS/HTTPS configuration + #[serde(default)] + pub tls: TlsConfig, } /// TLS/HTTPS configuration for secure connections #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TlsConfig { - /// Enable TLS (HTTPS) - #[serde(default)] - pub enabled: bool, - /// Path to the TLS certificate file (PEM format) - #[serde(default)] - pub cert_path: Option, - /// Path to the TLS private key file (PEM format) - #[serde(default)] - pub key_path: Option, - /// Enable HTTP to HTTPS redirect (starts a second listener on http_port) - #[serde(default)] - pub redirect_http: bool, - /// Port for HTTP redirect listener (default: 80) - #[serde(default = "default_http_port")] - pub http_port: u16, - /// Enable HSTS (HTTP Strict Transport Security) header - #[serde(default = "default_true")] - pub hsts_enabled: bool, - /// HSTS max-age in seconds (default: 1 year) - #[serde(default = "default_hsts_max_age")] - pub hsts_max_age: u64, + /// Enable TLS (HTTPS) + #[serde(default)] + pub enabled: bool, + /// Path to the TLS certificate file (PEM format) + #[serde(default)] + pub cert_path: Option, + /// Path to the TLS private key file (PEM format) + #[serde(default)] + pub key_path: Option, + /// Enable HTTP to HTTPS redirect (starts a second listener on http_port) + #[serde(default)] + pub redirect_http: bool, + /// Port for HTTP redirect listener (default: 80) + #[serde(default = "default_http_port")] + pub http_port: u16, + /// Enable HSTS (HTTP Strict Transport Security) header + #[serde(default = "default_true")] + pub hsts_enabled: bool, + /// HSTS max-age in seconds (default: 1 year) + #[serde(default = "default_hsts_max_age")] + pub hsts_max_age: u64, } fn default_http_port() -> u16 { - 80 + 80 } fn default_hsts_max_age() -> u64 { - 31536000 // 1 year in seconds + 31536000 // 1 year in seconds } impl Default for TlsConfig { - fn default() -> Self { - Self { - enabled: false, - cert_path: None, - key_path: None, - redirect_http: false, - http_port: default_http_port(), - hsts_enabled: true, - hsts_max_age: default_hsts_max_age(), - } + fn default() -> Self { + Self { + enabled: false, + cert_path: None, + key_path: None, + redirect_http: false, + http_port: default_http_port(), + hsts_enabled: true, + hsts_max_age: default_hsts_max_age(), } + } } impl TlsConfig { - /// Validate TLS configuration - pub fn validate(&self) -> Result<(), String> { - if self.enabled { - if self.cert_path.is_none() { - return Err("TLS enabled but cert_path not specified".into()); - } - if self.key_path.is_none() { - return Err("TLS enabled but key_path not specified".into()); - } - if let Some(ref cert_path) = self.cert_path - && !cert_path.exists() - { - return Err(format!( - "TLS certificate file not found: {}", - cert_path.display() - )); - } - if let Some(ref key_path) = self.key_path - && !key_path.exists() - { - return Err(format!("TLS key file not found: {}", key_path.display())); - } - } - Ok(()) + /// Validate TLS configuration + pub fn validate(&self) -> Result<(), String> { + if self.enabled { + if self.cert_path.is_none() { + return Err("TLS enabled but cert_path not specified".into()); + } + if self.key_path.is_none() { + return Err("TLS enabled but key_path not specified".into()); + } + if let Some(ref cert_path) = self.cert_path + && !cert_path.exists() + { + return Err(format!( + "TLS certificate file not found: {}", + cert_path.display() + )); + } + if let Some(ref key_path) = self.key_path + && !key_path.exists() + { + return Err(format!("TLS key file not found: {}", key_path.display())); + } } + Ok(()) + } } impl Config { - pub fn from_file(path: &Path) -> crate::error::Result { - let content = std::fs::read_to_string(path).map_err(|e| { - crate::error::PinakesError::Config(format!("failed to read config file: {e}")) - })?; - let mut config: Self = toml::from_str(&content).map_err(|e| { - crate::error::PinakesError::Config(format!("failed to parse config: {e}")) - })?; - config.expand_env_vars()?; - Ok(config) + pub fn from_file(path: &Path) -> crate::error::Result { + let content = std::fs::read_to_string(path).map_err(|e| { + crate::error::PinakesError::Config(format!( + "failed to read config file: {e}" + )) + })?; + let mut config: Self = toml::from_str(&content).map_err(|e| { + crate::error::PinakesError::Config(format!("failed to parse config: {e}")) + })?; + config.expand_env_vars()?; + Ok(config) + } + + /// Expand environment variables in secret fields. + /// Supports ${VAR_NAME} and $VAR_NAME syntax. + fn expand_env_vars(&mut self) -> crate::error::Result<()> { + // Postgres password + if let Some(ref mut postgres) = self.storage.postgres { + postgres.password = expand_env_var_string(&postgres.password)?; } - /// Expand environment variables in secret fields. - /// Supports ${VAR_NAME} and $VAR_NAME syntax. - fn expand_env_vars(&mut self) -> crate::error::Result<()> { - // Postgres password - if let Some(ref mut postgres) = self.storage.postgres { - postgres.password = expand_env_var_string(&postgres.password)?; - } - - // Server API key - if let Some(ref api_key) = self.server.api_key { - self.server.api_key = Some(expand_env_var_string(api_key)?); - } - - // Webhook secrets - for webhook in &mut self.webhooks { - if let Some(ref secret) = webhook.secret { - webhook.secret = Some(expand_env_var_string(secret)?); - } - } - - // Enrichment API keys - if let Some(ref api_key) = self.enrichment.sources.musicbrainz.api_key { - self.enrichment.sources.musicbrainz.api_key = Some(expand_env_var_string(api_key)?); - } - if let Some(ref api_key) = self.enrichment.sources.tmdb.api_key { - self.enrichment.sources.tmdb.api_key = Some(expand_env_var_string(api_key)?); - } - if let Some(ref api_key) = self.enrichment.sources.lastfm.api_key { - self.enrichment.sources.lastfm.api_key = Some(expand_env_var_string(api_key)?); - } - - Ok(()) + // Server API key + if let Some(ref api_key) = self.server.api_key { + self.server.api_key = Some(expand_env_var_string(api_key)?); } - /// Try loading from file, falling back to defaults if the file doesn't exist. - pub fn load_or_default(path: &Path) -> crate::error::Result { - if path.exists() { - Self::from_file(path) - } else { - let config = Self::default(); - // Ensure the data directory exists for the default SQLite database - config.ensure_dirs()?; - Ok(config) - } + // Webhook secrets + for webhook in &mut self.webhooks { + if let Some(ref secret) = webhook.secret { + webhook.secret = Some(expand_env_var_string(secret)?); + } } - /// Save the current config to a TOML file. - pub fn save_to_file(&self, path: &Path) -> crate::error::Result<()> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let content = toml::to_string_pretty(self).map_err(|e| { - crate::error::PinakesError::Config(format!("failed to serialize config: {e}")) - })?; - std::fs::write(path, content)?; - Ok(()) + // Enrichment API keys + if let Some(ref api_key) = self.enrichment.sources.musicbrainz.api_key { + self.enrichment.sources.musicbrainz.api_key = + Some(expand_env_var_string(api_key)?); + } + if let Some(ref api_key) = self.enrichment.sources.tmdb.api_key { + self.enrichment.sources.tmdb.api_key = + Some(expand_env_var_string(api_key)?); + } + if let Some(ref api_key) = self.enrichment.sources.lastfm.api_key { + self.enrichment.sources.lastfm.api_key = + Some(expand_env_var_string(api_key)?); } - /// Ensure all directories needed by this config exist and are writable. - pub fn ensure_dirs(&self) -> crate::error::Result<()> { - if let Some(ref sqlite) = self.storage.sqlite { - if let Some(parent) = sqlite.path.parent() { - // Skip if parent is empty string (happens with bare filenames like "pinakes.db") - if !parent.as_os_str().is_empty() { - std::fs::create_dir_all(parent)?; - let metadata = std::fs::metadata(parent)?; - if metadata.permissions().readonly() { - return Err(crate::error::PinakesError::Config(format!( - "directory is not writable: {}", - parent.display() - ))); - } - } - } + Ok(()) + } + + /// Try loading from file, falling back to defaults if the file doesn't exist. + pub fn load_or_default(path: &Path) -> crate::error::Result { + if path.exists() { + Self::from_file(path) + } else { + let config = Self::default(); + // Ensure the data directory exists for the default SQLite database + config.ensure_dirs()?; + Ok(config) + } + } + + /// Save the current config to a TOML file. + pub fn save_to_file(&self, path: &Path) -> crate::error::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let content = toml::to_string_pretty(self).map_err(|e| { + crate::error::PinakesError::Config(format!( + "failed to serialize config: {e}" + )) + })?; + std::fs::write(path, content)?; + Ok(()) + } + + /// Ensure all directories needed by this config exist and are writable. + pub fn ensure_dirs(&self) -> crate::error::Result<()> { + if let Some(ref sqlite) = self.storage.sqlite { + if let Some(parent) = sqlite.path.parent() { + // Skip if parent is empty string (happens with bare filenames like + // "pinakes.db") + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent)?; + let metadata = std::fs::metadata(parent)?; + if metadata.permissions().readonly() { + return Err(crate::error::PinakesError::Config(format!( + "directory is not writable: {}", + parent.display() + ))); + } } - Ok(()) + } + } + Ok(()) + } + + /// Returns the default config file path following XDG conventions. + pub fn default_config_path() -> PathBuf { + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + PathBuf::from(xdg).join("pinakes").join("pinakes.toml") + } else if let Ok(home) = std::env::var("HOME") { + PathBuf::from(home) + .join(".config") + .join("pinakes") + .join("pinakes.toml") + } else { + PathBuf::from("pinakes.toml") + } + } + + /// Validate configuration values for correctness. + pub fn validate(&self) -> Result<(), String> { + if self.server.port == 0 { + return Err("server port cannot be 0".into()); + } + if self.server.host.is_empty() { + return Err("server host cannot be empty".into()); + } + if self.scanning.poll_interval_secs == 0 { + return Err("poll interval cannot be 0".into()); + } + if self.scanning.import_concurrency == 0 + || self.scanning.import_concurrency > 256 + { + return Err("import_concurrency must be between 1 and 256".into()); } - /// Returns the default config file path following XDG conventions. - pub fn default_config_path() -> PathBuf { - if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { - PathBuf::from(xdg).join("pinakes").join("pinakes.toml") - } else if let Ok(home) = std::env::var("HOME") { - PathBuf::from(home) - .join(".config") - .join("pinakes") - .join("pinakes.toml") - } else { - PathBuf::from("pinakes.toml") - } + // Validate authentication configuration + let has_api_key = + self.server.api_key.as_ref().is_some_and(|k| !k.is_empty()); + let has_accounts = !self.accounts.users.is_empty(); + let auth_disabled = self.server.authentication_disabled; + + if !auth_disabled && !has_api_key && !has_accounts { + return Err( + "authentication is not configured: set an api_key, configure user \ + accounts, or explicitly set authentication_disabled = true" + .into(), + ); } - /// Validate configuration values for correctness. - pub fn validate(&self) -> Result<(), String> { - if self.server.port == 0 { - return Err("server port cannot be 0".into()); - } - if self.server.host.is_empty() { - return Err("server host cannot be empty".into()); - } - if self.scanning.poll_interval_secs == 0 { - return Err("poll interval cannot be 0".into()); - } - if self.scanning.import_concurrency == 0 || self.scanning.import_concurrency > 256 { - return Err("import_concurrency must be between 1 and 256".into()); - } - - // Validate authentication configuration - let has_api_key = self.server.api_key.as_ref().is_some_and(|k| !k.is_empty()); - let has_accounts = !self.accounts.users.is_empty(); - let auth_disabled = self.server.authentication_disabled; - - if !auth_disabled && !has_api_key && !has_accounts { - return Err( - "authentication is not configured: set an api_key, configure user accounts, \ - or explicitly set authentication_disabled = true" - .into(), - ); - } - - // Empty API key is not allowed (must use authentication_disabled flag) - if let Some(ref api_key) = self.server.api_key - && api_key.is_empty() - { - return Err("empty api_key is not allowed. To disable authentication, \ - set authentication_disabled = true instead" - .into()); - } - - // Require TLS when authentication is enabled on non-localhost - let is_localhost = self.server.host == "127.0.0.1" - || self.server.host == "localhost" - || self.server.host == "::1"; - - if (has_api_key || has_accounts) - && !auth_disabled - && !is_localhost - && !self.server.tls.enabled - { - return Err( - "TLS must be enabled when authentication is used on non-localhost hosts. \ - Set server.tls.enabled = true or bind to localhost only" - .into(), - ); - } - - // Validate TLS configuration - self.server.tls.validate()?; - Ok(()) + // Empty API key is not allowed (must use authentication_disabled flag) + if let Some(ref api_key) = self.server.api_key + && api_key.is_empty() + { + return Err( + "empty api_key is not allowed. To disable authentication, set \ + authentication_disabled = true instead" + .into(), + ); } - /// Returns the default data directory following XDG conventions. - pub fn default_data_dir() -> PathBuf { - if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { - PathBuf::from(xdg).join("pinakes") - } else if let Ok(home) = std::env::var("HOME") { - PathBuf::from(home) - .join(".local") - .join("share") - .join("pinakes") - } else { - PathBuf::from("pinakes-data") - } + // Require TLS when authentication is enabled on non-localhost + let is_localhost = self.server.host == "127.0.0.1" + || self.server.host == "localhost" + || self.server.host == "::1"; + + if (has_api_key || has_accounts) + && !auth_disabled + && !is_localhost + && !self.server.tls.enabled + { + return Err( + "TLS must be enabled when authentication is used on non-localhost \ + hosts. Set server.tls.enabled = true or bind to localhost only" + .into(), + ); } + + // Validate TLS configuration + self.server.tls.validate()?; + Ok(()) + } + + /// Returns the default data directory following XDG conventions. + pub fn default_data_dir() -> PathBuf { + if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { + PathBuf::from(xdg).join("pinakes") + } else if let Ok(home) = std::env::var("HOME") { + PathBuf::from(home) + .join(".local") + .join("share") + .join("pinakes") + } else { + PathBuf::from("pinakes-data") + } + } } impl Default for Config { - fn default() -> Self { - let data_dir = Self::default_data_dir(); - Self { - storage: StorageConfig { - backend: StorageBackendType::Sqlite, - sqlite: Some(SqliteConfig { - path: data_dir.join("pinakes.db"), - }), - postgres: None, - }, - directories: DirectoryConfig { roots: vec![] }, - scanning: ScanningConfig { - watch: false, - poll_interval_secs: 300, - ignore_patterns: vec![ - ".*".to_string(), - "node_modules".to_string(), - "__pycache__".to_string(), - "target".to_string(), - ], - import_concurrency: default_import_concurrency(), - }, - server: ServerConfig { - host: "127.0.0.1".to_string(), - port: 3000, - api_key: None, - authentication_disabled: false, - tls: TlsConfig::default(), - }, - ui: UiConfig::default(), - accounts: AccountsConfig::default(), - jobs: JobsConfig::default(), - thumbnails: ThumbnailConfig::default(), - webhooks: vec![], - scheduled_tasks: vec![], - plugins: PluginsConfig::default(), - transcoding: TranscodingConfig::default(), - enrichment: EnrichmentConfig::default(), - cloud: CloudConfig::default(), - analytics: AnalyticsConfig::default(), - photos: PhotoConfig::default(), - managed_storage: ManagedStorageConfig::default(), - sync: SyncConfig::default(), - sharing: SharingConfig::default(), - } + fn default() -> Self { + let data_dir = Self::default_data_dir(); + Self { + storage: StorageConfig { + backend: StorageBackendType::Sqlite, + sqlite: Some(SqliteConfig { + path: data_dir.join("pinakes.db"), + }), + postgres: None, + }, + directories: DirectoryConfig { roots: vec![] }, + scanning: ScanningConfig { + watch: false, + poll_interval_secs: 300, + ignore_patterns: vec![ + ".*".to_string(), + "node_modules".to_string(), + "__pycache__".to_string(), + "target".to_string(), + ], + import_concurrency: default_import_concurrency(), + }, + server: ServerConfig { + host: "127.0.0.1".to_string(), + port: 3000, + api_key: None, + authentication_disabled: false, + tls: TlsConfig::default(), + }, + ui: UiConfig::default(), + accounts: AccountsConfig::default(), + jobs: JobsConfig::default(), + thumbnails: ThumbnailConfig::default(), + webhooks: vec![], + scheduled_tasks: vec![], + plugins: PluginsConfig::default(), + transcoding: TranscodingConfig::default(), + enrichment: EnrichmentConfig::default(), + cloud: CloudConfig::default(), + analytics: AnalyticsConfig::default(), + photos: PhotoConfig::default(), + managed_storage: ManagedStorageConfig::default(), + sync: SyncConfig::default(), + sharing: SharingConfig::default(), } + } } #[cfg(test)] mod tests { - use super::*; + use super::*; - fn test_config_with_concurrency(concurrency: usize) -> Config { - let mut config = Config::default(); - config.scanning.import_concurrency = concurrency; - config.server.authentication_disabled = true; // Disable auth for concurrency tests - config - } + fn test_config_with_concurrency(concurrency: usize) -> Config { + let mut config = Config::default(); + config.scanning.import_concurrency = concurrency; + config.server.authentication_disabled = true; // Disable auth for concurrency tests + config + } - #[test] - fn test_validate_import_concurrency_zero() { - let config = test_config_with_concurrency(0); - assert!(config.validate().is_err()); - assert!( - config - .validate() - .unwrap_err() - .contains("import_concurrency") - ); - } + #[test] + fn test_validate_import_concurrency_zero() { + let config = test_config_with_concurrency(0); + assert!(config.validate().is_err()); + assert!( + config + .validate() + .unwrap_err() + .contains("import_concurrency") + ); + } - #[test] - fn test_validate_import_concurrency_too_high() { - let config = test_config_with_concurrency(257); - assert!(config.validate().is_err()); - assert!( - config - .validate() - .unwrap_err() - .contains("import_concurrency") - ); - } + #[test] + fn test_validate_import_concurrency_too_high() { + let config = test_config_with_concurrency(257); + assert!(config.validate().is_err()); + assert!( + config + .validate() + .unwrap_err() + .contains("import_concurrency") + ); + } - #[test] - fn test_validate_import_concurrency_valid() { - let config = test_config_with_concurrency(8); - assert!(config.validate().is_ok()); - } + #[test] + fn test_validate_import_concurrency_valid() { + let config = test_config_with_concurrency(8); + assert!(config.validate().is_ok()); + } - #[test] - fn test_validate_import_concurrency_boundary_low() { - let config = test_config_with_concurrency(1); - assert!(config.validate().is_ok()); - } + #[test] + fn test_validate_import_concurrency_boundary_low() { + let config = test_config_with_concurrency(1); + assert!(config.validate().is_ok()); + } - #[test] - fn test_validate_import_concurrency_boundary_high() { - let config = test_config_with_concurrency(256); - assert!(config.validate().is_ok()); - } + #[test] + fn test_validate_import_concurrency_boundary_high() { + let config = test_config_with_concurrency(256); + assert!(config.validate().is_ok()); + } - // Environment variable expansion tests - #[test] - fn test_expand_env_var_simple() { - unsafe { - std::env::set_var("TEST_VAR_SIMPLE", "test_value"); - } - let result = expand_env_var_string("$TEST_VAR_SIMPLE"); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), "test_value"); - unsafe { - std::env::remove_var("TEST_VAR_SIMPLE"); - } + // Environment variable expansion tests + #[test] + fn test_expand_env_var_simple() { + unsafe { + std::env::set_var("TEST_VAR_SIMPLE", "test_value"); } + let result = expand_env_var_string("$TEST_VAR_SIMPLE"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "test_value"); + unsafe { + std::env::remove_var("TEST_VAR_SIMPLE"); + } + } - #[test] - fn test_expand_env_var_braces() { - unsafe { - std::env::set_var("TEST_VAR_BRACES", "test_value"); - } - let result = expand_env_var_string("${TEST_VAR_BRACES}"); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), "test_value"); - unsafe { - std::env::remove_var("TEST_VAR_BRACES"); - } + #[test] + fn test_expand_env_var_braces() { + unsafe { + std::env::set_var("TEST_VAR_BRACES", "test_value"); } + let result = expand_env_var_string("${TEST_VAR_BRACES}"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "test_value"); + unsafe { + std::env::remove_var("TEST_VAR_BRACES"); + } + } - #[test] - fn test_expand_env_var_embedded() { - unsafe { - std::env::set_var("TEST_VAR_EMBEDDED", "value"); - } - let result = expand_env_var_string("prefix_${TEST_VAR_EMBEDDED}_suffix"); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), "prefix_value_suffix"); - unsafe { - std::env::remove_var("TEST_VAR_EMBEDDED"); - } + #[test] + fn test_expand_env_var_embedded() { + unsafe { + std::env::set_var("TEST_VAR_EMBEDDED", "value"); } + let result = expand_env_var_string("prefix_${TEST_VAR_EMBEDDED}_suffix"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "prefix_value_suffix"); + unsafe { + std::env::remove_var("TEST_VAR_EMBEDDED"); + } + } - #[test] - fn test_expand_env_var_multiple() { - unsafe { - std::env::set_var("VAR1", "value1"); - std::env::set_var("VAR2", "value2"); - } - let result = expand_env_var_string("${VAR1}_${VAR2}"); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), "value1_value2"); - unsafe { - std::env::remove_var("VAR1"); - std::env::remove_var("VAR2"); - } + #[test] + fn test_expand_env_var_multiple() { + unsafe { + std::env::set_var("VAR1", "value1"); + std::env::set_var("VAR2", "value2"); } + let result = expand_env_var_string("${VAR1}_${VAR2}"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "value1_value2"); + unsafe { + std::env::remove_var("VAR1"); + std::env::remove_var("VAR2"); + } + } - #[test] - fn test_expand_env_var_missing() { - let result = expand_env_var_string("${NONEXISTENT_VAR}"); - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("environment variable not set") - ); - } + #[test] + fn test_expand_env_var_missing() { + let result = expand_env_var_string("${NONEXISTENT_VAR}"); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("environment variable not set") + ); + } - #[test] - fn test_expand_env_var_empty_name() { - let result = expand_env_var_string("${}"); - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("empty environment variable name") - ); - } + #[test] + fn test_expand_env_var_empty_name() { + let result = expand_env_var_string("${}"); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("empty environment variable name") + ); + } - #[test] - fn test_expand_env_var_escaped() { - let result = expand_env_var_string("\\$NOT_A_VAR"); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), "$NOT_A_VAR"); - } + #[test] + fn test_expand_env_var_escaped() { + let result = expand_env_var_string("\\$NOT_A_VAR"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "$NOT_A_VAR"); + } - #[test] - fn test_expand_env_var_no_vars() { - let result = expand_env_var_string("plain_text"); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), "plain_text"); - } + #[test] + fn test_expand_env_var_no_vars() { + let result = expand_env_var_string("plain_text"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "plain_text"); + } - #[test] - fn test_expand_env_var_underscore() { - unsafe { - std::env::set_var("TEST_VAR_NAME", "value"); - } - let result = expand_env_var_string("$TEST_VAR_NAME"); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), "value"); - unsafe { - std::env::remove_var("TEST_VAR_NAME"); - } + #[test] + fn test_expand_env_var_underscore() { + unsafe { + std::env::set_var("TEST_VAR_NAME", "value"); } + let result = expand_env_var_string("$TEST_VAR_NAME"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "value"); + unsafe { + std::env::remove_var("TEST_VAR_NAME"); + } + } - #[test] - fn test_expand_env_var_mixed_syntax() { - unsafe { - std::env::set_var("VAR1_MIXED", "v1"); - std::env::set_var("VAR2_MIXED", "v2"); - } - let result = expand_env_var_string("$VAR1_MIXED and ${VAR2_MIXED}"); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), "v1 and v2"); - unsafe { - std::env::remove_var("VAR1_MIXED"); - std::env::remove_var("VAR2_MIXED"); - } + #[test] + fn test_expand_env_var_mixed_syntax() { + unsafe { + std::env::set_var("VAR1_MIXED", "v1"); + std::env::set_var("VAR2_MIXED", "v2"); } + let result = expand_env_var_string("$VAR1_MIXED and ${VAR2_MIXED}"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "v1 and v2"); + unsafe { + std::env::remove_var("VAR1_MIXED"); + std::env::remove_var("VAR2_MIXED"); + } + } } diff --git a/crates/pinakes-core/src/enrichment/books.rs b/crates/pinakes-core/src/enrichment/books.rs index 61b85e1..5882b2d 100644 --- a/crates/pinakes-core/src/enrichment/books.rs +++ b/crates/pinakes-core/src/enrichment/books.rs @@ -1,237 +1,253 @@ use chrono::Utc; use uuid::Uuid; -use crate::error::{PinakesError, Result}; -use crate::model::MediaItem; - -use super::googlebooks::GoogleBooksClient; -use super::openlibrary::OpenLibraryClient; -use super::{EnrichmentSourceType, ExternalMetadata, MetadataEnricher}; +use super::{ + EnrichmentSourceType, + ExternalMetadata, + MetadataEnricher, + googlebooks::GoogleBooksClient, + openlibrary::OpenLibraryClient, +}; +use crate::{ + error::{PinakesError, Result}, + model::MediaItem, +}; /// Book enricher that tries OpenLibrary first, then falls back to Google Books pub struct BookEnricher { - openlibrary: OpenLibraryClient, - googlebooks: GoogleBooksClient, + openlibrary: OpenLibraryClient, + googlebooks: GoogleBooksClient, } impl BookEnricher { - pub fn new(google_api_key: Option) -> Self { - Self { - openlibrary: OpenLibraryClient::new(), - googlebooks: GoogleBooksClient::new(google_api_key), - } + pub fn new(google_api_key: Option) -> Self { + Self { + openlibrary: OpenLibraryClient::new(), + googlebooks: GoogleBooksClient::new(google_api_key), + } + } + + /// Try to enrich from OpenLibrary first + pub async fn try_openlibrary( + &self, + isbn: &str, + ) -> Result> { + match self.openlibrary.fetch_by_isbn(isbn).await { + Ok(book) => { + let metadata_json = serde_json::to_string(&book).map_err(|e| { + PinakesError::External(format!("Failed to serialize metadata: {}", e)) + })?; + + Ok(Some(ExternalMetadata { + id: Uuid::new_v4(), + media_id: crate::model::MediaId(Uuid::nil()), // Will be set by caller + source: EnrichmentSourceType::OpenLibrary, + external_id: None, + metadata_json, + confidence: calculate_openlibrary_confidence(&book), + last_updated: Utc::now(), + })) + }, + Err(_) => Ok(None), + } + } + + /// Try to enrich from Google Books + pub async fn try_googlebooks( + &self, + isbn: &str, + ) -> Result> { + match self.googlebooks.fetch_by_isbn(isbn).await { + Ok(books) if !books.is_empty() => { + let book = &books[0]; + let metadata_json = serde_json::to_string(book).map_err(|e| { + PinakesError::External(format!("Failed to serialize metadata: {}", e)) + })?; + + Ok(Some(ExternalMetadata { + id: Uuid::new_v4(), + media_id: crate::model::MediaId(Uuid::nil()), // Will be set by caller + source: EnrichmentSourceType::GoogleBooks, + external_id: Some(book.id.clone()), + metadata_json, + confidence: calculate_googlebooks_confidence(&book.volume_info), + last_updated: Utc::now(), + })) + }, + _ => Ok(None), + } + } + + /// Try to enrich by searching with title and author + pub async fn enrich_by_search( + &self, + title: &str, + author: Option<&str>, + ) -> Result> { + // Try OpenLibrary search first + if let Ok(results) = self.openlibrary.search(title, author).await + && let Some(result) = results.first() + { + let metadata_json = serde_json::to_string(result).map_err(|e| { + PinakesError::External(format!("Failed to serialize metadata: {}", e)) + })?; + + return Ok(Some(ExternalMetadata { + id: Uuid::new_v4(), + media_id: crate::model::MediaId(Uuid::nil()), + source: EnrichmentSourceType::OpenLibrary, + external_id: result.key.clone(), + metadata_json, + confidence: 0.6, // Lower confidence for search results + last_updated: Utc::now(), + })); } - /// Try to enrich from OpenLibrary first - pub async fn try_openlibrary(&self, isbn: &str) -> Result> { - match self.openlibrary.fetch_by_isbn(isbn).await { - Ok(book) => { - let metadata_json = serde_json::to_string(&book).map_err(|e| { - PinakesError::External(format!("Failed to serialize metadata: {}", e)) - })?; + // Fall back to Google Books + if let Ok(results) = self.googlebooks.search(title, author).await + && let Some(book) = results.first() + { + let metadata_json = serde_json::to_string(book).map_err(|e| { + PinakesError::External(format!("Failed to serialize metadata: {}", e)) + })?; - Ok(Some(ExternalMetadata { - id: Uuid::new_v4(), - media_id: crate::model::MediaId(Uuid::nil()), // Will be set by caller - source: EnrichmentSourceType::OpenLibrary, - external_id: None, - metadata_json, - confidence: calculate_openlibrary_confidence(&book), - last_updated: Utc::now(), - })) - } - Err(_) => Ok(None), - } + return Ok(Some(ExternalMetadata { + id: Uuid::new_v4(), + media_id: crate::model::MediaId(Uuid::nil()), + source: EnrichmentSourceType::GoogleBooks, + external_id: Some(book.id.clone()), + metadata_json, + confidence: 0.6, + last_updated: Utc::now(), + })); } - /// Try to enrich from Google Books - pub async fn try_googlebooks(&self, isbn: &str) -> Result> { - match self.googlebooks.fetch_by_isbn(isbn).await { - Ok(books) if !books.is_empty() => { - let book = &books[0]; - let metadata_json = serde_json::to_string(book).map_err(|e| { - PinakesError::External(format!("Failed to serialize metadata: {}", e)) - })?; - - Ok(Some(ExternalMetadata { - id: Uuid::new_v4(), - media_id: crate::model::MediaId(Uuid::nil()), // Will be set by caller - source: EnrichmentSourceType::GoogleBooks, - external_id: Some(book.id.clone()), - metadata_json, - confidence: calculate_googlebooks_confidence(&book.volume_info), - last_updated: Utc::now(), - })) - } - _ => Ok(None), - } - } - - /// Try to enrich by searching with title and author - pub async fn enrich_by_search( - &self, - title: &str, - author: Option<&str>, - ) -> Result> { - // Try OpenLibrary search first - if let Ok(results) = self.openlibrary.search(title, author).await - && let Some(result) = results.first() - { - let metadata_json = serde_json::to_string(result).map_err(|e| { - PinakesError::External(format!("Failed to serialize metadata: {}", e)) - })?; - - return Ok(Some(ExternalMetadata { - id: Uuid::new_v4(), - media_id: crate::model::MediaId(Uuid::nil()), - source: EnrichmentSourceType::OpenLibrary, - external_id: result.key.clone(), - metadata_json, - confidence: 0.6, // Lower confidence for search results - last_updated: Utc::now(), - })); - } - - // Fall back to Google Books - if let Ok(results) = self.googlebooks.search(title, author).await - && let Some(book) = results.first() - { - let metadata_json = serde_json::to_string(book).map_err(|e| { - PinakesError::External(format!("Failed to serialize metadata: {}", e)) - })?; - - return Ok(Some(ExternalMetadata { - id: Uuid::new_v4(), - media_id: crate::model::MediaId(Uuid::nil()), - source: EnrichmentSourceType::GoogleBooks, - external_id: Some(book.id.clone()), - metadata_json, - confidence: 0.6, - last_updated: Utc::now(), - })); - } - - Ok(None) - } + Ok(None) + } } #[async_trait::async_trait] impl MetadataEnricher for BookEnricher { - fn source(&self) -> EnrichmentSourceType { - // Returns the preferred source - EnrichmentSourceType::OpenLibrary - } + fn source(&self) -> EnrichmentSourceType { + // Returns the preferred source + EnrichmentSourceType::OpenLibrary + } - async fn enrich(&self, item: &MediaItem) -> Result> { - // Try ISBN-based enrichment first by checking title/description for ISBN patterns - if let Some(ref title) = item.title { - if let Some(isbn) = crate::books::extract_isbn_from_text(title) { - if let Some(mut metadata) = self.try_openlibrary(&isbn).await? { - metadata.media_id = item.id; - return Ok(Some(metadata)); - } - if let Some(mut metadata) = self.try_googlebooks(&isbn).await? { - metadata.media_id = item.id; - return Ok(Some(metadata)); - } - } - - // Fall back to title/author search - let author = item.artist.as_deref(); - return self.enrich_by_search(title, author).await; + async fn enrich(&self, item: &MediaItem) -> Result> { + // Try ISBN-based enrichment first by checking title/description for ISBN + // patterns + if let Some(ref title) = item.title { + if let Some(isbn) = crate::books::extract_isbn_from_text(title) { + if let Some(mut metadata) = self.try_openlibrary(&isbn).await? { + metadata.media_id = item.id; + return Ok(Some(metadata)); } + if let Some(mut metadata) = self.try_googlebooks(&isbn).await? { + metadata.media_id = item.id; + return Ok(Some(metadata)); + } + } - // No title available - Ok(None) + // Fall back to title/author search + let author = item.artist.as_deref(); + return self.enrich_by_search(title, author).await; } + + // No title available + Ok(None) + } } /// Calculate confidence score for OpenLibrary metadata -pub fn calculate_openlibrary_confidence(book: &super::openlibrary::OpenLibraryBook) -> f64 { - let mut score: f64 = 0.5; // Base score +pub fn calculate_openlibrary_confidence( + book: &super::openlibrary::OpenLibraryBook, +) -> f64 { + let mut score: f64 = 0.5; // Base score - if book.title.is_some() { - score += 0.1; - } - if !book.authors.is_empty() { - score += 0.1; - } - if !book.publishers.is_empty() { - score += 0.05; - } - if book.publish_date.is_some() { - score += 0.05; - } - if book.description.is_some() { - score += 0.1; - } - if !book.covers.is_empty() { - score += 0.1; - } + if book.title.is_some() { + score += 0.1; + } + if !book.authors.is_empty() { + score += 0.1; + } + if !book.publishers.is_empty() { + score += 0.05; + } + if book.publish_date.is_some() { + score += 0.05; + } + if book.description.is_some() { + score += 0.1; + } + if !book.covers.is_empty() { + score += 0.1; + } - score.min(1.0) + score.min(1.0) } /// Calculate confidence score for Google Books metadata -pub fn calculate_googlebooks_confidence(info: &super::googlebooks::VolumeInfo) -> f64 { - let mut score: f64 = 0.5; // Base score +pub fn calculate_googlebooks_confidence( + info: &super::googlebooks::VolumeInfo, +) -> f64 { + let mut score: f64 = 0.5; // Base score - if info.title.is_some() { - score += 0.1; - } - if !info.authors.is_empty() { - score += 0.1; - } - if info.publisher.is_some() { - score += 0.05; - } - if info.published_date.is_some() { - score += 0.05; - } - if info.description.is_some() { - score += 0.1; - } - if info.image_links.is_some() { - score += 0.1; - } + if info.title.is_some() { + score += 0.1; + } + if !info.authors.is_empty() { + score += 0.1; + } + if info.publisher.is_some() { + score += 0.05; + } + if info.published_date.is_some() { + score += 0.05; + } + if info.description.is_some() { + score += 0.1; + } + if info.image_links.is_some() { + score += 0.1; + } - score.min(1.0) + score.min(1.0) } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn test_openlibrary_confidence_calculation() { - let book = super::super::openlibrary::OpenLibraryBook { - title: Some("Test Book".to_string()), - subtitle: None, - authors: vec![], - publishers: vec![], - publish_date: None, - number_of_pages: None, - subjects: vec![], - covers: vec![], - isbn_10: vec![], - isbn_13: vec![], - series: vec![], - description: None, - languages: vec![], - }; + #[test] + fn test_openlibrary_confidence_calculation() { + let book = super::super::openlibrary::OpenLibraryBook { + title: Some("Test Book".to_string()), + subtitle: None, + authors: vec![], + publishers: vec![], + publish_date: None, + number_of_pages: None, + subjects: vec![], + covers: vec![], + isbn_10: vec![], + isbn_13: vec![], + series: vec![], + description: None, + languages: vec![], + }; - let confidence = calculate_openlibrary_confidence(&book); - assert_eq!(confidence, 0.6); // 0.5 base + 0.1 for title - } + let confidence = calculate_openlibrary_confidence(&book); + assert_eq!(confidence, 0.6); // 0.5 base + 0.1 for title + } - #[test] - fn test_googlebooks_confidence_calculation() { - let info = super::super::googlebooks::VolumeInfo { - title: Some("Test Book".to_string()), - ..Default::default() - }; + #[test] + fn test_googlebooks_confidence_calculation() { + let info = super::super::googlebooks::VolumeInfo { + title: Some("Test Book".to_string()), + ..Default::default() + }; - let confidence = calculate_googlebooks_confidence(&info); - assert_eq!(confidence, 0.6); // 0.5 base + 0.1 for title - } + let confidence = calculate_googlebooks_confidence(&info); + assert_eq!(confidence, 0.6); // 0.5 base + 0.1 for title + } } diff --git a/crates/pinakes-core/src/enrichment/googlebooks.rs b/crates/pinakes-core/src/enrichment/googlebooks.rs index 59e1285..0e9bc01 100644 --- a/crates/pinakes-core/src/enrichment/googlebooks.rs +++ b/crates/pinakes-core/src/enrichment/googlebooks.rs @@ -4,274 +4,276 @@ use crate::error::{PinakesError, Result}; /// Google Books API client for book metadata enrichment pub struct GoogleBooksClient { - client: reqwest::Client, - api_key: Option, + client: reqwest::Client, + api_key: Option, } impl GoogleBooksClient { - pub fn new(api_key: Option) -> Self { - Self { - client: reqwest::Client::builder() - .user_agent("Pinakes/1.0") - .timeout(std::time::Duration::from_secs(10)) - .build() - .expect("Failed to build HTTP client"), - api_key, - } + pub fn new(api_key: Option) -> Self { + Self { + client: reqwest::Client::builder() + .user_agent("Pinakes/1.0") + .timeout(std::time::Duration::from_secs(10)) + .build() + .expect("Failed to build HTTP client"), + api_key, + } + } + + /// Fetch book metadata by ISBN + pub async fn fetch_by_isbn(&self, isbn: &str) -> Result> { + let mut url = format!( + "https://www.googleapis.com/books/v1/volumes?q=isbn:{}", + isbn + ); + + if let Some(ref key) = self.api_key { + url.push_str(&format!("&key={}", key)); } - /// Fetch book metadata by ISBN - pub async fn fetch_by_isbn(&self, isbn: &str) -> Result> { - let mut url = format!( - "https://www.googleapis.com/books/v1/volumes?q=isbn:{}", - isbn - ); + let response = self.client.get(&url).send().await.map_err(|e| { + PinakesError::External(format!("Google Books request failed: {}", e)) + })?; - if let Some(ref key) = self.api_key { - url.push_str(&format!("&key={}", key)); - } - - let response = - self.client.get(&url).send().await.map_err(|e| { - PinakesError::External(format!("Google Books request failed: {}", e)) - })?; - - if !response.status().is_success() { - return Err(PinakesError::External(format!( - "Google Books returned status: {}", - response.status() - ))); - } - - let volumes: GoogleBooksResponse = response.json().await.map_err(|e| { - PinakesError::External(format!("Failed to parse Google Books response: {}", e)) - })?; - - Ok(volumes.items) + if !response.status().is_success() { + return Err(PinakesError::External(format!( + "Google Books returned status: {}", + response.status() + ))); } - /// Search for books by title and author - pub async fn search(&self, title: &str, author: Option<&str>) -> Result> { - let mut query = format!("intitle:{}", urlencoding::encode(title)); + let volumes: GoogleBooksResponse = response.json().await.map_err(|e| { + PinakesError::External(format!( + "Failed to parse Google Books response: {}", + e + )) + })?; - if let Some(author) = author { - query.push_str(&format!("+inauthor:{}", urlencoding::encode(author))); - } + Ok(volumes.items) + } - let mut url = format!( - "https://www.googleapis.com/books/v1/volumes?q={}&maxResults=5", - query - ); + /// Search for books by title and author + pub async fn search( + &self, + title: &str, + author: Option<&str>, + ) -> Result> { + let mut query = format!("intitle:{}", urlencoding::encode(title)); - if let Some(ref key) = self.api_key { - url.push_str(&format!("&key={}", key)); - } - - let response = - self.client.get(&url).send().await.map_err(|e| { - PinakesError::External(format!("Google Books search failed: {}", e)) - })?; - - if !response.status().is_success() { - return Err(PinakesError::External(format!( - "Google Books search returned status: {}", - response.status() - ))); - } - - let volumes: GoogleBooksResponse = response.json().await.map_err(|e| { - PinakesError::External(format!("Failed to parse search results: {}", e)) - })?; - - Ok(volumes.items) + if let Some(author) = author { + query.push_str(&format!("+inauthor:{}", urlencoding::encode(author))); } - /// Download cover image from Google Books - pub async fn fetch_cover(&self, image_link: &str) -> Result> { - // Replace thumbnail link with higher resolution if possible - let high_res_link = image_link - .replace("&zoom=1", "&zoom=2") - .replace("&edge=curl", ""); + let mut url = format!( + "https://www.googleapis.com/books/v1/volumes?q={}&maxResults=5", + query + ); - let response = self - .client - .get(&high_res_link) - .send() - .await - .map_err(|e| PinakesError::External(format!("Cover download failed: {}", e)))?; - - if !response.status().is_success() { - return Err(PinakesError::External(format!( - "Cover download returned status: {}", - response.status() - ))); - } - - response - .bytes() - .await - .map(|b| b.to_vec()) - .map_err(|e| PinakesError::External(format!("Failed to read cover data: {}", e))) + if let Some(ref key) = self.api_key { + url.push_str(&format!("&key={}", key)); } + + let response = self.client.get(&url).send().await.map_err(|e| { + PinakesError::External(format!("Google Books search failed: {}", e)) + })?; + + if !response.status().is_success() { + return Err(PinakesError::External(format!( + "Google Books search returned status: {}", + response.status() + ))); + } + + let volumes: GoogleBooksResponse = response.json().await.map_err(|e| { + PinakesError::External(format!("Failed to parse search results: {}", e)) + })?; + + Ok(volumes.items) + } + + /// Download cover image from Google Books + pub async fn fetch_cover(&self, image_link: &str) -> Result> { + // Replace thumbnail link with higher resolution if possible + let high_res_link = image_link + .replace("&zoom=1", "&zoom=2") + .replace("&edge=curl", ""); + + let response = + self.client.get(&high_res_link).send().await.map_err(|e| { + PinakesError::External(format!("Cover download failed: {}", e)) + })?; + + if !response.status().is_success() { + return Err(PinakesError::External(format!( + "Cover download returned status: {}", + response.status() + ))); + } + + response.bytes().await.map(|b| b.to_vec()).map_err(|e| { + PinakesError::External(format!("Failed to read cover data: {}", e)) + }) + } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GoogleBooksResponse { - #[serde(default)] - pub items: Vec, + #[serde(default)] + pub items: Vec, - #[serde(default)] - pub total_items: i32, + #[serde(default)] + pub total_items: i32, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GoogleBook { - pub id: String, + pub id: String, - #[serde(default)] - pub volume_info: VolumeInfo, + #[serde(default)] + pub volume_info: VolumeInfo, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct VolumeInfo { - #[serde(default)] - pub title: Option, + #[serde(default)] + pub title: Option, - #[serde(default)] - pub subtitle: Option, + #[serde(default)] + pub subtitle: Option, - #[serde(default)] - pub authors: Vec, + #[serde(default)] + pub authors: Vec, - #[serde(default)] - pub publisher: Option, + #[serde(default)] + pub publisher: Option, - #[serde(default)] - pub published_date: Option, + #[serde(default)] + pub published_date: Option, - #[serde(default)] - pub description: Option, + #[serde(default)] + pub description: Option, - #[serde(default)] - pub page_count: Option, + #[serde(default)] + pub page_count: Option, - #[serde(default)] - pub categories: Vec, + #[serde(default)] + pub categories: Vec, - #[serde(default)] - pub average_rating: Option, + #[serde(default)] + pub average_rating: Option, - #[serde(default)] - pub ratings_count: Option, + #[serde(default)] + pub ratings_count: Option, - #[serde(default)] - pub image_links: Option, + #[serde(default)] + pub image_links: Option, - #[serde(default)] - pub language: Option, + #[serde(default)] + pub language: Option, - #[serde(default)] - pub industry_identifiers: Vec, + #[serde(default)] + pub industry_identifiers: Vec, - #[serde(default)] - pub main_category: Option, + #[serde(default)] + pub main_category: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ImageLinks { - #[serde(default)] - pub small_thumbnail: Option, + #[serde(default)] + pub small_thumbnail: Option, - #[serde(default)] - pub thumbnail: Option, + #[serde(default)] + pub thumbnail: Option, - #[serde(default)] - pub small: Option, + #[serde(default)] + pub small: Option, - #[serde(default)] - pub medium: Option, + #[serde(default)] + pub medium: Option, - #[serde(default)] - pub large: Option, + #[serde(default)] + pub large: Option, - #[serde(default)] - pub extra_large: Option, + #[serde(default)] + pub extra_large: Option, } impl ImageLinks { - /// Get the best available image link (highest resolution) - pub fn best_link(&self) -> Option<&String> { - self.extra_large - .as_ref() - .or(self.large.as_ref()) - .or(self.medium.as_ref()) - .or(self.small.as_ref()) - .or(self.thumbnail.as_ref()) - .or(self.small_thumbnail.as_ref()) - } + /// Get the best available image link (highest resolution) + pub fn best_link(&self) -> Option<&String> { + self + .extra_large + .as_ref() + .or(self.large.as_ref()) + .or(self.medium.as_ref()) + .or(self.small.as_ref()) + .or(self.thumbnail.as_ref()) + .or(self.small_thumbnail.as_ref()) + } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IndustryIdentifier { - #[serde(rename = "type")] - pub identifier_type: String, + #[serde(rename = "type")] + pub identifier_type: String, - pub identifier: String, + pub identifier: String, } impl IndustryIdentifier { - /// Check if this is an ISBN-13 - pub fn is_isbn13(&self) -> bool { - self.identifier_type == "ISBN_13" - } + /// Check if this is an ISBN-13 + pub fn is_isbn13(&self) -> bool { + self.identifier_type == "ISBN_13" + } - /// Check if this is an ISBN-10 - pub fn is_isbn10(&self) -> bool { - self.identifier_type == "ISBN_10" - } + /// Check if this is an ISBN-10 + pub fn is_isbn10(&self) -> bool { + self.identifier_type == "ISBN_10" + } } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn test_googlebooks_client_creation() { - let client = GoogleBooksClient::new(None); - assert!(client.api_key.is_none()); + #[test] + fn test_googlebooks_client_creation() { + let client = GoogleBooksClient::new(None); + assert!(client.api_key.is_none()); - let client_with_key = GoogleBooksClient::new(Some("test-key".to_string())); - assert_eq!(client_with_key.api_key, Some("test-key".to_string())); - } + let client_with_key = GoogleBooksClient::new(Some("test-key".to_string())); + assert_eq!(client_with_key.api_key, Some("test-key".to_string())); + } - #[test] - fn test_image_links_best_link() { - let links = ImageLinks { - small_thumbnail: Some("small.jpg".to_string()), - thumbnail: Some("thumb.jpg".to_string()), - small: None, - medium: Some("medium.jpg".to_string()), - large: Some("large.jpg".to_string()), - extra_large: None, - }; + #[test] + fn test_image_links_best_link() { + let links = ImageLinks { + small_thumbnail: Some("small.jpg".to_string()), + thumbnail: Some("thumb.jpg".to_string()), + small: None, + medium: Some("medium.jpg".to_string()), + large: Some("large.jpg".to_string()), + extra_large: None, + }; - assert_eq!(links.best_link(), Some(&"large.jpg".to_string())); - } + assert_eq!(links.best_link(), Some(&"large.jpg".to_string())); + } - #[test] - fn test_industry_identifier_type_checks() { - let isbn13 = IndustryIdentifier { - identifier_type: "ISBN_13".to_string(), - identifier: "9780123456789".to_string(), - }; - assert!(isbn13.is_isbn13()); - assert!(!isbn13.is_isbn10()); + #[test] + fn test_industry_identifier_type_checks() { + let isbn13 = IndustryIdentifier { + identifier_type: "ISBN_13".to_string(), + identifier: "9780123456789".to_string(), + }; + assert!(isbn13.is_isbn13()); + assert!(!isbn13.is_isbn10()); - let isbn10 = IndustryIdentifier { - identifier_type: "ISBN_10".to_string(), - identifier: "0123456789".to_string(), - }; - assert!(!isbn10.is_isbn13()); - assert!(isbn10.is_isbn10()); - } + let isbn10 = IndustryIdentifier { + identifier_type: "ISBN_10".to_string(), + identifier: "0123456789".to_string(), + }; + assert!(!isbn10.is_isbn13()); + assert!(isbn10.is_isbn10()); + } } diff --git a/crates/pinakes-core/src/enrichment/lastfm.rs b/crates/pinakes-core/src/enrichment/lastfm.rs index 260f4fc..63c7d60 100644 --- a/crates/pinakes-core/src/enrichment/lastfm.rs +++ b/crates/pinakes-core/src/enrichment/lastfm.rs @@ -5,105 +5,110 @@ use std::time::Duration; use chrono::Utc; use uuid::Uuid; -use crate::error::{PinakesError, Result}; -use crate::model::MediaItem; - use super::{EnrichmentSourceType, ExternalMetadata, MetadataEnricher}; +use crate::{ + error::{PinakesError, Result}, + model::MediaItem, +}; pub struct LastFmEnricher { - client: reqwest::Client, - api_key: String, - base_url: String, + client: reqwest::Client, + api_key: String, + base_url: String, } impl LastFmEnricher { - pub fn new(api_key: String) -> Self { - Self { - client: reqwest::Client::builder() - .timeout(Duration::from_secs(10)) - .connect_timeout(Duration::from_secs(5)) - .build() - .expect("failed to build HTTP client with configured timeouts"), - api_key, - base_url: "https://ws.audioscrobbler.com/2.0".to_string(), - } + pub fn new(api_key: String) -> Self { + Self { + client: reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .connect_timeout(Duration::from_secs(5)) + .build() + .expect("failed to build HTTP client with configured timeouts"), + api_key, + base_url: "https://ws.audioscrobbler.com/2.0".to_string(), } + } } #[async_trait::async_trait] impl MetadataEnricher for LastFmEnricher { - fn source(&self) -> EnrichmentSourceType { - EnrichmentSourceType::LastFm + fn source(&self) -> EnrichmentSourceType { + EnrichmentSourceType::LastFm + } + + async fn enrich(&self, item: &MediaItem) -> Result> { + let artist = match &item.artist { + Some(a) if !a.is_empty() => a, + _ => return Ok(None), + }; + + let title = match &item.title { + Some(t) if !t.is_empty() => t, + _ => return Ok(None), + }; + + let url = format!("{}/", self.base_url); + + let resp = self + .client + .get(&url) + .query(&[ + ("method", "track.getInfo"), + ("api_key", self.api_key.as_str()), + ("artist", artist.as_str()), + ("track", title.as_str()), + ("format", "json"), + ]) + .send() + .await + .map_err(|e| { + PinakesError::MetadataExtraction(format!("Last.fm request failed: {e}")) + })?; + + if !resp.status().is_success() { + return Ok(None); } - async fn enrich(&self, item: &MediaItem) -> Result> { - let artist = match &item.artist { - Some(a) if !a.is_empty() => a, - _ => return Ok(None), - }; + let body = resp.text().await.map_err(|e| { + PinakesError::MetadataExtraction(format!( + "Last.fm response read failed: {e}" + )) + })?; - let title = match &item.title { - Some(t) if !t.is_empty() => t, - _ => return Ok(None), - }; + let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| { + PinakesError::MetadataExtraction(format!( + "Last.fm JSON parse failed: {e}" + )) + })?; - let url = format!("{}/", self.base_url); - - let resp = self - .client - .get(&url) - .query(&[ - ("method", "track.getInfo"), - ("api_key", self.api_key.as_str()), - ("artist", artist.as_str()), - ("track", title.as_str()), - ("format", "json"), - ]) - .send() - .await - .map_err(|e| { - PinakesError::MetadataExtraction(format!("Last.fm request failed: {e}")) - })?; - - if !resp.status().is_success() { - return Ok(None); - } - - let body = resp.text().await.map_err(|e| { - PinakesError::MetadataExtraction(format!("Last.fm response read failed: {e}")) - })?; - - let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| { - PinakesError::MetadataExtraction(format!("Last.fm JSON parse failed: {e}")) - })?; - - // Check for error response - if json.get("error").is_some() { - return Ok(None); - } - - let track = match json.get("track") { - Some(t) => t, - None => return Ok(None), - }; - - let mbid = track.get("mbid").and_then(|m| m.as_str()).map(String::from); - let listeners = track - .get("listeners") - .and_then(|l| l.as_str()) - .and_then(|l| l.parse::().ok()) - .unwrap_or(0.0); - // Normalize listeners to confidence (arbitrary scale) - let confidence = (listeners / 1_000_000.0).min(1.0); - - Ok(Some(ExternalMetadata { - id: Uuid::now_v7(), - media_id: item.id, - source: EnrichmentSourceType::LastFm, - external_id: mbid, - metadata_json: body, - confidence, - last_updated: Utc::now(), - })) + // Check for error response + if json.get("error").is_some() { + return Ok(None); } + + let track = match json.get("track") { + Some(t) => t, + None => return Ok(None), + }; + + let mbid = track.get("mbid").and_then(|m| m.as_str()).map(String::from); + let listeners = track + .get("listeners") + .and_then(|l| l.as_str()) + .and_then(|l| l.parse::().ok()) + .unwrap_or(0.0); + // Normalize listeners to confidence (arbitrary scale) + let confidence = (listeners / 1_000_000.0).min(1.0); + + Ok(Some(ExternalMetadata { + id: Uuid::now_v7(), + media_id: item.id, + source: EnrichmentSourceType::LastFm, + external_id: mbid, + metadata_json: body, + confidence, + last_updated: Utc::now(), + })) + } } diff --git a/crates/pinakes-core/src/enrichment/mod.rs b/crates/pinakes-core/src/enrichment/mod.rs index f79676c..16de3cb 100644 --- a/crates/pinakes-core/src/enrichment/mod.rs +++ b/crates/pinakes-core/src/enrichment/mod.rs @@ -11,67 +11,69 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::error::Result; -use crate::model::{MediaId, MediaItem}; +use crate::{ + error::Result, + model::{MediaId, MediaItem}, +}; /// Externally-sourced metadata for a media item. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ExternalMetadata { - pub id: Uuid, - pub media_id: MediaId, - pub source: EnrichmentSourceType, - pub external_id: Option, - pub metadata_json: String, - pub confidence: f64, - pub last_updated: DateTime, + pub id: Uuid, + pub media_id: MediaId, + pub source: EnrichmentSourceType, + pub external_id: Option, + pub metadata_json: String, + pub confidence: f64, + pub last_updated: DateTime, } /// Supported enrichment data sources. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum EnrichmentSourceType { - #[serde(rename = "musicbrainz")] - MusicBrainz, - #[serde(rename = "tmdb")] - Tmdb, - #[serde(rename = "lastfm")] - LastFm, - #[serde(rename = "openlibrary")] - OpenLibrary, - #[serde(rename = "googlebooks")] - GoogleBooks, + #[serde(rename = "musicbrainz")] + MusicBrainz, + #[serde(rename = "tmdb")] + Tmdb, + #[serde(rename = "lastfm")] + LastFm, + #[serde(rename = "openlibrary")] + OpenLibrary, + #[serde(rename = "googlebooks")] + GoogleBooks, } impl std::fmt::Display for EnrichmentSourceType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let s = match self { - Self::MusicBrainz => "musicbrainz", - Self::Tmdb => "tmdb", - Self::LastFm => "lastfm", - Self::OpenLibrary => "openlibrary", - Self::GoogleBooks => "googlebooks", - }; - write!(f, "{s}") - } + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + Self::MusicBrainz => "musicbrainz", + Self::Tmdb => "tmdb", + Self::LastFm => "lastfm", + Self::OpenLibrary => "openlibrary", + Self::GoogleBooks => "googlebooks", + }; + write!(f, "{s}") + } } impl std::str::FromStr for EnrichmentSourceType { - type Err = String; + type Err = String; - fn from_str(s: &str) -> std::result::Result { - match s { - "musicbrainz" => Ok(Self::MusicBrainz), - "tmdb" => Ok(Self::Tmdb), - "lastfm" => Ok(Self::LastFm), - "openlibrary" => Ok(Self::OpenLibrary), - "googlebooks" => Ok(Self::GoogleBooks), - _ => Err(format!("unknown enrichment source: {s}")), - } + fn from_str(s: &str) -> std::result::Result { + match s { + "musicbrainz" => Ok(Self::MusicBrainz), + "tmdb" => Ok(Self::Tmdb), + "lastfm" => Ok(Self::LastFm), + "openlibrary" => Ok(Self::OpenLibrary), + "googlebooks" => Ok(Self::GoogleBooks), + _ => Err(format!("unknown enrichment source: {s}")), } + } } /// Trait for metadata enrichment providers. #[async_trait::async_trait] pub trait MetadataEnricher: Send + Sync { - fn source(&self) -> EnrichmentSourceType; - async fn enrich(&self, item: &MediaItem) -> Result>; + fn source(&self) -> EnrichmentSourceType; + async fn enrich(&self, item: &MediaItem) -> Result>; } diff --git a/crates/pinakes-core/src/enrichment/musicbrainz.rs b/crates/pinakes-core/src/enrichment/musicbrainz.rs index 3d5c779..77669d2 100644 --- a/crates/pinakes-core/src/enrichment/musicbrainz.rs +++ b/crates/pinakes-core/src/enrichment/musicbrainz.rs @@ -5,130 +5,137 @@ use std::time::Duration; use chrono::Utc; use uuid::Uuid; -use crate::error::{PinakesError, Result}; -use crate::model::MediaItem; - use super::{EnrichmentSourceType, ExternalMetadata, MetadataEnricher}; +use crate::{ + error::{PinakesError, Result}, + model::MediaItem, +}; pub struct MusicBrainzEnricher { - client: reqwest::Client, - base_url: String, + client: reqwest::Client, + base_url: String, } impl Default for MusicBrainzEnricher { - fn default() -> Self { - Self::new() - } + fn default() -> Self { + Self::new() + } } impl MusicBrainzEnricher { - pub fn new() -> Self { - Self { - client: reqwest::Client::builder() - .user_agent("Pinakes/0.1 (https://github.com/notashelf/pinakes)") - .timeout(Duration::from_secs(10)) - .connect_timeout(Duration::from_secs(5)) - .build() - .expect("failed to build HTTP client with configured timeouts"), - base_url: "https://musicbrainz.org/ws/2".to_string(), - } + pub fn new() -> Self { + Self { + client: reqwest::Client::builder() + .user_agent("Pinakes/0.1 (https://github.com/notashelf/pinakes)") + .timeout(Duration::from_secs(10)) + .connect_timeout(Duration::from_secs(5)) + .build() + .expect("failed to build HTTP client with configured timeouts"), + base_url: "https://musicbrainz.org/ws/2".to_string(), } + } } fn escape_lucene_query(s: &str) -> String { - let special_chars = [ - '+', '-', '&', '|', '!', '(', ')', '{', '}', '[', ']', '^', '"', '~', '*', '?', ':', '\\', - '/', - ]; - let mut escaped = String::with_capacity(s.len() * 2); - for c in s.chars() { - if special_chars.contains(&c) { - escaped.push('\\'); - } - escaped.push(c); + let special_chars = [ + '+', '-', '&', '|', '!', '(', ')', '{', '}', '[', ']', '^', '"', '~', '*', + '?', ':', '\\', '/', + ]; + let mut escaped = String::with_capacity(s.len() * 2); + for c in s.chars() { + if special_chars.contains(&c) { + escaped.push('\\'); } - escaped + escaped.push(c); + } + escaped } #[async_trait::async_trait] impl MetadataEnricher for MusicBrainzEnricher { - fn source(&self) -> EnrichmentSourceType { - EnrichmentSourceType::MusicBrainz + fn source(&self) -> EnrichmentSourceType { + EnrichmentSourceType::MusicBrainz + } + + async fn enrich(&self, item: &MediaItem) -> Result> { + let title = match &item.title { + Some(t) if !t.is_empty() => t, + _ => return Ok(None), + }; + + let mut query = format!("recording:{}", escape_lucene_query(title)); + if let Some(ref artist) = item.artist { + query.push_str(&format!(" AND artist:{}", escape_lucene_query(artist))); } - async fn enrich(&self, item: &MediaItem) -> Result> { - let title = match &item.title { - Some(t) if !t.is_empty() => t, - _ => return Ok(None), - }; + let url = format!("{}/recording/", self.base_url); - let mut query = format!("recording:{}", escape_lucene_query(title)); - if let Some(ref artist) = item.artist { - query.push_str(&format!(" AND artist:{}", escape_lucene_query(artist))); - } + let resp = self + .client + .get(&url) + .query(&[ + ("query", &query), + ("fmt", &"json".to_string()), + ("limit", &"1".to_string()), + ]) + .send() + .await + .map_err(|e| { + PinakesError::MetadataExtraction(format!( + "MusicBrainz request failed: {e}" + )) + })?; - let url = format!("{}/recording/", self.base_url); - - let resp = self - .client - .get(&url) - .query(&[ - ("query", &query), - ("fmt", &"json".to_string()), - ("limit", &"1".to_string()), - ]) - .send() - .await - .map_err(|e| { - PinakesError::MetadataExtraction(format!("MusicBrainz request failed: {e}")) - })?; - - if !resp.status().is_success() { - let status = resp.status(); - if status == reqwest::StatusCode::TOO_MANY_REQUESTS - || status == reqwest::StatusCode::SERVICE_UNAVAILABLE - { - return Err(PinakesError::MetadataExtraction(format!( - "MusicBrainz rate limited (HTTP {})", - status.as_u16() - ))); - } - return Ok(None); - } - - let body = resp.text().await.map_err(|e| { - PinakesError::MetadataExtraction(format!("MusicBrainz response read failed: {e}")) - })?; - - // Parse to check if we got results - let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| { - PinakesError::MetadataExtraction(format!("MusicBrainz JSON parse failed: {e}")) - })?; - - let recordings = json.get("recordings").and_then(|r| r.as_array()); - if recordings.is_none_or(|r| r.is_empty()) { - return Ok(None); - } - - let recording = &recordings.unwrap()[0]; - let external_id = recording - .get("id") - .and_then(|id| id.as_str()) - .map(String::from); - let score = recording - .get("score") - .and_then(|s| s.as_f64()) - .unwrap_or(0.0) - / 100.0; - - Ok(Some(ExternalMetadata { - id: Uuid::now_v7(), - media_id: item.id, - source: EnrichmentSourceType::MusicBrainz, - external_id, - metadata_json: body, - confidence: score, - last_updated: Utc::now(), - })) + if !resp.status().is_success() { + let status = resp.status(); + if status == reqwest::StatusCode::TOO_MANY_REQUESTS + || status == reqwest::StatusCode::SERVICE_UNAVAILABLE + { + return Err(PinakesError::MetadataExtraction(format!( + "MusicBrainz rate limited (HTTP {})", + status.as_u16() + ))); + } + return Ok(None); } + + let body = resp.text().await.map_err(|e| { + PinakesError::MetadataExtraction(format!( + "MusicBrainz response read failed: {e}" + )) + })?; + + // Parse to check if we got results + let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| { + PinakesError::MetadataExtraction(format!( + "MusicBrainz JSON parse failed: {e}" + )) + })?; + + let recordings = json.get("recordings").and_then(|r| r.as_array()); + if recordings.is_none_or(|r| r.is_empty()) { + return Ok(None); + } + + let recording = &recordings.unwrap()[0]; + let external_id = recording + .get("id") + .and_then(|id| id.as_str()) + .map(String::from); + let score = recording + .get("score") + .and_then(|s| s.as_f64()) + .unwrap_or(0.0) + / 100.0; + + Ok(Some(ExternalMetadata { + id: Uuid::now_v7(), + media_id: item.id, + source: EnrichmentSourceType::MusicBrainz, + external_id, + metadata_json: body, + confidence: score, + last_updated: Utc::now(), + })) + } } diff --git a/crates/pinakes-core/src/enrichment/openlibrary.rs b/crates/pinakes-core/src/enrichment/openlibrary.rs index 9be9b20..df1c517 100644 --- a/crates/pinakes-core/src/enrichment/openlibrary.rs +++ b/crates/pinakes-core/src/enrichment/openlibrary.rs @@ -4,285 +4,284 @@ use crate::error::{PinakesError, Result}; /// OpenLibrary API client for book metadata enrichment pub struct OpenLibraryClient { - client: reqwest::Client, - base_url: String, + client: reqwest::Client, + base_url: String, } impl Default for OpenLibraryClient { - fn default() -> Self { - Self::new() - } + fn default() -> Self { + Self::new() + } } impl OpenLibraryClient { - pub fn new() -> Self { - Self { - client: reqwest::Client::builder() - .user_agent("Pinakes/1.0") - .timeout(std::time::Duration::from_secs(10)) - .build() - .expect("Failed to build HTTP client"), - base_url: "https://openlibrary.org".to_string(), - } + pub fn new() -> Self { + Self { + client: reqwest::Client::builder() + .user_agent("Pinakes/1.0") + .timeout(std::time::Duration::from_secs(10)) + .build() + .expect("Failed to build HTTP client"), + base_url: "https://openlibrary.org".to_string(), + } + } + + /// Fetch book metadata by ISBN + pub async fn fetch_by_isbn(&self, isbn: &str) -> Result { + let url = format!("{}/isbn/{}.json", self.base_url, isbn); + + let response = self.client.get(&url).send().await.map_err(|e| { + PinakesError::External(format!("OpenLibrary request failed: {}", e)) + })?; + + if !response.status().is_success() { + return Err(PinakesError::External(format!( + "OpenLibrary returned status: {}", + response.status() + ))); } - /// Fetch book metadata by ISBN - pub async fn fetch_by_isbn(&self, isbn: &str) -> Result { - let url = format!("{}/isbn/{}.json", self.base_url, isbn); + response.json::().await.map_err(|e| { + PinakesError::External(format!( + "Failed to parse OpenLibrary response: {}", + e + )) + }) + } - let response = - self.client.get(&url).send().await.map_err(|e| { - PinakesError::External(format!("OpenLibrary request failed: {}", e)) - })?; + /// Search for books by title and author + pub async fn search( + &self, + title: &str, + author: Option<&str>, + ) -> Result> { + let mut url = format!( + "{}/search.json?title={}", + self.base_url, + urlencoding::encode(title) + ); - if !response.status().is_success() { - return Err(PinakesError::External(format!( - "OpenLibrary returned status: {}", - response.status() - ))); - } - - response.json::().await.map_err(|e| { - PinakesError::External(format!("Failed to parse OpenLibrary response: {}", e)) - }) + if let Some(author) = author { + url.push_str(&format!("&author={}", urlencoding::encode(author))); } - /// Search for books by title and author - pub async fn search( - &self, - title: &str, - author: Option<&str>, - ) -> Result> { - let mut url = format!( - "{}/search.json?title={}", - self.base_url, - urlencoding::encode(title) - ); + url.push_str("&limit=5"); - if let Some(author) = author { - url.push_str(&format!("&author={}", urlencoding::encode(author))); - } + let response = self.client.get(&url).send().await.map_err(|e| { + PinakesError::External(format!("OpenLibrary search failed: {}", e)) + })?; - url.push_str("&limit=5"); - - let response = self - .client - .get(&url) - .send() - .await - .map_err(|e| PinakesError::External(format!("OpenLibrary search failed: {}", e)))?; - - if !response.status().is_success() { - return Err(PinakesError::External(format!( - "OpenLibrary search returned status: {}", - response.status() - ))); - } - - let search_response: OpenLibrarySearchResponse = response.json().await.map_err(|e| { - PinakesError::External(format!("Failed to parse search results: {}", e)) - })?; - - Ok(search_response.docs) + if !response.status().is_success() { + return Err(PinakesError::External(format!( + "OpenLibrary search returned status: {}", + response.status() + ))); } - /// Fetch cover image by cover ID - pub async fn fetch_cover(&self, cover_id: i64, size: CoverSize) -> Result> { - let size_str = match size { - CoverSize::Small => "S", - CoverSize::Medium => "M", - CoverSize::Large => "L", - }; + let search_response: OpenLibrarySearchResponse = + response.json().await.map_err(|e| { + PinakesError::External(format!("Failed to parse search results: {}", e)) + })?; - let url = format!( - "https://covers.openlibrary.org/b/id/{}-{}.jpg", - cover_id, size_str - ); + Ok(search_response.docs) + } - let response = self - .client - .get(&url) - .send() - .await - .map_err(|e| PinakesError::External(format!("Cover download failed: {}", e)))?; + /// Fetch cover image by cover ID + pub async fn fetch_cover( + &self, + cover_id: i64, + size: CoverSize, + ) -> Result> { + let size_str = match size { + CoverSize::Small => "S", + CoverSize::Medium => "M", + CoverSize::Large => "L", + }; - if !response.status().is_success() { - return Err(PinakesError::External(format!( - "Cover download returned status: {}", - response.status() - ))); - } + let url = format!( + "https://covers.openlibrary.org/b/id/{}-{}.jpg", + cover_id, size_str + ); - response - .bytes() - .await - .map(|b| b.to_vec()) - .map_err(|e| PinakesError::External(format!("Failed to read cover data: {}", e))) + let response = self.client.get(&url).send().await.map_err(|e| { + PinakesError::External(format!("Cover download failed: {}", e)) + })?; + + if !response.status().is_success() { + return Err(PinakesError::External(format!( + "Cover download returned status: {}", + response.status() + ))); } - /// Fetch cover by ISBN - pub async fn fetch_cover_by_isbn(&self, isbn: &str, size: CoverSize) -> Result> { - let size_str = match size { - CoverSize::Small => "S", - CoverSize::Medium => "M", - CoverSize::Large => "L", - }; + response.bytes().await.map(|b| b.to_vec()).map_err(|e| { + PinakesError::External(format!("Failed to read cover data: {}", e)) + }) + } - let url = format!( - "https://covers.openlibrary.org/b/isbn/{}-{}.jpg", - isbn, size_str - ); + /// Fetch cover by ISBN + pub async fn fetch_cover_by_isbn( + &self, + isbn: &str, + size: CoverSize, + ) -> Result> { + let size_str = match size { + CoverSize::Small => "S", + CoverSize::Medium => "M", + CoverSize::Large => "L", + }; - let response = self - .client - .get(&url) - .send() - .await - .map_err(|e| PinakesError::External(format!("Cover download failed: {}", e)))?; + let url = format!( + "https://covers.openlibrary.org/b/isbn/{}-{}.jpg", + isbn, size_str + ); - if !response.status().is_success() { - return Err(PinakesError::External(format!( - "Cover download returned status: {}", - response.status() - ))); - } + let response = self.client.get(&url).send().await.map_err(|e| { + PinakesError::External(format!("Cover download failed: {}", e)) + })?; - response - .bytes() - .await - .map(|b| b.to_vec()) - .map_err(|e| PinakesError::External(format!("Failed to read cover data: {}", e))) + if !response.status().is_success() { + return Err(PinakesError::External(format!( + "Cover download returned status: {}", + response.status() + ))); } + + response.bytes().await.map(|b| b.to_vec()).map_err(|e| { + PinakesError::External(format!("Failed to read cover data: {}", e)) + }) + } } #[derive(Debug, Clone, Copy)] pub enum CoverSize { - Small, // 256x256 - Medium, // 600x800 - Large, // Original + Small, // 256x256 + Medium, // 600x800 + Large, // Original } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OpenLibraryBook { - #[serde(default)] - pub title: Option, + #[serde(default)] + pub title: Option, - #[serde(default)] - pub subtitle: Option, + #[serde(default)] + pub subtitle: Option, - #[serde(default)] - pub authors: Vec, + #[serde(default)] + pub authors: Vec, - #[serde(default)] - pub publishers: Vec, + #[serde(default)] + pub publishers: Vec, - #[serde(default)] - pub publish_date: Option, + #[serde(default)] + pub publish_date: Option, - #[serde(default)] - pub number_of_pages: Option, + #[serde(default)] + pub number_of_pages: Option, - #[serde(default)] - pub subjects: Vec, + #[serde(default)] + pub subjects: Vec, - #[serde(default)] - pub covers: Vec, + #[serde(default)] + pub covers: Vec, - #[serde(default)] - pub isbn_10: Vec, + #[serde(default)] + pub isbn_10: Vec, - #[serde(default)] - pub isbn_13: Vec, + #[serde(default)] + pub isbn_13: Vec, - #[serde(default)] - pub series: Vec, + #[serde(default)] + pub series: Vec, - #[serde(default)] - pub description: Option, + #[serde(default)] + pub description: Option, - #[serde(default)] - pub languages: Vec, + #[serde(default)] + pub languages: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuthorRef { - pub key: String, + pub key: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LanguageRef { - pub key: String, + pub key: String, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum StringOrObject { - String(String), - Object { value: String }, + String(String), + Object { value: String }, } impl StringOrObject { - pub fn as_str(&self) -> &str { - match self { - Self::String(s) => s, - Self::Object { value } => value, - } + pub fn as_str(&self) -> &str { + match self { + Self::String(s) => s, + Self::Object { value } => value, } + } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OpenLibrarySearchResponse { - #[serde(default)] - pub docs: Vec, + #[serde(default)] + pub docs: Vec, - #[serde(default)] - pub num_found: i32, + #[serde(default)] + pub num_found: i32, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OpenLibrarySearchResult { - #[serde(default)] - pub key: Option, + #[serde(default)] + pub key: Option, - #[serde(default)] - pub title: Option, + #[serde(default)] + pub title: Option, - #[serde(default)] - pub author_name: Vec, + #[serde(default)] + pub author_name: Vec, - #[serde(default)] - pub first_publish_year: Option, + #[serde(default)] + pub first_publish_year: Option, - #[serde(default)] - pub publisher: Vec, + #[serde(default)] + pub publisher: Vec, - #[serde(default)] - pub isbn: Vec, + #[serde(default)] + pub isbn: Vec, - #[serde(default)] - pub cover_i: Option, + #[serde(default)] + pub cover_i: Option, - #[serde(default)] - pub subject: Vec, + #[serde(default)] + pub subject: Vec, } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[tokio::test] - async fn test_openlibrary_client_creation() { - let client = OpenLibraryClient::new(); - assert_eq!(client.base_url, "https://openlibrary.org"); - } + #[tokio::test] + async fn test_openlibrary_client_creation() { + let client = OpenLibraryClient::new(); + assert_eq!(client.base_url, "https://openlibrary.org"); + } - #[test] - fn test_string_or_object_parsing() { - let string_desc: StringOrObject = serde_json::from_str(r#""Simple description""#).unwrap(); - assert_eq!(string_desc.as_str(), "Simple description"); + #[test] + fn test_string_or_object_parsing() { + let string_desc: StringOrObject = + serde_json::from_str(r#""Simple description""#).unwrap(); + assert_eq!(string_desc.as_str(), "Simple description"); - let object_desc: StringOrObject = - serde_json::from_str(r#"{"value": "Object description"}"#).unwrap(); - assert_eq!(object_desc.as_str(), "Object description"); - } + let object_desc: StringOrObject = + serde_json::from_str(r#"{"value": "Object description"}"#).unwrap(); + assert_eq!(object_desc.as_str(), "Object description"); + } } diff --git a/crates/pinakes-core/src/enrichment/tmdb.rs b/crates/pinakes-core/src/enrichment/tmdb.rs index 28d0a8c..ed04da8 100644 --- a/crates/pinakes-core/src/enrichment/tmdb.rs +++ b/crates/pinakes-core/src/enrichment/tmdb.rs @@ -5,105 +5,110 @@ use std::time::Duration; use chrono::Utc; use uuid::Uuid; -use crate::error::{PinakesError, Result}; -use crate::model::MediaItem; - use super::{EnrichmentSourceType, ExternalMetadata, MetadataEnricher}; +use crate::{ + error::{PinakesError, Result}, + model::MediaItem, +}; pub struct TmdbEnricher { - client: reqwest::Client, - api_key: String, - base_url: String, + client: reqwest::Client, + api_key: String, + base_url: String, } impl TmdbEnricher { - pub fn new(api_key: String) -> Self { - Self { - client: reqwest::Client::builder() - .timeout(Duration::from_secs(10)) - .connect_timeout(Duration::from_secs(5)) - .build() - .expect("failed to build HTTP client with configured timeouts"), - api_key, - base_url: "https://api.themoviedb.org/3".to_string(), - } + pub fn new(api_key: String) -> Self { + Self { + client: reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .connect_timeout(Duration::from_secs(5)) + .build() + .expect("failed to build HTTP client with configured timeouts"), + api_key, + base_url: "https://api.themoviedb.org/3".to_string(), } + } } #[async_trait::async_trait] impl MetadataEnricher for TmdbEnricher { - fn source(&self) -> EnrichmentSourceType { - EnrichmentSourceType::Tmdb + fn source(&self) -> EnrichmentSourceType { + EnrichmentSourceType::Tmdb + } + + async fn enrich(&self, item: &MediaItem) -> Result> { + let title = match &item.title { + Some(t) if !t.is_empty() => t, + _ => return Ok(None), + }; + + let url = format!("{}/search/movie", self.base_url); + + let resp = self + .client + .get(&url) + .query(&[ + ("api_key", &self.api_key), + ("query", &title.to_string()), + ("page", &"1".to_string()), + ]) + .send() + .await + .map_err(|e| { + PinakesError::MetadataExtraction(format!("TMDB request failed: {e}")) + })?; + + if !resp.status().is_success() { + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED { + return Err(PinakesError::MetadataExtraction( + "TMDB API key is invalid (401)".into(), + )); + } + if status == reqwest::StatusCode::TOO_MANY_REQUESTS { + tracing::warn!("TMDB rate limit exceeded (429)"); + return Ok(None); + } + tracing::debug!(status = %status, "TMDB search returned non-success status"); + return Ok(None); } - async fn enrich(&self, item: &MediaItem) -> Result> { - let title = match &item.title { - Some(t) if !t.is_empty() => t, - _ => return Ok(None), - }; + let body = resp.text().await.map_err(|e| { + PinakesError::MetadataExtraction(format!( + "TMDB response read failed: {e}" + )) + })?; - let url = format!("{}/search/movie", self.base_url); + let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| { + PinakesError::MetadataExtraction(format!("TMDB JSON parse failed: {e}")) + })?; - let resp = self - .client - .get(&url) - .query(&[ - ("api_key", &self.api_key), - ("query", &title.to_string()), - ("page", &"1".to_string()), - ]) - .send() - .await - .map_err(|e| PinakesError::MetadataExtraction(format!("TMDB request failed: {e}")))?; - - if !resp.status().is_success() { - let status = resp.status(); - if status == reqwest::StatusCode::UNAUTHORIZED { - return Err(PinakesError::MetadataExtraction( - "TMDB API key is invalid (401)".into(), - )); - } - if status == reqwest::StatusCode::TOO_MANY_REQUESTS { - tracing::warn!("TMDB rate limit exceeded (429)"); - return Ok(None); - } - tracing::debug!(status = %status, "TMDB search returned non-success status"); - return Ok(None); - } - - let body = resp.text().await.map_err(|e| { - PinakesError::MetadataExtraction(format!("TMDB response read failed: {e}")) - })?; - - let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| { - PinakesError::MetadataExtraction(format!("TMDB JSON parse failed: {e}")) - })?; - - let results = json.get("results").and_then(|r| r.as_array()); - if results.is_none_or(|r| r.is_empty()) { - return Ok(None); - } - - let movie = &results.unwrap()[0]; - let external_id = match movie.get("id").and_then(|id| id.as_i64()) { - Some(id) => id.to_string(), - None => return Ok(None), - }; - let popularity = movie - .get("popularity") - .and_then(|p| p.as_f64()) - .unwrap_or(0.0); - // Normalize popularity to 0-1 range (TMDB popularity can be very high) - let confidence = (popularity / 100.0).min(1.0); - - Ok(Some(ExternalMetadata { - id: Uuid::now_v7(), - media_id: item.id, - source: EnrichmentSourceType::Tmdb, - external_id: Some(external_id), - metadata_json: body, - confidence, - last_updated: Utc::now(), - })) + let results = json.get("results").and_then(|r| r.as_array()); + if results.is_none_or(|r| r.is_empty()) { + return Ok(None); } + + let movie = &results.unwrap()[0]; + let external_id = match movie.get("id").and_then(|id| id.as_i64()) { + Some(id) => id.to_string(), + None => return Ok(None), + }; + let popularity = movie + .get("popularity") + .and_then(|p| p.as_f64()) + .unwrap_or(0.0); + // Normalize popularity to 0-1 range (TMDB popularity can be very high) + let confidence = (popularity / 100.0).min(1.0); + + Ok(Some(ExternalMetadata { + id: Uuid::now_v7(), + media_id: item.id, + source: EnrichmentSourceType::Tmdb, + external_id: Some(external_id), + metadata_json: body, + confidence, + last_updated: Utc::now(), + })) + } } diff --git a/crates/pinakes-core/src/error.rs b/crates/pinakes-core/src/error.rs index 7343679..565d643 100644 --- a/crates/pinakes-core/src/error.rs +++ b/crates/pinakes-core/src/error.rs @@ -4,125 +4,125 @@ use thiserror::Error; #[derive(Debug, Error)] pub enum PinakesError { - #[error("IO error: {0}")] - Io(#[from] std::io::Error), + #[error("IO error: {0}")] + Io(#[from] std::io::Error), - #[error("database error: {0}")] - Database(String), + #[error("database error: {0}")] + Database(String), - #[error("migration error: {0}")] - Migration(String), + #[error("migration error: {0}")] + Migration(String), - #[error("configuration error: {0}")] - Config(String), + #[error("configuration error: {0}")] + Config(String), - #[error("media item not found: {0}")] - NotFound(String), + #[error("media item not found: {0}")] + NotFound(String), - #[error("duplicate content hash: {0}")] - DuplicateHash(String), + #[error("duplicate content hash: {0}")] + DuplicateHash(String), - #[error("unsupported media type for path: {0}")] - UnsupportedMediaType(PathBuf), + #[error("unsupported media type for path: {0}")] + UnsupportedMediaType(PathBuf), - #[error("metadata extraction failed: {0}")] - MetadataExtraction(String), + #[error("metadata extraction failed: {0}")] + MetadataExtraction(String), - #[error("search query parse error: {0}")] - SearchParse(String), + #[error("search query parse error: {0}")] + SearchParse(String), - #[error("file not found at path: {0}")] - FileNotFound(PathBuf), + #[error("file not found at path: {0}")] + FileNotFound(PathBuf), - #[error("tag not found: {0}")] - TagNotFound(String), + #[error("tag not found: {0}")] + TagNotFound(String), - #[error("collection not found: {0}")] - CollectionNotFound(String), + #[error("collection not found: {0}")] + CollectionNotFound(String), - #[error("invalid operation: {0}")] - InvalidOperation(String), + #[error("invalid operation: {0}")] + InvalidOperation(String), - #[error("invalid data: {0}")] - InvalidData(String), + #[error("invalid data: {0}")] + InvalidData(String), - #[error("authentication error: {0}")] - Authentication(String), + #[error("authentication error: {0}")] + Authentication(String), - #[error("authorization error: {0}")] - Authorization(String), + #[error("authorization error: {0}")] + Authorization(String), - #[error("path not allowed: {0}")] - PathNotAllowed(String), + #[error("path not allowed: {0}")] + PathNotAllowed(String), - #[error("external API error: {0}")] - External(String), + #[error("external API error: {0}")] + External(String), - // Managed Storage errors - #[error("managed storage not enabled")] - ManagedStorageDisabled, + // Managed Storage errors + #[error("managed storage not enabled")] + ManagedStorageDisabled, - #[error("upload too large: {0} bytes exceeds limit")] - UploadTooLarge(u64), + #[error("upload too large: {0} bytes exceeds limit")] + UploadTooLarge(u64), - #[error("blob not found: {0}")] - BlobNotFound(String), + #[error("blob not found: {0}")] + BlobNotFound(String), - #[error("storage integrity error: {0}")] - StorageIntegrity(String), + #[error("storage integrity error: {0}")] + StorageIntegrity(String), - // Sync errors - #[error("sync not enabled")] - SyncDisabled, + // Sync errors + #[error("sync not enabled")] + SyncDisabled, - #[error("device not found: {0}")] - DeviceNotFound(String), + #[error("device not found: {0}")] + DeviceNotFound(String), - #[error("sync conflict: {0}")] - SyncConflict(String), + #[error("sync conflict: {0}")] + SyncConflict(String), - #[error("upload session expired: {0}")] - UploadSessionExpired(String), + #[error("upload session expired: {0}")] + UploadSessionExpired(String), - #[error("upload session not found: {0}")] - UploadSessionNotFound(String), + #[error("upload session not found: {0}")] + UploadSessionNotFound(String), - #[error("chunk out of order: expected {expected}, got {actual}")] - ChunkOutOfOrder { expected: u64, actual: u64 }, + #[error("chunk out of order: expected {expected}, got {actual}")] + ChunkOutOfOrder { expected: u64, actual: u64 }, - // Sharing errors - #[error("share not found: {0}")] - ShareNotFound(String), + // Sharing errors + #[error("share not found: {0}")] + ShareNotFound(String), - #[error("share expired: {0}")] - ShareExpired(String), + #[error("share expired: {0}")] + ShareExpired(String), - #[error("share password required")] - SharePasswordRequired, + #[error("share password required")] + SharePasswordRequired, - #[error("share password invalid")] - SharePasswordInvalid, + #[error("share password invalid")] + SharePasswordInvalid, - #[error("insufficient share permissions")] - InsufficientSharePermissions, + #[error("insufficient share permissions")] + InsufficientSharePermissions, } impl From for PinakesError { - fn from(e: rusqlite::Error) -> Self { - PinakesError::Database(e.to_string()) - } + fn from(e: rusqlite::Error) -> Self { + PinakesError::Database(e.to_string()) + } } impl From for PinakesError { - fn from(e: tokio_postgres::Error) -> Self { - PinakesError::Database(e.to_string()) - } + fn from(e: tokio_postgres::Error) -> Self { + PinakesError::Database(e.to_string()) + } } impl From for PinakesError { - fn from(e: serde_json::Error) -> Self { - PinakesError::Database(format!("JSON serialization error: {}", e)) - } + fn from(e: serde_json::Error) -> Self { + PinakesError::Database(format!("JSON serialization error: {}", e)) + } } pub type Result = std::result::Result; diff --git a/crates/pinakes-core/src/events.rs b/crates/pinakes-core/src/events.rs index 576ac48..c2c726e 100644 --- a/crates/pinakes-core/src/events.rs +++ b/crates/pinakes-core/src/events.rs @@ -1,205 +1,212 @@ -//! Auto-detection of photo events and albums based on time and location proximity +//! Auto-detection of photo events and albums based on time and location +//! proximity use chrono::{DateTime, Utc}; -use crate::error::Result; -use crate::model::{MediaId, MediaItem}; +use crate::{ + error::Result, + model::{MediaId, MediaItem}, +}; /// Configuration for event detection #[derive(Debug, Clone)] pub struct EventDetectionConfig { - /// Maximum time gap between photos in the same event (in seconds) - pub max_time_gap_secs: i64, - /// Minimum number of photos to form an event - pub min_photos: usize, - /// Maximum distance between photos in the same event (in kilometers) - /// None means location is not considered - pub max_distance_km: Option, - /// Consider photos on the same day as potentially the same event - pub same_day_threshold: bool, + /// Maximum time gap between photos in the same event (in seconds) + pub max_time_gap_secs: i64, + /// Minimum number of photos to form an event + pub min_photos: usize, + /// Maximum distance between photos in the same event (in kilometers) + /// None means location is not considered + pub max_distance_km: Option, + /// Consider photos on the same day as potentially the same event + pub same_day_threshold: bool, } impl Default for EventDetectionConfig { - fn default() -> Self { - Self { - max_time_gap_secs: 2 * 60 * 60, // 2 hours - min_photos: 5, - max_distance_km: Some(1.0), // 1km - same_day_threshold: true, - } + fn default() -> Self { + Self { + max_time_gap_secs: 2 * 60 * 60, // 2 hours + min_photos: 5, + max_distance_km: Some(1.0), // 1km + same_day_threshold: true, } + } } /// A detected photo event/album #[derive(Debug, Clone)] pub struct DetectedEvent { - /// Suggested name for the event (e.g., "Photos from 2024-01-15") - pub suggested_name: String, - /// Start time of the event - pub start_time: DateTime, - /// End time of the event - pub end_time: DateTime, - /// Media items in this event - pub items: Vec, - /// Representative location (if available) - pub location: Option<(f64, f64)>, // (latitude, longitude) + /// Suggested name for the event (e.g., "Photos from 2024-01-15") + pub suggested_name: String, + /// Start time of the event + pub start_time: DateTime, + /// End time of the event + pub end_time: DateTime, + /// Media items in this event + pub items: Vec, + /// Representative location (if available) + pub location: Option<(f64, f64)>, // (latitude, longitude) } /// Calculate Haversine distance between two GPS coordinates in kilometers fn haversine_distance(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 { - const EARTH_RADIUS_KM: f64 = 6371.0; + const EARTH_RADIUS_KM: f64 = 6371.0; - let dlat = (lat2 - lat1).to_radians(); - let dlon = (lon2 - lon1).to_radians(); + let dlat = (lat2 - lat1).to_radians(); + let dlon = (lon2 - lon1).to_radians(); - let a = (dlat / 2.0).sin().powi(2) - + lat1.to_radians().cos() * lat2.to_radians().cos() * (dlon / 2.0).sin().powi(2); + let a = (dlat / 2.0).sin().powi(2) + + lat1.to_radians().cos() + * lat2.to_radians().cos() + * (dlon / 2.0).sin().powi(2); - let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt()); + let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt()); - EARTH_RADIUS_KM * c + EARTH_RADIUS_KM * c } /// Detect photo events from a list of media items pub fn detect_events( - mut items: Vec, - config: &EventDetectionConfig, + mut items: Vec, + config: &EventDetectionConfig, ) -> Result> { - // Filter to only photos with date_taken - items.retain(|item| item.date_taken.is_some()); + // Filter to only photos with date_taken + items.retain(|item| item.date_taken.is_some()); - if items.is_empty() { - return Ok(Vec::new()); - } + if items.is_empty() { + return Ok(Vec::new()); + } - // Sort by date_taken - items.sort_by(|a, b| a.date_taken.unwrap().cmp(&b.date_taken.unwrap())); + // Sort by date_taken + items.sort_by(|a, b| a.date_taken.unwrap().cmp(&b.date_taken.unwrap())); - let mut events: Vec = Vec::new(); - let mut current_event_items: Vec = vec![items[0].id]; - let mut current_start_time = items[0].date_taken.unwrap(); - let mut current_last_time = items[0].date_taken.unwrap(); - let mut current_location = items[0].latitude.zip(items[0].longitude); + let mut events: Vec = Vec::new(); + let mut current_event_items: Vec = vec![items[0].id]; + let mut current_start_time = items[0].date_taken.unwrap(); + let mut current_last_time = items[0].date_taken.unwrap(); + let mut current_location = items[0].latitude.zip(items[0].longitude); - for item in items.iter().skip(1) { - let item_time = item.date_taken.unwrap(); - let time_gap = (item_time - current_last_time).num_seconds(); + for item in items.iter().skip(1) { + let item_time = item.date_taken.unwrap(); + let time_gap = (item_time - current_last_time).num_seconds(); - // Check time gap - let time_ok = if config.same_day_threshold { - // Same day or within time gap - item_time.date_naive() == current_last_time.date_naive() - || time_gap <= config.max_time_gap_secs - } else { - time_gap <= config.max_time_gap_secs - }; + // Check time gap + let time_ok = if config.same_day_threshold { + // Same day or within time gap + item_time.date_naive() == current_last_time.date_naive() + || time_gap <= config.max_time_gap_secs + } else { + time_gap <= config.max_time_gap_secs + }; - // Check location proximity if both have GPS data - let location_ok = match ( - config.max_distance_km, - current_location, - item.latitude.zip(item.longitude), - ) { - (Some(max_dist), Some((lat1, lon1)), Some((lat2, lon2))) => { - let dist = haversine_distance(lat1, lon1, lat2, lon2); - dist <= max_dist - } - // If no location constraint or missing GPS, consider location OK - _ => true, - }; + // Check location proximity if both have GPS data + let location_ok = match ( + config.max_distance_km, + current_location, + item.latitude.zip(item.longitude), + ) { + (Some(max_dist), Some((lat1, lon1)), Some((lat2, lon2))) => { + let dist = haversine_distance(lat1, lon1, lat2, lon2); + dist <= max_dist + }, + // If no location constraint or missing GPS, consider location OK + _ => true, + }; - if time_ok && location_ok { - // Add to current event - current_event_items.push(item.id); - current_last_time = item_time; + if time_ok && location_ok { + // Add to current event + current_event_items.push(item.id); + current_last_time = item_time; - // Update location to average if available - if let (Some((lat1, lon1)), Some((lat2, lon2))) = - (current_location, item.latitude.zip(item.longitude)) - { - current_location = Some(((lat1 + lat2) / 2.0, (lon1 + lon2) / 2.0)); - } else if item.latitude.is_some() && item.longitude.is_some() { - current_location = item.latitude.zip(item.longitude); - } - } else { - // Start new event if current has enough photos - if current_event_items.len() >= config.min_photos { - let event_name = format!("Event on {}", current_start_time.format("%Y-%m-%d")); - - events.push(DetectedEvent { - suggested_name: event_name, - start_time: current_start_time, - end_time: current_last_time, - items: current_event_items.clone(), - location: current_location, - }); - } - - // Reset for new event - current_event_items = vec![item.id]; - current_start_time = item_time; - current_last_time = item_time; - current_location = item.latitude.zip(item.longitude); - } - } - - // Don't forget the last event - if current_event_items.len() >= config.min_photos { - let event_name = format!("Event on {}", current_start_time.format("%Y-%m-%d")); + // Update location to average if available + if let (Some((lat1, lon1)), Some((lat2, lon2))) = + (current_location, item.latitude.zip(item.longitude)) + { + current_location = Some(((lat1 + lat2) / 2.0, (lon1 + lon2) / 2.0)); + } else if item.latitude.is_some() && item.longitude.is_some() { + current_location = item.latitude.zip(item.longitude); + } + } else { + // Start new event if current has enough photos + if current_event_items.len() >= config.min_photos { + let event_name = + format!("Event on {}", current_start_time.format("%Y-%m-%d")); events.push(DetectedEvent { - suggested_name: event_name, - start_time: current_start_time, - end_time: current_last_time, - items: current_event_items, - location: current_location, + suggested_name: event_name, + start_time: current_start_time, + end_time: current_last_time, + items: current_event_items.clone(), + location: current_location, }); - } + } - Ok(events) + // Reset for new event + current_event_items = vec![item.id]; + current_start_time = item_time; + current_last_time = item_time; + current_location = item.latitude.zip(item.longitude); + } + } + + // Don't forget the last event + if current_event_items.len() >= config.min_photos { + let event_name = + format!("Event on {}", current_start_time.format("%Y-%m-%d")); + + events.push(DetectedEvent { + suggested_name: event_name, + start_time: current_start_time, + end_time: current_last_time, + items: current_event_items, + location: current_location, + }); + } + + Ok(events) } /// Detect photo bursts (rapid sequences of photos) /// Returns groups of media IDs that are likely burst sequences pub fn detect_bursts( - mut items: Vec, - max_gap_secs: i64, - min_burst_size: usize, + mut items: Vec, + max_gap_secs: i64, + min_burst_size: usize, ) -> Result>> { - // Filter to only photos with date_taken - items.retain(|item| item.date_taken.is_some()); + // Filter to only photos with date_taken + items.retain(|item| item.date_taken.is_some()); - if items.is_empty() { - return Ok(Vec::new()); + if items.is_empty() { + return Ok(Vec::new()); + } + + // Sort by date_taken + items.sort_by(|a, b| a.date_taken.unwrap().cmp(&b.date_taken.unwrap())); + + let mut bursts: Vec> = Vec::new(); + let mut current_burst: Vec = vec![items[0].id]; + let mut last_time = items[0].date_taken.unwrap(); + + for item in items.iter().skip(1) { + let item_time = item.date_taken.unwrap(); + let gap = (item_time - last_time).num_seconds(); + + if gap <= max_gap_secs { + current_burst.push(item.id); + } else { + if current_burst.len() >= min_burst_size { + bursts.push(current_burst.clone()); + } + current_burst = vec![item.id]; } - // Sort by date_taken - items.sort_by(|a, b| a.date_taken.unwrap().cmp(&b.date_taken.unwrap())); + last_time = item_time; + } - let mut bursts: Vec> = Vec::new(); - let mut current_burst: Vec = vec![items[0].id]; - let mut last_time = items[0].date_taken.unwrap(); + // Don't forget the last burst + if current_burst.len() >= min_burst_size { + bursts.push(current_burst); + } - for item in items.iter().skip(1) { - let item_time = item.date_taken.unwrap(); - let gap = (item_time - last_time).num_seconds(); - - if gap <= max_gap_secs { - current_burst.push(item.id); - } else { - if current_burst.len() >= min_burst_size { - bursts.push(current_burst.clone()); - } - current_burst = vec![item.id]; - } - - last_time = item_time; - } - - // Don't forget the last burst - if current_burst.len() >= min_burst_size { - bursts.push(current_burst); - } - - Ok(bursts) + Ok(bursts) } diff --git a/crates/pinakes-core/src/export.rs b/crates/pinakes-core/src/export.rs index e611dfe..a68bf63 100644 --- a/crates/pinakes-core/src/export.rs +++ b/crates/pinakes-core/src/export.rs @@ -2,67 +2,70 @@ use std::path::Path; use serde::{Deserialize, Serialize}; -use crate::error::Result; -use crate::jobs::ExportFormat; -use crate::storage::DynStorageBackend; +use crate::{error::Result, jobs::ExportFormat, storage::DynStorageBackend}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ExportResult { - pub items_exported: usize, - pub output_path: String, + pub items_exported: usize, + pub output_path: String, } /// Export library data to the specified format. pub async fn export_library( - storage: &DynStorageBackend, - format: &ExportFormat, - destination: &Path, + storage: &DynStorageBackend, + format: &ExportFormat, + destination: &Path, ) -> Result { - let pagination = crate::model::Pagination { - offset: 0, - limit: u64::MAX, - sort: None, - }; - let items = storage.list_media(&pagination).await?; - let count = items.len(); + let pagination = crate::model::Pagination { + offset: 0, + limit: u64::MAX, + sort: None, + }; + let items = storage.list_media(&pagination).await?; + let count = items.len(); - match format { - ExportFormat::Json => { - let json = serde_json::to_string_pretty(&items) - .map_err(|e| crate::error::PinakesError::Config(format!("json serialize: {e}")))?; - std::fs::write(destination, json)?; - } - ExportFormat::Csv => { - let mut csv = String::new(); - csv.push_str("id,path,file_name,media_type,content_hash,file_size,title,artist,album,genre,year,duration_secs,description,created_at,updated_at\n"); - for item in &items { - csv.push_str(&format!( - "{},{},{},{:?},{},{},{},{},{},{},{},{},{},{},{}\n", - item.id, - item.path.display(), - item.file_name, - item.media_type, - item.content_hash, - item.file_size, - item.title.as_deref().unwrap_or(""), - item.artist.as_deref().unwrap_or(""), - item.album.as_deref().unwrap_or(""), - item.genre.as_deref().unwrap_or(""), - item.year.map(|y| y.to_string()).unwrap_or_default(), - item.duration_secs - .map(|d| d.to_string()) - .unwrap_or_default(), - item.description.as_deref().unwrap_or(""), - item.created_at, - item.updated_at, - )); - } - std::fs::write(destination, csv)?; - } - } + match format { + ExportFormat::Json => { + let json = serde_json::to_string_pretty(&items).map_err(|e| { + crate::error::PinakesError::Config(format!("json serialize: {e}")) + })?; + std::fs::write(destination, json)?; + }, + ExportFormat::Csv => { + let mut csv = String::new(); + csv.push_str( + "id,path,file_name,media_type,content_hash,file_size,title,artist,\ + album,genre,year,duration_secs,description,created_at,updated_at\n", + ); + for item in &items { + csv.push_str(&format!( + "{},{},{},{:?},{},{},{},{},{},{},{},{},{},{},{}\n", + item.id, + item.path.display(), + item.file_name, + item.media_type, + item.content_hash, + item.file_size, + item.title.as_deref().unwrap_or(""), + item.artist.as_deref().unwrap_or(""), + item.album.as_deref().unwrap_or(""), + item.genre.as_deref().unwrap_or(""), + item.year.map(|y| y.to_string()).unwrap_or_default(), + item + .duration_secs + .map(|d| d.to_string()) + .unwrap_or_default(), + item.description.as_deref().unwrap_or(""), + item.created_at, + item.updated_at, + )); + } + std::fs::write(destination, csv)?; + }, + } - Ok(ExportResult { - items_exported: count, - output_path: destination.to_string_lossy().to_string(), - }) + Ok(ExportResult { + items_exported: count, + output_path: destination.to_string_lossy().to_string(), + }) } diff --git a/crates/pinakes-core/src/hash.rs b/crates/pinakes-core/src/hash.rs index 8435084..3c64361 100644 --- a/crates/pinakes-core/src/hash.rs +++ b/crates/pinakes-core/src/hash.rs @@ -1,31 +1,30 @@ use std::path::Path; -use crate::error::Result; -use crate::model::ContentHash; +use crate::{error::Result, model::ContentHash}; const BUFFER_SIZE: usize = 65536; pub async fn compute_file_hash(path: &Path) -> Result { - let path = path.to_path_buf(); - let hash = tokio::task::spawn_blocking(move || -> Result { - let mut hasher = blake3::Hasher::new(); - let mut file = std::fs::File::open(&path)?; - let mut buf = vec![0u8; BUFFER_SIZE]; - loop { - let n = std::io::Read::read(&mut file, &mut buf)?; - if n == 0 { - break; - } - hasher.update(&buf[..n]); - } - Ok(ContentHash::new(hasher.finalize().to_hex().to_string())) - }) - .await - .map_err(|e| crate::error::PinakesError::Io(std::io::Error::other(e)))??; - Ok(hash) + let path = path.to_path_buf(); + let hash = tokio::task::spawn_blocking(move || -> Result { + let mut hasher = blake3::Hasher::new(); + let mut file = std::fs::File::open(&path)?; + let mut buf = vec![0u8; BUFFER_SIZE]; + loop { + let n = std::io::Read::read(&mut file, &mut buf)?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + Ok(ContentHash::new(hasher.finalize().to_hex().to_string())) + }) + .await + .map_err(|e| crate::error::PinakesError::Io(std::io::Error::other(e)))??; + Ok(hash) } pub fn compute_hash_sync(data: &[u8]) -> ContentHash { - let hash = blake3::hash(data); - ContentHash::new(hash.to_hex().to_string()) + let hash = blake3::hash(data); + ContentHash::new(hash.to_hex().to_string()) } diff --git a/crates/pinakes-core/src/import.rs b/crates/pinakes-core/src/import.rs index bde809c..5df4465 100644 --- a/crates/pinakes-core/src/import.rs +++ b/crates/pinakes-core/src/import.rs @@ -1,436 +1,457 @@ -use std::path::{Path, PathBuf}; -use std::time::SystemTime; +use std::{ + path::{Path, PathBuf}, + time::SystemTime, +}; use tracing::info; -use crate::audit; -use crate::error::{PinakesError, Result}; -use crate::hash::compute_file_hash; -use crate::links; -use crate::media_type::{BuiltinMediaType, MediaType}; -use crate::metadata; -use crate::model::*; -use crate::storage::DynStorageBackend; -use crate::thumbnail; +use crate::{ + audit, + error::{PinakesError, Result}, + hash::compute_file_hash, + links, + media_type::{BuiltinMediaType, MediaType}, + metadata, + model::*, + storage::DynStorageBackend, + thumbnail, +}; pub struct ImportResult { - pub media_id: MediaId, - pub was_duplicate: bool, - /// True if the file was skipped because it hasn't changed since last scan - pub was_skipped: bool, - pub path: PathBuf, + pub media_id: MediaId, + pub was_duplicate: bool, + /// True if the file was skipped because it hasn't changed since last scan + pub was_skipped: bool, + pub path: PathBuf, } /// Options for import operations #[derive(Debug, Clone)] pub struct ImportOptions { - /// Skip files that haven't changed since last scan (based on mtime) - pub incremental: bool, - /// Force re-import even if mtime hasn't changed - pub force: bool, - /// Photo configuration for toggleable features - pub photo_config: crate::config::PhotoConfig, + /// Skip files that haven't changed since last scan (based on mtime) + pub incremental: bool, + /// Force re-import even if mtime hasn't changed + pub force: bool, + /// Photo configuration for toggleable features + pub photo_config: crate::config::PhotoConfig, } impl Default for ImportOptions { - fn default() -> Self { - Self { - incremental: false, - force: false, - photo_config: crate::config::PhotoConfig::default(), - } + fn default() -> Self { + Self { + incremental: false, + force: false, + photo_config: crate::config::PhotoConfig::default(), } + } } /// Get the modification time of a file as a Unix timestamp fn get_file_mtime(path: &Path) -> Option { - std::fs::metadata(path) - .ok() - .and_then(|m| m.modified().ok()) - .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok()) - .map(|d| d.as_secs() as i64) + std::fs::metadata(path) + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) } -/// Check that a canonicalized path falls under at least one configured root directory. -/// If no roots are configured, all paths are allowed (for ad-hoc imports). -pub async fn validate_path_in_roots(storage: &DynStorageBackend, path: &Path) -> Result<()> { - let roots = storage.list_root_dirs().await?; - if roots.is_empty() { - return Ok(()); +/// Check that a canonicalized path falls under at least one configured root +/// directory. If no roots are configured, all paths are allowed (for ad-hoc +/// imports). +pub async fn validate_path_in_roots( + storage: &DynStorageBackend, + path: &Path, +) -> Result<()> { + let roots = storage.list_root_dirs().await?; + if roots.is_empty() { + return Ok(()); + } + for root in &roots { + if let Ok(canonical_root) = root.canonicalize() + && path.starts_with(&canonical_root) + { + return Ok(()); } - for root in &roots { - if let Ok(canonical_root) = root.canonicalize() - && path.starts_with(&canonical_root) - { - return Ok(()); - } - } - Err(PinakesError::InvalidOperation(format!( - "path {} is not within any configured root directory", - path.display() - ))) + } + Err(PinakesError::InvalidOperation(format!( + "path {} is not within any configured root directory", + path.display() + ))) } -pub async fn import_file(storage: &DynStorageBackend, path: &Path) -> Result { - import_file_with_options(storage, path, &ImportOptions::default()).await +pub async fn import_file( + storage: &DynStorageBackend, + path: &Path, +) -> Result { + import_file_with_options(storage, path, &ImportOptions::default()).await } /// Import a file with configurable options for incremental scanning pub async fn import_file_with_options( - storage: &DynStorageBackend, - path: &Path, - options: &ImportOptions, + storage: &DynStorageBackend, + path: &Path, + options: &ImportOptions, ) -> Result { - let path = path.canonicalize()?; + let path = path.canonicalize()?; - if !path.exists() { - return Err(PinakesError::FileNotFound(path)); + if !path.exists() { + return Err(PinakesError::FileNotFound(path)); + } + + validate_path_in_roots(storage, &path).await?; + + let media_type = MediaType::from_path(&path) + .ok_or_else(|| PinakesError::UnsupportedMediaType(path.clone()))?; + + let current_mtime = get_file_mtime(&path); + + // Check for incremental scan: skip if file hasn't changed + if options.incremental + && !options.force + && let Some(existing) = storage.get_media_by_path(&path).await? + && let (Some(stored_mtime), Some(curr_mtime)) = + (existing.file_mtime, current_mtime) + && stored_mtime == curr_mtime + { + return Ok(ImportResult { + media_id: existing.id, + was_duplicate: false, + was_skipped: true, + path: path.clone(), + }); + } + + let content_hash = compute_file_hash(&path).await?; + + if let Some(existing) = storage.get_media_by_hash(&content_hash).await? { + // Update the mtime even for duplicates so incremental scan works + if current_mtime.is_some() && existing.file_mtime != current_mtime { + let mut updated = existing.clone(); + updated.file_mtime = current_mtime; + let _ = storage.update_media(&updated).await; } + return Ok(ImportResult { + media_id: existing.id, + was_duplicate: true, + was_skipped: false, + path: path.clone(), + }); + } - validate_path_in_roots(storage, &path).await?; + let file_meta = std::fs::metadata(&path)?; + let file_size = file_meta.len(); - let media_type = MediaType::from_path(&path) - .ok_or_else(|| PinakesError::UnsupportedMediaType(path.clone()))?; - - let current_mtime = get_file_mtime(&path); - - // Check for incremental scan: skip if file hasn't changed - if options.incremental - && !options.force - && let Some(existing) = storage.get_media_by_path(&path).await? - && let (Some(stored_mtime), Some(curr_mtime)) = (existing.file_mtime, current_mtime) - && stored_mtime == curr_mtime - { - return Ok(ImportResult { - media_id: existing.id, - was_duplicate: false, - was_skipped: true, - path: path.clone(), - }); - } - - let content_hash = compute_file_hash(&path).await?; - - if let Some(existing) = storage.get_media_by_hash(&content_hash).await? { - // Update the mtime even for duplicates so incremental scan works - if current_mtime.is_some() && existing.file_mtime != current_mtime { - let mut updated = existing.clone(); - updated.file_mtime = current_mtime; - let _ = storage.update_media(&updated).await; - } - return Ok(ImportResult { - media_id: existing.id, - was_duplicate: true, - was_skipped: false, - path: path.clone(), - }); - } - - let file_meta = std::fs::metadata(&path)?; - let file_size = file_meta.len(); - - let extracted = { - let path_clone = path.clone(); - let media_type_clone = media_type.clone(); - tokio::task::spawn_blocking(move || { - metadata::extract_metadata(&path_clone, media_type_clone) - }) - .await - .map_err(|e| PinakesError::MetadataExtraction(e.to_string()))?? - }; - - let file_name = path - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string(); - - let now = chrono::Utc::now(); - let media_id = MediaId::new(); - - // Generate thumbnail for image types - let thumb_path = { - let source = path.clone(); - let thumb_dir = thumbnail::default_thumbnail_dir(); - let media_type_clone = media_type.clone(); - tokio::task::spawn_blocking(move || { - thumbnail::generate_thumbnail(media_id, &source, media_type_clone, &thumb_dir) - }) - .await - .map_err(|e| PinakesError::MetadataExtraction(e.to_string()))?? - }; - - // Generate perceptual hash for image files (if enabled in config) - let perceptual_hash = if options.photo_config.generate_perceptual_hash - && media_type.category() == crate::media_type::MediaCategory::Image - { - crate::metadata::image::generate_perceptual_hash(&path) - } else { - None - }; - - // Check if this is a markdown file for link extraction - let is_markdown = media_type == MediaType::Builtin(BuiltinMediaType::Markdown); - - let item = MediaItem { - id: media_id, - path: path.clone(), - file_name, - media_type, - content_hash, - file_size, - title: extracted.title, - artist: extracted.artist, - album: extracted.album, - genre: extracted.genre, - year: extracted.year, - duration_secs: extracted.duration_secs, - description: extracted.description, - thumbnail_path: thumb_path, - custom_fields: std::collections::HashMap::new(), - file_mtime: current_mtime, - - // Photo-specific metadata from extraction - date_taken: extracted.date_taken, - latitude: extracted.latitude, - longitude: extracted.longitude, - camera_make: extracted.camera_make, - camera_model: extracted.camera_model, - rating: extracted.rating, - perceptual_hash, - - // Managed storage fields - external files use defaults - storage_mode: StorageMode::External, - original_filename: None, - uploaded_at: None, - storage_key: None, - - created_at: now, - updated_at: now, - - // New items are not deleted - deleted_at: None, - - // Links will be extracted separately - links_extracted_at: None, - }; - - storage.insert_media(&item).await?; - - // Extract and store markdown links for markdown files - if is_markdown { - if let Err(e) = extract_and_store_links(storage, media_id, &path).await { - tracing::warn!( - media_id = %media_id, - path = %path.display(), - error = %e, - "failed to extract markdown links" - ); - } - } - - // Store extracted extra metadata as custom fields - for (key, value) in &extracted.extra { - let field = CustomField { - field_type: CustomFieldType::Text, - value: value.clone(), - }; - if let Err(e) = storage.set_custom_field(media_id, key, &field).await { - tracing::warn!( - media_id = %media_id, - field = %key, - error = %e, - "failed to store extracted metadata as custom field" - ); - } - } - - audit::record_action( - storage, - Some(media_id), - AuditAction::Imported, - Some(format!("path={}", path.display())), - ) - .await?; - - info!(media_id = %media_id, path = %path.display(), "imported media file"); - - Ok(ImportResult { - media_id, - was_duplicate: false, - was_skipped: false, - path: path.clone(), + let extracted = { + let path_clone = path.clone(); + let media_type_clone = media_type.clone(); + tokio::task::spawn_blocking(move || { + metadata::extract_metadata(&path_clone, media_type_clone) }) + .await + .map_err(|e| PinakesError::MetadataExtraction(e.to_string()))?? + }; + + let file_name = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + + let now = chrono::Utc::now(); + let media_id = MediaId::new(); + + // Generate thumbnail for image types + let thumb_path = { + let source = path.clone(); + let thumb_dir = thumbnail::default_thumbnail_dir(); + let media_type_clone = media_type.clone(); + tokio::task::spawn_blocking(move || { + thumbnail::generate_thumbnail( + media_id, + &source, + media_type_clone, + &thumb_dir, + ) + }) + .await + .map_err(|e| PinakesError::MetadataExtraction(e.to_string()))?? + }; + + // Generate perceptual hash for image files (if enabled in config) + let perceptual_hash = if options.photo_config.generate_perceptual_hash + && media_type.category() == crate::media_type::MediaCategory::Image + { + crate::metadata::image::generate_perceptual_hash(&path) + } else { + None + }; + + // Check if this is a markdown file for link extraction + let is_markdown = + media_type == MediaType::Builtin(BuiltinMediaType::Markdown); + + let item = MediaItem { + id: media_id, + path: path.clone(), + file_name, + media_type, + content_hash, + file_size, + title: extracted.title, + artist: extracted.artist, + album: extracted.album, + genre: extracted.genre, + year: extracted.year, + duration_secs: extracted.duration_secs, + description: extracted.description, + thumbnail_path: thumb_path, + custom_fields: std::collections::HashMap::new(), + file_mtime: current_mtime, + + // Photo-specific metadata from extraction + date_taken: extracted.date_taken, + latitude: extracted.latitude, + longitude: extracted.longitude, + camera_make: extracted.camera_make, + camera_model: extracted.camera_model, + rating: extracted.rating, + perceptual_hash, + + // Managed storage fields - external files use defaults + storage_mode: StorageMode::External, + original_filename: None, + uploaded_at: None, + storage_key: None, + + created_at: now, + updated_at: now, + + // New items are not deleted + deleted_at: None, + + // Links will be extracted separately + links_extracted_at: None, + }; + + storage.insert_media(&item).await?; + + // Extract and store markdown links for markdown files + if is_markdown { + if let Err(e) = extract_and_store_links(storage, media_id, &path).await { + tracing::warn!( + media_id = %media_id, + path = %path.display(), + error = %e, + "failed to extract markdown links" + ); + } + } + + // Store extracted extra metadata as custom fields + for (key, value) in &extracted.extra { + let field = CustomField { + field_type: CustomFieldType::Text, + value: value.clone(), + }; + if let Err(e) = storage.set_custom_field(media_id, key, &field).await { + tracing::warn!( + media_id = %media_id, + field = %key, + error = %e, + "failed to store extracted metadata as custom field" + ); + } + } + + audit::record_action( + storage, + Some(media_id), + AuditAction::Imported, + Some(format!("path={}", path.display())), + ) + .await?; + + info!(media_id = %media_id, path = %path.display(), "imported media file"); + + Ok(ImportResult { + media_id, + was_duplicate: false, + was_skipped: false, + path: path.clone(), + }) } -pub(crate) fn should_ignore(path: &std::path::Path, patterns: &[String]) -> bool { - for component in path.components() { - if let std::path::Component::Normal(name) = component { - let name_str = name.to_string_lossy(); - for pattern in patterns { - if pattern.starts_with('.') - && name_str.starts_with('.') - && pattern == name_str.as_ref() - { - return true; - } - // Simple glob: ".*" matches any dotfile - if pattern == ".*" && name_str.starts_with('.') { - return true; - } - if name_str == pattern.as_str() { - return true; - } - } +pub(crate) fn should_ignore( + path: &std::path::Path, + patterns: &[String], +) -> bool { + for component in path.components() { + if let std::path::Component::Normal(name) = component { + let name_str = name.to_string_lossy(); + for pattern in patterns { + if pattern.starts_with('.') + && name_str.starts_with('.') + && pattern == name_str.as_ref() + { + return true; } + // Simple glob: ".*" matches any dotfile + if pattern == ".*" && name_str.starts_with('.') { + return true; + } + if name_str == pattern.as_str() { + return true; + } + } } - false + } + false } /// Default number of concurrent import tasks. const DEFAULT_IMPORT_CONCURRENCY: usize = 8; pub async fn import_directory( - storage: &DynStorageBackend, - dir: &Path, - ignore_patterns: &[String], + storage: &DynStorageBackend, + dir: &Path, + ignore_patterns: &[String], ) -> Result>> { - import_directory_with_options( - storage, - dir, - ignore_patterns, - DEFAULT_IMPORT_CONCURRENCY, - &ImportOptions::default(), - ) - .await + import_directory_with_options( + storage, + dir, + ignore_patterns, + DEFAULT_IMPORT_CONCURRENCY, + &ImportOptions::default(), + ) + .await } pub async fn import_directory_with_concurrency( - storage: &DynStorageBackend, - dir: &Path, - ignore_patterns: &[String], - concurrency: usize, + storage: &DynStorageBackend, + dir: &Path, + ignore_patterns: &[String], + concurrency: usize, ) -> Result>> { - import_directory_with_options( - storage, - dir, - ignore_patterns, - concurrency, - &ImportOptions::default(), - ) - .await + import_directory_with_options( + storage, + dir, + ignore_patterns, + concurrency, + &ImportOptions::default(), + ) + .await } /// Import a directory with full options including incremental scanning support pub async fn import_directory_with_options( - storage: &DynStorageBackend, - dir: &Path, - ignore_patterns: &[String], - concurrency: usize, - options: &ImportOptions, + storage: &DynStorageBackend, + dir: &Path, + ignore_patterns: &[String], + concurrency: usize, + options: &ImportOptions, ) -> Result>> { - let concurrency = concurrency.clamp(1, 256); - let dir = dir.to_path_buf(); - let patterns = ignore_patterns.to_vec(); - let options = options.clone(); + let concurrency = concurrency.clamp(1, 256); + let dir = dir.to_path_buf(); + let patterns = ignore_patterns.to_vec(); + let options = options.clone(); - let entries: Vec = { - let dir = dir.clone(); - tokio::task::spawn_blocking(move || { - walkdir::WalkDir::new(&dir) - .follow_links(true) - .into_iter() - .filter_map(|e| e.ok()) - .filter(|e| e.file_type().is_file()) - .filter(|e| MediaType::from_path(e.path()).is_some()) - .filter(|e| !should_ignore(e.path(), &patterns)) - .map(|e| e.path().to_path_buf()) - .collect() - }) - .await - .map_err(|e| PinakesError::Io(std::io::Error::other(e)))? - }; + let entries: Vec = { + let dir = dir.clone(); + tokio::task::spawn_blocking(move || { + walkdir::WalkDir::new(&dir) + .follow_links(true) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()) + .filter(|e| MediaType::from_path(e.path()).is_some()) + .filter(|e| !should_ignore(e.path(), &patterns)) + .map(|e| e.path().to_path_buf()) + .collect() + }) + .await + .map_err(|e| PinakesError::Io(std::io::Error::other(e)))? + }; - let mut results = Vec::with_capacity(entries.len()); - let mut join_set = tokio::task::JoinSet::new(); + let mut results = Vec::with_capacity(entries.len()); + let mut join_set = tokio::task::JoinSet::new(); - for entry_path in entries { - let storage = storage.clone(); - let path = entry_path.clone(); - let opts = options.clone(); + for entry_path in entries { + let storage = storage.clone(); + let path = entry_path.clone(); + let opts = options.clone(); - join_set.spawn(async move { - let result = import_file_with_options(&storage, &path, &opts).await; - (path, result) - }); + join_set.spawn(async move { + let result = import_file_with_options(&storage, &path, &opts).await; + (path, result) + }); - // Limit concurrency by draining when we hit the cap - if join_set.len() >= concurrency - && let Some(Ok((path, result))) = join_set.join_next().await - { - match result { - Ok(r) => results.push(Ok(r)), - Err(e) => { - tracing::warn!(path = %path.display(), error = %e, "failed to import file"); - results.push(Err(e)); - } - } - } + // Limit concurrency by draining when we hit the cap + if join_set.len() >= concurrency + && let Some(Ok((path, result))) = join_set.join_next().await + { + match result { + Ok(r) => results.push(Ok(r)), + Err(e) => { + tracing::warn!(path = %path.display(), error = %e, "failed to import file"); + results.push(Err(e)); + }, + } } + } - // Drain remaining tasks - while let Some(Ok((path, result))) = join_set.join_next().await { - match result { - Ok(r) => results.push(Ok(r)), - Err(e) => { - tracing::warn!(path = %path.display(), error = %e, "failed to import file"); - results.push(Err(e)); - } - } + // Drain remaining tasks + while let Some(Ok((path, result))) = join_set.join_next().await { + match result { + Ok(r) => results.push(Ok(r)), + Err(e) => { + tracing::warn!(path = %path.display(), error = %e, "failed to import file"); + results.push(Err(e)); + }, } + } - Ok(results) + Ok(results) } /// Extract markdown links from a file and store them in the database. async fn extract_and_store_links( - storage: &DynStorageBackend, - media_id: MediaId, - path: &Path, + storage: &DynStorageBackend, + media_id: MediaId, + path: &Path, ) -> Result<()> { - // Read file content - let content = tokio::fs::read_to_string(path).await.map_err(|e| { - PinakesError::Io(std::io::Error::new( - std::io::ErrorKind::Other, - format!("failed to read markdown file for link extraction: {e}"), - )) - })?; + // Read file content + let content = tokio::fs::read_to_string(path).await.map_err(|e| { + PinakesError::Io(std::io::Error::new( + std::io::ErrorKind::Other, + format!("failed to read markdown file for link extraction: {e}"), + )) + })?; - // Extract links - let extracted_links = links::extract_links(media_id, &content); + // Extract links + let extracted_links = links::extract_links(media_id, &content); - if extracted_links.is_empty() { - // No links found, just mark as extracted - storage.mark_links_extracted(media_id).await?; - return Ok(()); - } - - // Clear any existing links for this media (in case of re-import) - storage.clear_links_for_media(media_id).await?; - - // Save extracted links - storage - .save_markdown_links(media_id, &extracted_links) - .await?; - - // Mark links as extracted + if extracted_links.is_empty() { + // No links found, just mark as extracted storage.mark_links_extracted(media_id).await?; + return Ok(()); + } - tracing::debug!( - media_id = %media_id, - link_count = extracted_links.len(), - "extracted markdown links" - ); + // Clear any existing links for this media (in case of re-import) + storage.clear_links_for_media(media_id).await?; - Ok(()) + // Save extracted links + storage + .save_markdown_links(media_id, &extracted_links) + .await?; + + // Mark links as extracted + storage.mark_links_extracted(media_id).await?; + + tracing::debug!( + media_id = %media_id, + link_count = extracted_links.len(), + "extracted markdown links" + ); + + Ok(()) } diff --git a/crates/pinakes-core/src/integrity.rs b/crates/pinakes-core/src/integrity.rs index d0fd9c7..76e3837 100644 --- a/crates/pinakes-core/src/integrity.rs +++ b/crates/pinakes-core/src/integrity.rs @@ -1,373 +1,384 @@ -use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; +use std::{ + collections::{HashMap, HashSet}, + path::{Path, PathBuf}, +}; use serde::{Deserialize, Serialize}; use tracing::{info, warn}; -use crate::error::Result; -use crate::hash::compute_file_hash; -use crate::media_type::MediaType; -use crate::model::{ContentHash, MediaId}; -use crate::storage::DynStorageBackend; +use crate::{ + error::Result, + hash::compute_file_hash, + media_type::MediaType, + model::{ContentHash, MediaId}, + storage::DynStorageBackend, +}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OrphanReport { - /// Media items whose files no longer exist on disk. - pub orphaned_ids: Vec, - /// Files on disk that are not tracked in the database. - pub untracked_paths: Vec, - /// Files that appear to have moved (same hash, different path). - pub moved_files: Vec<(MediaId, PathBuf, PathBuf)>, + /// Media items whose files no longer exist on disk. + pub orphaned_ids: Vec, + /// Files on disk that are not tracked in the database. + pub untracked_paths: Vec, + /// Files that appear to have moved (same hash, different path). + pub moved_files: Vec<(MediaId, PathBuf, PathBuf)>, } #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum OrphanAction { - Delete, - Ignore, + Delete, + Ignore, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VerificationReport { - pub verified: usize, - pub mismatched: Vec<(MediaId, String, String)>, - pub missing: Vec, - pub errors: Vec<(MediaId, String)>, + pub verified: usize, + pub mismatched: Vec<(MediaId, String, String)>, + pub missing: Vec, + pub errors: Vec<(MediaId, String)>, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum IntegrityStatus { - Unverified, - Verified, - Mismatch, - Missing, + Unverified, + Verified, + Mismatch, + Missing, } impl std::fmt::Display for IntegrityStatus { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Unverified => write!(f, "unverified"), - Self::Verified => write!(f, "verified"), - Self::Mismatch => write!(f, "mismatch"), - Self::Missing => write!(f, "missing"), - } + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unverified => write!(f, "unverified"), + Self::Verified => write!(f, "verified"), + Self::Mismatch => write!(f, "mismatch"), + Self::Missing => write!(f, "missing"), } + } } impl std::str::FromStr for IntegrityStatus { - type Err = String; - fn from_str(s: &str) -> std::result::Result { - match s { - "unverified" => Ok(Self::Unverified), - "verified" => Ok(Self::Verified), - "mismatch" => Ok(Self::Mismatch), - "missing" => Ok(Self::Missing), - _ => Err(format!("unknown integrity status: {s}")), - } + type Err = String; + fn from_str(s: &str) -> std::result::Result { + match s { + "unverified" => Ok(Self::Unverified), + "verified" => Ok(Self::Verified), + "mismatch" => Ok(Self::Mismatch), + "missing" => Ok(Self::Missing), + _ => Err(format!("unknown integrity status: {s}")), } + } } /// Detect orphaned media items (files that no longer exist on disk), -/// untracked files (files on disk not in database), and moved files (same hash, different path). -pub async fn detect_orphans(storage: &DynStorageBackend) -> Result { - let media_paths = storage.list_media_paths().await?; - let mut orphaned_ids = Vec::new(); +/// untracked files (files on disk not in database), and moved files (same hash, +/// different path). +pub async fn detect_orphans( + storage: &DynStorageBackend, +) -> Result { + let media_paths = storage.list_media_paths().await?; + let mut orphaned_ids = Vec::new(); - // Build hash index: ContentHash -> Vec<(MediaId, PathBuf)> - let mut hash_index: HashMap> = HashMap::new(); - for (id, path, hash) in &media_paths { - hash_index - .entry(hash.clone()) - .or_default() - .push((*id, path.clone())); + // Build hash index: ContentHash -> Vec<(MediaId, PathBuf)> + let mut hash_index: HashMap> = + HashMap::new(); + for (id, path, hash) in &media_paths { + hash_index + .entry(hash.clone()) + .or_default() + .push((*id, path.clone())); + } + + // Detect orphaned files (in DB but not on disk) + for (id, path, _hash) in &media_paths { + if !path.exists() { + orphaned_ids.push(*id); } + } - // Detect orphaned files (in DB but not on disk) - for (id, path, _hash) in &media_paths { - if !path.exists() { - orphaned_ids.push(*id); - } - } + // Detect moved files (orphaned items with same hash existing elsewhere) + let moved_files = + detect_moved_files(&orphaned_ids, &media_paths, &hash_index); - // Detect moved files (orphaned items with same hash existing elsewhere) - let moved_files = detect_moved_files(&orphaned_ids, &media_paths, &hash_index); + // Detect untracked files (on disk but not in DB) + let untracked_paths = detect_untracked_files(storage, &media_paths).await?; - // Detect untracked files (on disk but not in DB) - let untracked_paths = detect_untracked_files(storage, &media_paths).await?; + info!( + orphaned = orphaned_ids.len(), + untracked = untracked_paths.len(), + moved = moved_files.len(), + total = media_paths.len(), + "orphan detection complete" + ); - info!( - orphaned = orphaned_ids.len(), - untracked = untracked_paths.len(), - moved = moved_files.len(), - total = media_paths.len(), - "orphan detection complete" - ); - - Ok(OrphanReport { - orphaned_ids, - untracked_paths, - moved_files, - }) + Ok(OrphanReport { + orphaned_ids, + untracked_paths, + moved_files, + }) } /// Detect files that appear to have moved (same content hash, different path). fn detect_moved_files( - orphaned_ids: &[MediaId], - media_paths: &[(MediaId, PathBuf, ContentHash)], - hash_index: &HashMap>, + orphaned_ids: &[MediaId], + media_paths: &[(MediaId, PathBuf, ContentHash)], + hash_index: &HashMap>, ) -> Vec<(MediaId, PathBuf, PathBuf)> { - let mut moved = Vec::new(); + let mut moved = Vec::new(); - // Build lookup map for orphaned items: MediaId -> (PathBuf, ContentHash) - let orphaned_map: HashMap = media_paths - .iter() - .filter(|(id, _, _)| orphaned_ids.contains(id)) - .map(|(id, path, hash)| (*id, (path.clone(), hash.clone()))) - .collect(); + // Build lookup map for orphaned items: MediaId -> (PathBuf, ContentHash) + let orphaned_map: HashMap = media_paths + .iter() + .filter(|(id, ..)| orphaned_ids.contains(id)) + .map(|(id, path, hash)| (*id, (path.clone(), hash.clone()))) + .collect(); - // For each orphaned item, check if there's another file with the same hash - for (orphaned_id, (old_path, hash)) in &orphaned_map { - if let Some(items_with_hash) = hash_index.get(hash) { - // Find other items with same hash that exist on disk - for (other_id, new_path) in items_with_hash { - // Skip if it's the same item - if other_id == orphaned_id { - continue; - } - - // Check if the new path exists - if new_path.exists() { - moved.push((*orphaned_id, old_path.clone(), new_path.clone())); - // Only report first match (most likely candidate) - break; - } - } + // For each orphaned item, check if there's another file with the same hash + for (orphaned_id, (old_path, hash)) in &orphaned_map { + if let Some(items_with_hash) = hash_index.get(hash) { + // Find other items with same hash that exist on disk + for (other_id, new_path) in items_with_hash { + // Skip if it's the same item + if other_id == orphaned_id { + continue; } - } - moved + // Check if the new path exists + if new_path.exists() { + moved.push((*orphaned_id, old_path.clone(), new_path.clone())); + // Only report first match (most likely candidate) + break; + } + } + } + } + + moved } /// Detect files on disk that are not tracked in the database. async fn detect_untracked_files( - storage: &DynStorageBackend, - media_paths: &[(MediaId, PathBuf, ContentHash)], + storage: &DynStorageBackend, + media_paths: &[(MediaId, PathBuf, ContentHash)], ) -> Result> { - // Get root directories - let roots = storage.list_root_dirs().await?; - if roots.is_empty() { - return Ok(Vec::new()); - } + // Get root directories + let roots = storage.list_root_dirs().await?; + if roots.is_empty() { + return Ok(Vec::new()); + } - // Build set of tracked paths for fast lookup - let tracked_paths: HashSet = media_paths - .iter() - .map(|(_, path, _)| path.clone()) - .collect(); + // Build set of tracked paths for fast lookup + let tracked_paths: HashSet = media_paths + .iter() + .map(|(_, path, _)| path.clone()) + .collect(); - // Get ignore patterns (we'll need to load config somehow, for now use empty) - let ignore_patterns: Vec = vec![ - ".*".to_string(), - "node_modules".to_string(), - "__pycache__".to_string(), - "target".to_string(), - ]; + // Get ignore patterns (we'll need to load config somehow, for now use empty) + let ignore_patterns: Vec = vec![ + ".*".to_string(), + "node_modules".to_string(), + "__pycache__".to_string(), + "target".to_string(), + ]; - // Walk filesystem for each root in parallel (limit concurrency to 4) - let mut filesystem_paths = HashSet::new(); - let mut tasks = tokio::task::JoinSet::new(); + // Walk filesystem for each root in parallel (limit concurrency to 4) + let mut filesystem_paths = HashSet::new(); + let mut tasks = tokio::task::JoinSet::new(); - for root in roots { - let ignore_patterns = ignore_patterns.clone(); - tasks.spawn_blocking(move || -> Result> { - let mut paths = Vec::new(); + for root in roots { + let ignore_patterns = ignore_patterns.clone(); + tasks.spawn_blocking(move || -> Result> { + let mut paths = Vec::new(); - let walker = walkdir::WalkDir::new(&root) - .follow_links(false) - .into_iter() - .filter_entry(|e| { - // Skip directories that match ignore patterns - if e.file_type().is_dir() { - let name = e.file_name().to_string_lossy(); - for pattern in &ignore_patterns { - if pattern.starts_with("*.") - && let Some(ext) = pattern.strip_prefix("*.") - && name.ends_with(ext) - { - // Extension pattern - return false; - } else if pattern.contains('*') { - // Glob pattern - simplified matching - let pattern_without_stars = pattern.replace('*', ""); - if name.contains(&pattern_without_stars) { - return false; - } - } else if name.as_ref() == pattern - || name.starts_with(&format!("{pattern}.")) - { - // Exact match or starts with pattern - return false; - } - } - } - true - }); - - for entry in walker { - match entry { - Ok(entry) => { - let path = entry.path(); - - // Only process files - if !path.is_file() { - continue; - } - - // Check if it's a supported media type - if MediaType::from_path(path).is_some() { - paths.push(path.to_path_buf()); - } - } - Err(e) => { - warn!(error = %e, "failed to read directory entry"); - } + let walker = walkdir::WalkDir::new(&root) + .follow_links(false) + .into_iter() + .filter_entry(|e| { + // Skip directories that match ignore patterns + if e.file_type().is_dir() { + let name = e.file_name().to_string_lossy(); + for pattern in &ignore_patterns { + if pattern.starts_with("*.") + && let Some(ext) = pattern.strip_prefix("*.") + && name.ends_with(ext) + { + // Extension pattern + return false; + } else if pattern.contains('*') { + // Glob pattern - simplified matching + let pattern_without_stars = pattern.replace('*', ""); + if name.contains(&pattern_without_stars) { + return false; } + } else if name.as_ref() == pattern + || name.starts_with(&format!("{pattern}.")) + { + // Exact match or starts with pattern + return false; + } } - - Ok(paths) + } + true }); - } - // Collect results from all tasks - while let Some(result) = tasks.join_next().await { - match result { - Ok(Ok(paths)) => { - filesystem_paths.extend(paths); + for entry in walker { + match entry { + Ok(entry) => { + let path = entry.path(); + + // Only process files + if !path.is_file() { + continue; } - Ok(Err(e)) => { - warn!(error = %e, "failed to walk directory"); - } - Err(e) => { - warn!(error = %e, "task join error"); + + // Check if it's a supported media type + if MediaType::from_path(path).is_some() { + paths.push(path.to_path_buf()); } + }, + Err(e) => { + warn!(error = %e, "failed to read directory entry"); + }, } + } + + Ok(paths) + }); + } + + // Collect results from all tasks + while let Some(result) = tasks.join_next().await { + match result { + Ok(Ok(paths)) => { + filesystem_paths.extend(paths); + }, + Ok(Err(e)) => { + warn!(error = %e, "failed to walk directory"); + }, + Err(e) => { + warn!(error = %e, "task join error"); + }, } + } - // Compute set difference: filesystem - tracked - let untracked: Vec = filesystem_paths - .difference(&tracked_paths) - .cloned() - .collect(); + // Compute set difference: filesystem - tracked + let untracked: Vec = filesystem_paths + .difference(&tracked_paths) + .cloned() + .collect(); - Ok(untracked) + Ok(untracked) } /// Resolve orphaned media items by deleting them from the database. pub async fn resolve_orphans( - storage: &DynStorageBackend, - action: OrphanAction, - ids: &[MediaId], + storage: &DynStorageBackend, + action: OrphanAction, + ids: &[MediaId], ) -> Result { - match action { - OrphanAction::Delete => { - let count = storage.batch_delete_media(ids).await?; - info!(count, "resolved orphans by deletion"); - Ok(count) - } - OrphanAction::Ignore => { - info!(count = ids.len(), "orphans ignored"); - Ok(0) - } - } + match action { + OrphanAction::Delete => { + let count = storage.batch_delete_media(ids).await?; + info!(count, "resolved orphans by deletion"); + Ok(count) + }, + OrphanAction::Ignore => { + info!(count = ids.len(), "orphans ignored"); + Ok(0) + }, + } } /// Verify integrity of media files by recomputing hashes and comparing. pub async fn verify_integrity( - storage: &DynStorageBackend, - media_ids: Option<&[MediaId]>, + storage: &DynStorageBackend, + media_ids: Option<&[MediaId]>, ) -> Result { - let all_paths = storage.list_media_paths().await?; + let all_paths = storage.list_media_paths().await?; - let paths_to_check: Vec<(MediaId, PathBuf, ContentHash)> = if let Some(ids) = media_ids { - let id_set: std::collections::HashSet = ids.iter().copied().collect(); - all_paths - .into_iter() - .filter(|(id, _, _)| id_set.contains(id)) - .collect() + let paths_to_check: Vec<(MediaId, PathBuf, ContentHash)> = + if let Some(ids) = media_ids { + let id_set: std::collections::HashSet = + ids.iter().copied().collect(); + all_paths + .into_iter() + .filter(|(id, ..)| id_set.contains(id)) + .collect() } else { - all_paths + all_paths }; - let mut report = VerificationReport { - verified: 0, - mismatched: Vec::new(), - missing: Vec::new(), - errors: Vec::new(), - }; + let mut report = VerificationReport { + verified: 0, + mismatched: Vec::new(), + missing: Vec::new(), + errors: Vec::new(), + }; - for (id, path, expected_hash) in paths_to_check { - if !path.exists() { - report.missing.push(id); - continue; - } - - match compute_file_hash(&path).await { - Ok(actual_hash) => { - if actual_hash.0 == expected_hash.0 { - report.verified += 1; - } else { - report - .mismatched - .push((id, expected_hash.0.clone(), actual_hash.0)); - } - } - Err(e) => { - report.errors.push((id, e.to_string())); - } - } + for (id, path, expected_hash) in paths_to_check { + if !path.exists() { + report.missing.push(id); + continue; } - info!( - verified = report.verified, - mismatched = report.mismatched.len(), - missing = report.missing.len(), - errors = report.errors.len(), - "integrity verification complete" - ); + match compute_file_hash(&path).await { + Ok(actual_hash) => { + if actual_hash.0 == expected_hash.0 { + report.verified += 1; + } else { + report + .mismatched + .push((id, expected_hash.0.clone(), actual_hash.0)); + } + }, + Err(e) => { + report.errors.push((id, e.to_string())); + }, + } + } - Ok(report) + info!( + verified = report.verified, + mismatched = report.mismatched.len(), + missing = report.missing.len(), + errors = report.errors.len(), + "integrity verification complete" + ); + + Ok(report) } /// Clean up orphaned thumbnail files that don't correspond to any media item. pub async fn cleanup_orphaned_thumbnails( - storage: &DynStorageBackend, - thumbnail_dir: &Path, + storage: &DynStorageBackend, + thumbnail_dir: &Path, ) -> Result { - let media_paths = storage.list_media_paths().await?; - let known_ids: std::collections::HashSet = media_paths - .iter() - .map(|(id, _, _)| id.0.to_string()) - .collect(); + let media_paths = storage.list_media_paths().await?; + let known_ids: std::collections::HashSet = media_paths + .iter() + .map(|(id, ..)| id.0.to_string()) + .collect(); - let mut removed = 0; + let mut removed = 0; - if thumbnail_dir.exists() { - let entries = std::fs::read_dir(thumbnail_dir)?; - for entry in entries.flatten() { - let path = entry.path(); - if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) - && !known_ids.contains(stem) - { - if let Err(e) = std::fs::remove_file(&path) { - warn!(path = %path.display(), error = %e, "failed to remove orphaned thumbnail"); - } else { - removed += 1; - } - } + if thumbnail_dir.exists() { + let entries = std::fs::read_dir(thumbnail_dir)?; + for entry in entries.flatten() { + let path = entry.path(); + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) + && !known_ids.contains(stem) + { + if let Err(e) = std::fs::remove_file(&path) { + warn!(path = %path.display(), error = %e, "failed to remove orphaned thumbnail"); + } else { + removed += 1; } + } } + } - info!(removed, "orphaned thumbnail cleanup complete"); - Ok(removed) + info!(removed, "orphaned thumbnail cleanup complete"); + Ok(removed) } diff --git a/crates/pinakes-core/src/jobs.rs b/crates/pinakes-core/src/jobs.rs index 6751ede..0a73c9a 100644 --- a/crates/pinakes-core/src/jobs.rs +++ b/crates/pinakes-core/src/jobs.rs @@ -1,6 +1,4 @@ -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Arc; +use std::{collections::HashMap, path::PathBuf, sync::Arc}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -14,258 +12,268 @@ use crate::model::MediaId; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case", tag = "type")] pub enum JobKind { - Scan { - path: Option, - }, - GenerateThumbnails { - media_ids: Vec, - }, - VerifyIntegrity { - media_ids: Vec, - }, - OrphanDetection, - CleanupThumbnails, - Export { - format: ExportFormat, - destination: PathBuf, - }, - Transcode { - media_id: MediaId, - profile: String, - }, - Enrich { - media_ids: Vec, - }, - CleanupAnalytics, + Scan { + path: Option, + }, + GenerateThumbnails { + media_ids: Vec, + }, + VerifyIntegrity { + media_ids: Vec, + }, + OrphanDetection, + CleanupThumbnails, + Export { + format: ExportFormat, + destination: PathBuf, + }, + Transcode { + media_id: MediaId, + profile: String, + }, + Enrich { + media_ids: Vec, + }, + CleanupAnalytics, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ExportFormat { - Json, - Csv, + Json, + Csv, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case", tag = "state")] pub enum JobStatus { - Pending, - Running { progress: f32, message: String }, - Completed { result: Value }, - Failed { error: String }, - Cancelled, + Pending, + Running { progress: f32, message: String }, + Completed { result: Value }, + Failed { error: String }, + Cancelled, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Job { - pub id: Uuid, - pub kind: JobKind, - pub status: JobStatus, - pub created_at: DateTime, - pub updated_at: DateTime, + pub id: Uuid, + pub kind: JobKind, + pub status: JobStatus, + pub created_at: DateTime, + pub updated_at: DateTime, } struct WorkerItem { - job_id: Uuid, - kind: JobKind, - cancel: CancellationToken, + job_id: Uuid, + kind: JobKind, + cancel: CancellationToken, } pub struct JobQueue { - jobs: Arc>>, - cancellations: Arc>>, - tx: mpsc::Sender, + jobs: Arc>>, + cancellations: Arc>>, + tx: mpsc::Sender, } impl JobQueue { - /// Create a new job queue and spawn `worker_count` background workers. - /// - /// The `executor` callback is invoked for each job; it receives the job kind, - /// a progress-reporting callback, and a cancellation token. - pub fn new(worker_count: usize, executor: F) -> Arc - where - F: Fn( - Uuid, - JobKind, - CancellationToken, - Arc>>, - ) -> tokio::task::JoinHandle<()> - + Send - + Sync - + 'static, - { - let (tx, rx) = mpsc::channel::(256); - let rx = Arc::new(tokio::sync::Mutex::new(rx)); - let jobs: Arc>> = Arc::new(RwLock::new(HashMap::new())); - let cancellations: Arc>> = - Arc::new(RwLock::new(HashMap::new())); + /// Create a new job queue and spawn `worker_count` background workers. + /// + /// The `executor` callback is invoked for each job; it receives the job kind, + /// a progress-reporting callback, and a cancellation token. + pub fn new(worker_count: usize, executor: F) -> Arc + where + F: Fn( + Uuid, + JobKind, + CancellationToken, + Arc>>, + ) -> tokio::task::JoinHandle<()> + + Send + + Sync + + 'static, + { + let (tx, rx) = mpsc::channel::(256); + let rx = Arc::new(tokio::sync::Mutex::new(rx)); + let jobs: Arc>> = + Arc::new(RwLock::new(HashMap::new())); + let cancellations: Arc>> = + Arc::new(RwLock::new(HashMap::new())); - let executor = Arc::new(executor); + let executor = Arc::new(executor); - for _ in 0..worker_count { - let rx = rx.clone(); - let jobs = jobs.clone(); - let cancellations = cancellations.clone(); - let executor = executor.clone(); + for _ in 0..worker_count { + let rx = rx.clone(); + let jobs = jobs.clone(); + let cancellations = cancellations.clone(); + let executor = executor.clone(); - tokio::spawn(async move { - loop { - let item = { - let mut guard = rx.lock().await; - guard.recv().await - }; - let Some(item) = item else { break }; + tokio::spawn(async move { + loop { + let item = { + let mut guard = rx.lock().await; + guard.recv().await + }; + let Some(item) = item else { break }; - // Mark as running - { - let mut map = jobs.write().await; - if let Some(job) = map.get_mut(&item.job_id) { - job.status = JobStatus::Running { - progress: 0.0, - message: "starting".to_string(), - }; - job.updated_at = Utc::now(); - } - } - - let handle = executor(item.job_id, item.kind, item.cancel, jobs.clone()); - let _ = handle.await; - - // Clean up cancellation token - cancellations.write().await.remove(&item.job_id); - } - }); - } - - Arc::new(Self { - jobs, - cancellations, - tx, - }) - } - - /// Submit a new job, returning its ID. - pub async fn submit(&self, kind: JobKind) -> Uuid { - let id = Uuid::now_v7(); - let now = Utc::now(); - let cancel = CancellationToken::new(); - - let job = Job { - id, - kind: kind.clone(), - status: JobStatus::Pending, - created_at: now, - updated_at: now, - }; - - self.jobs.write().await.insert(id, job); - self.cancellations.write().await.insert(id, cancel.clone()); - - let item = WorkerItem { - job_id: id, - kind, - cancel, - }; - - // If the channel is full we still record the job — it'll stay Pending - let _ = self.tx.send(item).await; - id - } - - /// Get the status of a job. - pub async fn status(&self, id: Uuid) -> Option { - self.jobs.read().await.get(&id).cloned() - } - - /// List all jobs, most recent first. - pub async fn list(&self) -> Vec { - let map = self.jobs.read().await; - let mut jobs: Vec = map.values().cloned().collect(); - jobs.sort_by_key(|job| std::cmp::Reverse(job.created_at)); - jobs - } - - /// Cancel a running or pending job. - pub async fn cancel(&self, id: Uuid) -> bool { - if let Some(token) = self.cancellations.read().await.get(&id) { - token.cancel(); - let mut map = self.jobs.write().await; - if let Some(job) = map.get_mut(&id) { - job.status = JobStatus::Cancelled; - job.updated_at = Utc::now(); + // Mark as running + { + let mut map = jobs.write().await; + if let Some(job) = map.get_mut(&item.job_id) { + job.status = JobStatus::Running { + progress: 0.0, + message: "starting".to_string(), + }; + job.updated_at = Utc::now(); } - true - } else { - false + } + + let handle = + executor(item.job_id, item.kind, item.cancel, jobs.clone()); + let _ = handle.await; + + // Clean up cancellation token + cancellations.write().await.remove(&item.job_id); } + }); } - /// Update a job's progress. Called by executors. - pub async fn update_progress( - jobs: &Arc>>, - id: Uuid, - progress: f32, - message: String, - ) { - let mut map = jobs.write().await; - if let Some(job) = map.get_mut(&id) { - job.status = JobStatus::Running { progress, message }; - job.updated_at = Utc::now(); - } + Arc::new(Self { + jobs, + cancellations, + tx, + }) + } + + /// Submit a new job, returning its ID. + pub async fn submit(&self, kind: JobKind) -> Uuid { + let id = Uuid::now_v7(); + let now = Utc::now(); + let cancel = CancellationToken::new(); + + let job = Job { + id, + kind: kind.clone(), + status: JobStatus::Pending, + created_at: now, + updated_at: now, + }; + + self.jobs.write().await.insert(id, job); + self.cancellations.write().await.insert(id, cancel.clone()); + + let item = WorkerItem { + job_id: id, + kind, + cancel, + }; + + // If the channel is full we still record the job — it'll stay Pending + let _ = self.tx.send(item).await; + id + } + + /// Get the status of a job. + pub async fn status(&self, id: Uuid) -> Option { + self.jobs.read().await.get(&id).cloned() + } + + /// List all jobs, most recent first. + pub async fn list(&self) -> Vec { + let map = self.jobs.read().await; + let mut jobs: Vec = map.values().cloned().collect(); + jobs.sort_by_key(|job| std::cmp::Reverse(job.created_at)); + jobs + } + + /// Cancel a running or pending job. + pub async fn cancel(&self, id: Uuid) -> bool { + if let Some(token) = self.cancellations.read().await.get(&id) { + token.cancel(); + let mut map = self.jobs.write().await; + if let Some(job) = map.get_mut(&id) { + job.status = JobStatus::Cancelled; + job.updated_at = Utc::now(); + } + true + } else { + false + } + } + + /// Update a job's progress. Called by executors. + pub async fn update_progress( + jobs: &Arc>>, + id: Uuid, + progress: f32, + message: String, + ) { + let mut map = jobs.write().await; + if let Some(job) = map.get_mut(&id) { + job.status = JobStatus::Running { progress, message }; + job.updated_at = Utc::now(); + } + } + + /// Mark a job as completed. + pub async fn complete( + jobs: &Arc>>, + id: Uuid, + result: Value, + ) { + let mut map = jobs.write().await; + if let Some(job) = map.get_mut(&id) { + job.status = JobStatus::Completed { result }; + job.updated_at = Utc::now(); + } + } + + /// Mark a job as failed. + pub async fn fail( + jobs: &Arc>>, + id: Uuid, + error: String, + ) { + let mut map = jobs.write().await; + if let Some(job) = map.get_mut(&id) { + job.status = JobStatus::Failed { error }; + job.updated_at = Utc::now(); + } + } + + /// Get job queue statistics + pub async fn stats(&self) -> JobQueueStats { + let jobs = self.jobs.read().await; + let mut pending = 0; + let mut running = 0; + let mut completed = 0; + let mut failed = 0; + + for job in jobs.values() { + match job.status { + JobStatus::Pending => pending += 1, + JobStatus::Running { .. } => running += 1, + JobStatus::Completed { .. } => completed += 1, + JobStatus::Failed { .. } => failed += 1, + JobStatus::Cancelled => {}, // Don't count cancelled jobs + } } - /// Mark a job as completed. - pub async fn complete(jobs: &Arc>>, id: Uuid, result: Value) { - let mut map = jobs.write().await; - if let Some(job) = map.get_mut(&id) { - job.status = JobStatus::Completed { result }; - job.updated_at = Utc::now(); - } - } - - /// Mark a job as failed. - pub async fn fail(jobs: &Arc>>, id: Uuid, error: String) { - let mut map = jobs.write().await; - if let Some(job) = map.get_mut(&id) { - job.status = JobStatus::Failed { error }; - job.updated_at = Utc::now(); - } - } - - /// Get job queue statistics - pub async fn stats(&self) -> JobQueueStats { - let jobs = self.jobs.read().await; - let mut pending = 0; - let mut running = 0; - let mut completed = 0; - let mut failed = 0; - - for job in jobs.values() { - match job.status { - JobStatus::Pending => pending += 1, - JobStatus::Running { .. } => running += 1, - JobStatus::Completed { .. } => completed += 1, - JobStatus::Failed { .. } => failed += 1, - JobStatus::Cancelled => {} // Don't count cancelled jobs - } - } - - JobQueueStats { - pending, - running, - completed, - failed, - total: jobs.len(), - } + JobQueueStats { + pending, + running, + completed, + failed, + total: jobs.len(), } + } } /// Statistics about the job queue #[derive(Debug, Clone, Default)] pub struct JobQueueStats { - pub pending: usize, - pub running: usize, - pub completed: usize, - pub failed: usize, - pub total: usize, + pub pending: usize, + pub running: usize, + pub completed: usize, + pub failed: usize, + pub total: usize, } diff --git a/crates/pinakes-core/src/links.rs b/crates/pinakes-core/src/links.rs index f037e76..986a850 100644 --- a/crates/pinakes-core/src/links.rs +++ b/crates/pinakes-core/src/links.rs @@ -1,4 +1,5 @@ -//! Markdown link extraction and management for Obsidian-style bidirectional links. +//! Markdown link extraction and management for Obsidian-style bidirectional +//! links. //! //! This module provides: //! - Wikilink extraction (`[[target]]` and `[[target|display]]`) @@ -24,254 +25,289 @@ const CONTEXT_CHARS_AFTER: usize = 50; /// - Wikilinks: `[[target]]` and `[[target|display text]]` /// - Embeds: `![[target]]` /// - Markdown links: `[text](path)` (internal paths only, no http/https) -pub fn extract_links(source_media_id: MediaId, content: &str) -> Vec { - let mut links = Vec::new(); +pub fn extract_links( + source_media_id: MediaId, + content: &str, +) -> Vec { + let mut links = Vec::new(); - // Extract wikilinks: [[target]] or [[target|display]] - links.extend(extract_wikilinks(source_media_id, content)); + // Extract wikilinks: [[target]] or [[target|display]] + links.extend(extract_wikilinks(source_media_id, content)); - // Extract embeds: ![[target]] - links.extend(extract_embeds(source_media_id, content)); + // Extract embeds: ![[target]] + links.extend(extract_embeds(source_media_id, content)); - // Extract markdown links: [text](path) - links.extend(extract_markdown_links(source_media_id, content)); + // Extract markdown links: [text](path) + links.extend(extract_markdown_links(source_media_id, content)); - links + links } /// Extract wikilinks from content. -/// Matches: `[[target]]` or `[[target|display text]]` but NOT `![[...]]` (embeds) -fn extract_wikilinks(source_media_id: MediaId, content: &str) -> Vec { - // Match [[...]] - we'll manually filter out embeds that are preceded by ! - let re = Regex::new(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]").unwrap(); - let mut links = Vec::new(); +/// Matches: `[[target]]` or `[[target|display text]]` but NOT `![[...]]` +/// (embeds) +fn extract_wikilinks( + source_media_id: MediaId, + content: &str, +) -> Vec { + // Match [[...]] - we'll manually filter out embeds that are preceded by ! + let re = Regex::new(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]").unwrap(); + let mut links = Vec::new(); - for (line_num, line) in content.lines().enumerate() { - for cap in re.captures_iter(line) { - let full_match = cap.get(0).unwrap(); - let match_start = full_match.start(); + for (line_num, line) in content.lines().enumerate() { + for cap in re.captures_iter(line) { + let full_match = cap.get(0).unwrap(); + let match_start = full_match.start(); - // Check if preceded by ! (which would make it an embed, not a wikilink) - if match_start > 0 { - let bytes = line.as_bytes(); - if bytes.get(match_start - 1) == Some(&b'!') { - continue; // Skip embeds - } - } - - let target = cap.get(1).unwrap().as_str().trim(); - let display_text = cap.get(2).map(|m| m.as_str().trim().to_string()); - - let context = extract_context(content, line_num, full_match.start(), full_match.end()); - - links.push(MarkdownLink { - id: Uuid::now_v7(), - source_media_id, - target_path: target.to_string(), - target_media_id: None, // Will be resolved later - link_type: LinkType::Wikilink, - link_text: display_text.or_else(|| Some(target.to_string())), - line_number: Some(line_num as i32 + 1), // 1-indexed - context: Some(context), - created_at: chrono::Utc::now(), - }); + // Check if preceded by ! (which would make it an embed, not a wikilink) + if match_start > 0 { + let bytes = line.as_bytes(); + if bytes.get(match_start - 1) == Some(&b'!') { + continue; // Skip embeds } - } + } - links + let target = cap.get(1).unwrap().as_str().trim(); + let display_text = cap.get(2).map(|m| m.as_str().trim().to_string()); + + let context = extract_context( + content, + line_num, + full_match.start(), + full_match.end(), + ); + + links.push(MarkdownLink { + id: Uuid::now_v7(), + source_media_id, + target_path: target.to_string(), + target_media_id: None, // Will be resolved later + link_type: LinkType::Wikilink, + link_text: display_text.or_else(|| Some(target.to_string())), + line_number: Some(line_num as i32 + 1), // 1-indexed + context: Some(context), + created_at: chrono::Utc::now(), + }); + } + } + + links } /// Extract embeds from content. /// Matches: `![[target]]` -fn extract_embeds(source_media_id: MediaId, content: &str) -> Vec { - let re = Regex::new(r"!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]").unwrap(); - let mut links = Vec::new(); +fn extract_embeds( + source_media_id: MediaId, + content: &str, +) -> Vec { + let re = Regex::new(r"!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]").unwrap(); + let mut links = Vec::new(); - for (line_num, line) in content.lines().enumerate() { - for cap in re.captures_iter(line) { - let full_match = cap.get(0).unwrap(); - let target = cap.get(1).unwrap().as_str().trim(); - let display_text = cap.get(2).map(|m| m.as_str().trim().to_string()); + for (line_num, line) in content.lines().enumerate() { + for cap in re.captures_iter(line) { + let full_match = cap.get(0).unwrap(); + let target = cap.get(1).unwrap().as_str().trim(); + let display_text = cap.get(2).map(|m| m.as_str().trim().to_string()); - let context = extract_context(content, line_num, full_match.start(), full_match.end()); + let context = extract_context( + content, + line_num, + full_match.start(), + full_match.end(), + ); - links.push(MarkdownLink { - id: Uuid::now_v7(), - source_media_id, - target_path: target.to_string(), - target_media_id: None, - link_type: LinkType::Embed, - link_text: display_text.or_else(|| Some(target.to_string())), - line_number: Some(line_num as i32 + 1), - context: Some(context), - created_at: chrono::Utc::now(), - }); - } + links.push(MarkdownLink { + id: Uuid::now_v7(), + source_media_id, + target_path: target.to_string(), + target_media_id: None, + link_type: LinkType::Embed, + link_text: display_text.or_else(|| Some(target.to_string())), + line_number: Some(line_num as i32 + 1), + context: Some(context), + created_at: chrono::Utc::now(), + }); } + } - links + links } /// Extract markdown links from content. /// Matches: `[text](path)` but only for internal paths (no http/https) -fn extract_markdown_links(source_media_id: MediaId, content: &str) -> Vec { - // Match [text](path) where path doesn't start with http:// or https:// - let re = Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap(); - let mut links = Vec::new(); +fn extract_markdown_links( + source_media_id: MediaId, + content: &str, +) -> Vec { + // Match [text](path) where path doesn't start with http:// or https:// + let re = Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap(); + let mut links = Vec::new(); - for (line_num, line) in content.lines().enumerate() { - for cap in re.captures_iter(line) { - let full_match = cap.get(0).unwrap(); - let match_start = full_match.start(); + for (line_num, line) in content.lines().enumerate() { + for cap in re.captures_iter(line) { + let full_match = cap.get(0).unwrap(); + let match_start = full_match.start(); - // Skip markdown images: ![alt](image.png) - // Check if the character immediately before '[' is '!' - if match_start > 0 && line.as_bytes().get(match_start - 1) == Some(&b'!') { - continue; - } + // Skip markdown images: ![alt](image.png) + // Check if the character immediately before '[' is '!' + if match_start > 0 && line.as_bytes().get(match_start - 1) == Some(&b'!') + { + continue; + } - let text = cap.get(1).unwrap().as_str().trim(); - let path = cap.get(2).unwrap().as_str().trim(); + let text = cap.get(1).unwrap().as_str().trim(); + let path = cap.get(2).unwrap().as_str().trim(); - // Skip external links - if path.starts_with("http://") - || path.starts_with("https://") - || path.starts_with("mailto:") - || path.starts_with("ftp://") - { - continue; - } + // Skip external links + if path.starts_with("http://") + || path.starts_with("https://") + || path.starts_with("mailto:") + || path.starts_with("ftp://") + { + continue; + } - // Skip anchor-only links - if path.starts_with('#') { - continue; - } + // Skip anchor-only links + if path.starts_with('#') { + continue; + } - // Remove any anchor from the path for resolution - let target_path = path.split('#').next().unwrap_or(path); + // Remove any anchor from the path for resolution + let target_path = path.split('#').next().unwrap_or(path); - let context = extract_context(content, line_num, full_match.start(), full_match.end()); + let context = extract_context( + content, + line_num, + full_match.start(), + full_match.end(), + ); - links.push(MarkdownLink { - id: Uuid::now_v7(), - source_media_id, - target_path: target_path.to_string(), - target_media_id: None, - link_type: LinkType::MarkdownLink, - link_text: Some(text.to_string()), - line_number: Some(line_num as i32 + 1), - context: Some(context), - created_at: chrono::Utc::now(), - }); - } + links.push(MarkdownLink { + id: Uuid::now_v7(), + source_media_id, + target_path: target_path.to_string(), + target_media_id: None, + link_type: LinkType::MarkdownLink, + link_text: Some(text.to_string()), + line_number: Some(line_num as i32 + 1), + context: Some(context), + created_at: chrono::Utc::now(), + }); } + } - links + links } /// Extract surrounding context for a link. -fn extract_context(content: &str, line_num: usize, _start: usize, _end: usize) -> String { - let lines: Vec<&str> = content.lines().collect(); - if line_num >= lines.len() { - return String::new(); - } +fn extract_context( + content: &str, + line_num: usize, + _start: usize, + _end: usize, +) -> String { + let lines: Vec<&str> = content.lines().collect(); + if line_num >= lines.len() { + return String::new(); + } - let line = lines[line_num]; - let line_len = line.len(); + let line = lines[line_num]; + let line_len = line.len(); - // Get surrounding lines for context if the current line is short - if line_len < 30 && line_num > 0 { - // Include previous line - let prev = lines.get(line_num.saturating_sub(1)).unwrap_or(&""); - let next = lines.get(line_num + 1).unwrap_or(&""); - return format!("{} {} {}", prev.trim(), line.trim(), next.trim()) - .chars() - .take(CONTEXT_CHARS_BEFORE + CONTEXT_CHARS_AFTER + 20) - .collect(); - } + // Get surrounding lines for context if the current line is short + if line_len < 30 && line_num > 0 { + // Include previous line + let prev = lines.get(line_num.saturating_sub(1)).unwrap_or(&""); + let next = lines.get(line_num + 1).unwrap_or(&""); + return format!("{} {} {}", prev.trim(), line.trim(), next.trim()) + .chars() + .take(CONTEXT_CHARS_BEFORE + CONTEXT_CHARS_AFTER + 20) + .collect(); + } - // Truncate long lines - if line_len > CONTEXT_CHARS_BEFORE + CONTEXT_CHARS_AFTER { - line.chars() - .take(CONTEXT_CHARS_BEFORE + CONTEXT_CHARS_AFTER) - .collect() - } else { - line.to_string() - } + // Truncate long lines + if line_len > CONTEXT_CHARS_BEFORE + CONTEXT_CHARS_AFTER { + line + .chars() + .take(CONTEXT_CHARS_BEFORE + CONTEXT_CHARS_AFTER) + .collect() + } else { + line.to_string() + } } /// Link resolution strategies for finding target media items. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ResolutionStrategy { - /// Direct path match - DirectPath, - /// Relative to source directory - RelativeToSource, - /// Filename with .md extension added - FilenameWithMd, - /// Filename-only search (Obsidian-style) - FilenameOnly, + /// Direct path match + DirectPath, + /// Relative to source directory + RelativeToSource, + /// Filename with .md extension added + FilenameWithMd, + /// Filename-only search (Obsidian-style) + FilenameOnly, } /// Resolve a link target to possible file paths. /// /// Returns a list of candidate paths to check, in order of preference. pub fn resolve_link_candidates( - target: &str, - source_path: &Path, - root_dirs: &[std::path::PathBuf], + target: &str, + source_path: &Path, + root_dirs: &[std::path::PathBuf], ) -> Vec { - let mut candidates = Vec::new(); + let mut candidates = Vec::new(); - // Clean up the target path - let target = target.trim(); + // Clean up the target path + let target = target.trim(); - // 1. Direct path - if it looks like a path - if target.contains('/') || target.contains('\\') { - let direct = std::path::PathBuf::from(target); - if direct.is_absolute() { - candidates.push(direct); - } else { - // Relative to each root dir - for root in root_dirs { - candidates.push(root.join(&direct)); - } - } - } - - // 2. Relative to source file's directory - if let Some(source_dir) = source_path.parent() { - let relative = source_dir.join(target); - candidates.push(relative.clone()); - - // Also try with .md extension - if !target.ends_with(".md") { - candidates.push(relative.with_extension("md")); - let mut with_md = relative.clone(); - with_md.set_file_name(format!( - "{}.md", - relative.file_name().unwrap_or_default().to_string_lossy() - )); - candidates.push(with_md); - } - } - - // 3. Filename with .md extension in root dirs - let target_with_md = if target.ends_with(".md") { - target.to_string() + // 1. Direct path - if it looks like a path + if target.contains('/') || target.contains('\\') { + let direct = std::path::PathBuf::from(target); + if direct.is_absolute() { + candidates.push(direct); } else { - format!("{}.md", target) - }; - - for root in root_dirs { - candidates.push(root.join(&target_with_md)); + // Relative to each root dir + for root in root_dirs { + candidates.push(root.join(&direct)); + } } + } - // 4. Remove duplicates while preserving order - let mut seen = std::collections::HashSet::new(); - candidates.retain(|p| seen.insert(p.clone())); + // 2. Relative to source file's directory + if let Some(source_dir) = source_path.parent() { + let relative = source_dir.join(target); + candidates.push(relative.clone()); - candidates + // Also try with .md extension + if !target.ends_with(".md") { + candidates.push(relative.with_extension("md")); + let mut with_md = relative.clone(); + with_md.set_file_name(format!( + "{}.md", + relative.file_name().unwrap_or_default().to_string_lossy() + )); + candidates.push(with_md); + } + } + + // 3. Filename with .md extension in root dirs + let target_with_md = if target.ends_with(".md") { + target.to_string() + } else { + format!("{}.md", target) + }; + + for root in root_dirs { + candidates.push(root.join(&target_with_md)); + } + + // 4. Remove duplicates while preserving order + let mut seen = std::collections::HashSet::new(); + candidates.retain(|p| seen.insert(p.clone())); + + candidates } /// Extract frontmatter aliases from markdown content. @@ -279,102 +315,107 @@ pub fn resolve_link_candidates( /// Obsidian uses the `aliases` field in frontmatter to define alternative names /// for a note that can be used in wikilinks. pub fn extract_aliases(content: &str) -> Vec { - let Ok(parsed) = gray_matter::Matter::::new().parse(content) else { - return Vec::new(); - }; + let Ok(parsed) = + gray_matter::Matter::::new().parse(content) + else { + return Vec::new(); + }; - let Some(data) = parsed.data else { - return Vec::new(); - }; + let Some(data) = parsed.data else { + return Vec::new(); + }; - let gray_matter::Pod::Hash(map) = data else { - return Vec::new(); - }; + let gray_matter::Pod::Hash(map) = data else { + return Vec::new(); + }; - let Some(aliases) = map.get("aliases") else { - return Vec::new(); - }; + let Some(aliases) = map.get("aliases") else { + return Vec::new(); + }; - match aliases { - gray_matter::Pod::Array(arr) => arr - .iter() - .filter_map(|a| { - if let gray_matter::Pod::String(s) = a { - Some(s.clone()) - } else { - None - } - }) - .collect(), - gray_matter::Pod::String(s) => { - // Single alias as string - vec![s.clone()] - } - _ => Vec::new(), - } + match aliases { + gray_matter::Pod::Array(arr) => { + arr + .iter() + .filter_map(|a| { + if let gray_matter::Pod::String(s) = a { + Some(s.clone()) + } else { + None + } + }) + .collect() + }, + gray_matter::Pod::String(s) => { + // Single alias as string + vec![s.clone()] + }, + _ => Vec::new(), + } } #[cfg(test)] mod tests { - use super::*; + use super::*; - fn test_media_id() -> MediaId { - MediaId(Uuid::nil()) - } + fn test_media_id() -> MediaId { + MediaId(Uuid::nil()) + } - #[test] - fn test_extract_simple_wikilink() { - let content = "This is a [[simple link]] in text."; - let links = extract_links(test_media_id(), content); + #[test] + fn test_extract_simple_wikilink() { + let content = "This is a [[simple link]] in text."; + let links = extract_links(test_media_id(), content); - assert_eq!(links.len(), 1); - assert_eq!(links[0].target_path, "simple link"); - assert_eq!(links[0].link_type, LinkType::Wikilink); - assert_eq!(links[0].link_text, Some("simple link".to_string())); - } + assert_eq!(links.len(), 1); + assert_eq!(links[0].target_path, "simple link"); + assert_eq!(links[0].link_type, LinkType::Wikilink); + assert_eq!(links[0].link_text, Some("simple link".to_string())); + } - #[test] - fn test_extract_wikilink_with_display() { - let content = "Check out [[target note|this article]] for more."; - let links = extract_links(test_media_id(), content); + #[test] + fn test_extract_wikilink_with_display() { + let content = "Check out [[target note|this article]] for more."; + let links = extract_links(test_media_id(), content); - assert_eq!(links.len(), 1); - assert_eq!(links[0].target_path, "target note"); - assert_eq!(links[0].link_text, Some("this article".to_string())); - } + assert_eq!(links.len(), 1); + assert_eq!(links[0].target_path, "target note"); + assert_eq!(links[0].link_text, Some("this article".to_string())); + } - #[test] - fn test_extract_embed() { - let content = "Here is an image: ![[image.png]]"; - let links = extract_links(test_media_id(), content); + #[test] + fn test_extract_embed() { + let content = "Here is an image: ![[image.png]]"; + let links = extract_links(test_media_id(), content); - assert_eq!(links.len(), 1); - assert_eq!(links[0].target_path, "image.png"); - assert_eq!(links[0].link_type, LinkType::Embed); - } + assert_eq!(links.len(), 1); + assert_eq!(links[0].target_path, "image.png"); + assert_eq!(links[0].link_type, LinkType::Embed); + } - #[test] - fn test_extract_markdown_link() { - let content = "Read [the documentation](docs/README.md) for details."; - let links = extract_links(test_media_id(), content); + #[test] + fn test_extract_markdown_link() { + let content = "Read [the documentation](docs/README.md) for details."; + let links = extract_links(test_media_id(), content); - assert_eq!(links.len(), 1); - assert_eq!(links[0].target_path, "docs/README.md"); - assert_eq!(links[0].link_type, LinkType::MarkdownLink); - assert_eq!(links[0].link_text, Some("the documentation".to_string())); - } + assert_eq!(links.len(), 1); + assert_eq!(links[0].target_path, "docs/README.md"); + assert_eq!(links[0].link_type, LinkType::MarkdownLink); + assert_eq!(links[0].link_text, Some("the documentation".to_string())); + } - #[test] - fn test_skip_external_links() { - let content = "Visit [our site](https://example.com) or [email us](mailto:test@test.com)."; - let links = extract_links(test_media_id(), content); + #[test] + fn test_skip_external_links() { + let content = "Visit [our site](https://example.com) or [email \ + us](mailto:test@test.com)."; + let links = extract_links(test_media_id(), content); - assert!(links.is_empty()); - } + assert!(links.is_empty()); + } - #[test] - fn test_multiple_links() { - let content = r#" + #[test] + fn test_multiple_links() { + let content = r#" # My Note This links to [[Note A]] and also [[Note B|Note B Title]]. @@ -383,44 +424,45 @@ We also have a markdown link to [config](./config.md). And an embedded image: ![[diagram.png]] "#; - let links = extract_links(test_media_id(), content); + let links = extract_links(test_media_id(), content); - assert_eq!(links.len(), 4); + assert_eq!(links.len(), 4); - let types: Vec<_> = links.iter().map(|l| l.link_type).collect(); - assert!(types.contains(&LinkType::Wikilink)); - assert!(types.contains(&LinkType::Embed)); - assert!(types.contains(&LinkType::MarkdownLink)); - } + let types: Vec<_> = links.iter().map(|l| l.link_type).collect(); + assert!(types.contains(&LinkType::Wikilink)); + assert!(types.contains(&LinkType::Embed)); + assert!(types.contains(&LinkType::MarkdownLink)); + } - #[test] - fn test_line_numbers() { - let content = "Line 1\n[[link on line 2]]\nLine 3"; - let links = extract_links(test_media_id(), content); + #[test] + fn test_line_numbers() { + let content = "Line 1\n[[link on line 2]]\nLine 3"; + let links = extract_links(test_media_id(), content); - assert_eq!(links.len(), 1); - assert_eq!(links[0].line_number, Some(2)); - } + assert_eq!(links.len(), 1); + assert_eq!(links[0].line_number, Some(2)); + } - #[test] - fn test_resolve_candidates() { - let source_path = std::path::Path::new("/notes/projects/readme.md"); - let root_dirs = vec![std::path::PathBuf::from("/notes")]; + #[test] + fn test_resolve_candidates() { + let source_path = std::path::Path::new("/notes/projects/readme.md"); + let root_dirs = vec![std::path::PathBuf::from("/notes")]; - let candidates = resolve_link_candidates("My Note", source_path, &root_dirs); + let candidates = + resolve_link_candidates("My Note", source_path, &root_dirs); - // Should include relative path and .md variations - assert!(!candidates.is_empty()); - assert!( - candidates - .iter() - .any(|p| p.to_string_lossy().contains("My Note.md")) - ); - } + // Should include relative path and .md variations + assert!(!candidates.is_empty()); + assert!( + candidates + .iter() + .any(|p| p.to_string_lossy().contains("My Note.md")) + ); + } - #[test] - fn test_extract_aliases() { - let content = r#"--- + #[test] + fn test_extract_aliases() { + let content = r#"--- title: My Note aliases: - Alternative Name @@ -429,48 +471,48 @@ aliases: # Content here "#; - let aliases = extract_aliases(content); - assert_eq!(aliases, vec!["Alternative Name", "Another Alias"]); - } + let aliases = extract_aliases(content); + assert_eq!(aliases, vec!["Alternative Name", "Another Alias"]); + } - #[test] - fn test_extract_single_alias() { - let content = r#"--- + #[test] + fn test_extract_single_alias() { + let content = r#"--- title: My Note aliases: Single Alias --- # Content "#; - let aliases = extract_aliases(content); - assert_eq!(aliases, vec!["Single Alias"]); - } + let aliases = extract_aliases(content); + assert_eq!(aliases, vec!["Single Alias"]); + } - #[test] - fn test_wikilink_not_matching_embed() { - let content = "A wikilink [[note]] and an embed ![[image.png]]"; - let links = extract_links(test_media_id(), content); + #[test] + fn test_wikilink_not_matching_embed() { + let content = "A wikilink [[note]] and an embed ![[image.png]]"; + let links = extract_links(test_media_id(), content); - assert_eq!(links.len(), 2); - let wikilinks: Vec<_> = links - .iter() - .filter(|l| l.link_type == LinkType::Wikilink) - .collect(); - let embeds: Vec<_> = links - .iter() - .filter(|l| l.link_type == LinkType::Embed) - .collect(); + assert_eq!(links.len(), 2); + let wikilinks: Vec<_> = links + .iter() + .filter(|l| l.link_type == LinkType::Wikilink) + .collect(); + let embeds: Vec<_> = links + .iter() + .filter(|l| l.link_type == LinkType::Embed) + .collect(); - assert_eq!(wikilinks.len(), 1); - assert_eq!(embeds.len(), 1); - assert_eq!(wikilinks[0].target_path, "note"); - assert_eq!(embeds[0].target_path, "image.png"); - } + assert_eq!(wikilinks.len(), 1); + assert_eq!(embeds.len(), 1); + assert_eq!(wikilinks[0].target_path, "note"); + assert_eq!(embeds[0].target_path, "image.png"); + } - #[test] - fn test_exclude_markdown_images() { - // Test that markdown images ![alt](image.png) are NOT extracted as links - let content = r#" + #[test] + fn test_exclude_markdown_images() { + // Test that markdown images ![alt](image.png) are NOT extracted as links + let content = r#" # My Note Here's a regular link: [documentation](docs/guide.md) @@ -484,70 +526,71 @@ Multiple images: Mixed: [link](file.md) then ![image](pic.png) then [another](other.md) "#; - let links = extract_links(test_media_id(), content); + let links = extract_links(test_media_id(), content); - // Should only extract the 4 markdown links, not the 4 images - assert_eq!( - links.len(), - 4, - "Should extract 4 links, not images. Got: {:#?}", - links - ); + // Should only extract the 4 markdown links, not the 4 images + assert_eq!( + links.len(), + 4, + "Should extract 4 links, not images. Got: {:#?}", + links + ); - // Verify all extracted items are MarkdownLink type (not images) - for link in &links { - assert_eq!( - link.link_type, - LinkType::MarkdownLink, - "Link '{}' should be MarkdownLink type", - link.target_path - ); - } - - // Verify correct targets were extracted (links, not images) - let targets: Vec<&str> = links.iter().map(|l| l.target_path.as_str()).collect(); - assert!( - targets.contains(&"docs/guide.md"), - "Should contain docs/guide.md" - ); - assert!( - targets.contains(&"config.toml"), - "Should contain config.toml" - ); - assert!(targets.contains(&"file.md"), "Should contain file.md"); - assert!(targets.contains(&"other.md"), "Should contain other.md"); - - // Verify images were NOT extracted - assert!( - !targets.contains(&"images/screenshot.png"), - "Should NOT contain screenshot.png (it's an image)" - ); - assert!( - !targets.contains(&"logo.png"), - "Should NOT contain logo.png (it's an image)" - ); - assert!( - !targets.contains(&"banner.jpg"), - "Should NOT contain banner.jpg (it's an image)" - ); - assert!( - !targets.contains(&"pic.png"), - "Should NOT contain pic.png (it's an image)" - ); + // Verify all extracted items are MarkdownLink type (not images) + for link in &links { + assert_eq!( + link.link_type, + LinkType::MarkdownLink, + "Link '{}' should be MarkdownLink type", + link.target_path + ); } - #[test] - fn test_edge_case_image_at_line_start() { - // Test edge case: image at the very start of a line - let content = "![Image at start](start.png)\n[Link](file.md)"; - let links = extract_links(test_media_id(), content); + // Verify correct targets were extracted (links, not images) + let targets: Vec<&str> = + links.iter().map(|l| l.target_path.as_str()).collect(); + assert!( + targets.contains(&"docs/guide.md"), + "Should contain docs/guide.md" + ); + assert!( + targets.contains(&"config.toml"), + "Should contain config.toml" + ); + assert!(targets.contains(&"file.md"), "Should contain file.md"); + assert!(targets.contains(&"other.md"), "Should contain other.md"); - assert_eq!( - links.len(), - 1, - "Should only extract the link, not the image" - ); - assert_eq!(links[0].target_path, "file.md"); - assert_eq!(links[0].link_type, LinkType::MarkdownLink); - } + // Verify images were NOT extracted + assert!( + !targets.contains(&"images/screenshot.png"), + "Should NOT contain screenshot.png (it's an image)" + ); + assert!( + !targets.contains(&"logo.png"), + "Should NOT contain logo.png (it's an image)" + ); + assert!( + !targets.contains(&"banner.jpg"), + "Should NOT contain banner.jpg (it's an image)" + ); + assert!( + !targets.contains(&"pic.png"), + "Should NOT contain pic.png (it's an image)" + ); + } + + #[test] + fn test_edge_case_image_at_line_start() { + // Test edge case: image at the very start of a line + let content = "![Image at start](start.png)\n[Link](file.md)"; + let links = extract_links(test_media_id(), content); + + assert_eq!( + links.len(), + 1, + "Should only extract the link, not the image" + ); + assert_eq!(links[0].target_path, "file.md"); + assert_eq!(links[0].link_type, LinkType::MarkdownLink); + } } diff --git a/crates/pinakes-core/src/managed_storage.rs b/crates/pinakes-core/src/managed_storage.rs index 210c5e0..4075ac0 100644 --- a/crates/pinakes-core/src/managed_storage.rs +++ b/crates/pinakes-core/src/managed_storage.rs @@ -7,390 +7,407 @@ use std::path::{Path, PathBuf}; -use tokio::fs; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::{ + fs, + io::{AsyncRead, AsyncReadExt, AsyncWriteExt, BufReader}, +}; use tracing::{debug, info, warn}; -use crate::error::{PinakesError, Result}; -use crate::model::ContentHash; +use crate::{ + error::{PinakesError, Result}, + model::ContentHash, +}; /// Content-addressable storage service for managed files. #[derive(Debug, Clone)] pub struct ManagedStorageService { - root_dir: PathBuf, - max_upload_size: u64, - verify_on_read: bool, + root_dir: PathBuf, + max_upload_size: u64, + verify_on_read: bool, } impl ManagedStorageService { - /// Create a new managed storage service. - pub fn new(root_dir: PathBuf, max_upload_size: u64, verify_on_read: bool) -> Self { - Self { - root_dir, - max_upload_size, - verify_on_read, - } + /// Create a new managed storage service. + pub fn new( + root_dir: PathBuf, + max_upload_size: u64, + verify_on_read: bool, + ) -> Self { + Self { + root_dir, + max_upload_size, + verify_on_read, } + } - /// Initialize the storage directory structure. - pub async fn init(&self) -> Result<()> { - fs::create_dir_all(&self.root_dir).await?; - info!(path = %self.root_dir.display(), "initialized managed storage"); - Ok(()) + /// Initialize the storage directory structure. + pub async fn init(&self) -> Result<()> { + fs::create_dir_all(&self.root_dir).await?; + info!(path = %self.root_dir.display(), "initialized managed storage"); + Ok(()) + } + + /// Get the storage path for a content hash. + /// + /// Layout: `///` + pub fn path(&self, hash: &ContentHash) -> PathBuf { + let h = &hash.0; + if h.len() >= 4 { + self.root_dir.join(&h[0..2]).join(&h[2..4]).join(h) + } else { + // Fallback for short hashes (shouldn't happen with BLAKE3) + self.root_dir.join(h) } + } - /// Get the storage path for a content hash. - /// - /// Layout: `///` - pub fn path(&self, hash: &ContentHash) -> PathBuf { - let h = &hash.0; - if h.len() >= 4 { - self.root_dir.join(&h[0..2]).join(&h[2..4]).join(h) - } else { - // Fallback for short hashes (shouldn't happen with BLAKE3) - self.root_dir.join(h) - } - } + /// Check if a blob exists in storage. + pub async fn exists(&self, hash: &ContentHash) -> bool { + self.path(hash).exists() + } - /// Check if a blob exists in storage. - pub async fn exists(&self, hash: &ContentHash) -> bool { - self.path(hash).exists() - } + /// Store a file from an async reader, computing the hash as we go. + /// + /// Returns the content hash and file size. + /// If the file already exists with the same hash, returns early + /// (deduplication). + pub async fn store_stream( + &self, + mut reader: R, + ) -> Result<(ContentHash, u64)> { + // First, stream to a temp file while computing the hash + let temp_dir = self.root_dir.join("temp"); + fs::create_dir_all(&temp_dir).await?; - /// Store a file from an async reader, computing the hash as we go. - /// - /// Returns the content hash and file size. - /// If the file already exists with the same hash, returns early (deduplication). - pub async fn store_stream( - &self, - mut reader: R, - ) -> Result<(ContentHash, u64)> { - // First, stream to a temp file while computing the hash - let temp_dir = self.root_dir.join("temp"); - fs::create_dir_all(&temp_dir).await?; + let temp_id = uuid::Uuid::now_v7(); + let temp_path = temp_dir.join(temp_id.to_string()); - let temp_id = uuid::Uuid::now_v7(); - let temp_path = temp_dir.join(temp_id.to_string()); + let mut hasher = blake3::Hasher::new(); + let mut temp_file = fs::File::create(&temp_path).await?; + let mut total_size = 0u64; - let mut hasher = blake3::Hasher::new(); - let mut temp_file = fs::File::create(&temp_path).await?; - let mut total_size = 0u64; + let mut buf = vec![0u8; 64 * 1024]; // 64KB buffer + loop { + let n = reader.read(&mut buf).await?; + if n == 0 { + break; + } - let mut buf = vec![0u8; 64 * 1024]; // 64KB buffer - loop { - let n = reader.read(&mut buf).await?; - if n == 0 { - break; - } - - total_size += n as u64; - if total_size > self.max_upload_size { - // Clean up temp file - drop(temp_file); - let _ = fs::remove_file(&temp_path).await; - return Err(PinakesError::UploadTooLarge(total_size)); - } - - hasher.update(&buf[..n]); - temp_file.write_all(&buf[..n]).await?; - } - - temp_file.flush().await?; - temp_file.sync_all().await?; + total_size += n as u64; + if total_size > self.max_upload_size { + // Clean up temp file drop(temp_file); + let _ = fs::remove_file(&temp_path).await; + return Err(PinakesError::UploadTooLarge(total_size)); + } - let hash = ContentHash::new(hasher.finalize().to_hex().to_string()); - let final_path = self.path(&hash); - - // Check if file already exists (deduplication) - if final_path.exists() { - // Verify size matches - let existing_meta = fs::metadata(&final_path).await?; - if existing_meta.len() == total_size { - debug!(hash = %hash, "blob already exists, deduplicating"); - let _ = fs::remove_file(&temp_path).await; - return Ok((hash, total_size)); - } else { - warn!( - hash = %hash, - expected = total_size, - actual = existing_meta.len(), - "size mismatch for existing blob, replacing" - ); - } - } - - // Move temp file to final location - if let Some(parent) = final_path.parent() { - fs::create_dir_all(parent).await?; - } - fs::rename(&temp_path, &final_path).await?; - - info!(hash = %hash, size = total_size, "stored new blob"); - Ok((hash, total_size)) + hasher.update(&buf[..n]); + temp_file.write_all(&buf[..n]).await?; } - /// Store a file from a path. - pub async fn store_file(&self, path: &Path) -> Result<(ContentHash, u64)> { - let file = fs::File::open(path).await?; - let reader = BufReader::new(file); - self.store_stream(reader).await + temp_file.flush().await?; + temp_file.sync_all().await?; + drop(temp_file); + + let hash = ContentHash::new(hasher.finalize().to_hex().to_string()); + let final_path = self.path(&hash); + + // Check if file already exists (deduplication) + if final_path.exists() { + // Verify size matches + let existing_meta = fs::metadata(&final_path).await?; + if existing_meta.len() == total_size { + debug!(hash = %hash, "blob already exists, deduplicating"); + let _ = fs::remove_file(&temp_path).await; + return Ok((hash, total_size)); + } else { + warn!( + hash = %hash, + expected = total_size, + actual = existing_meta.len(), + "size mismatch for existing blob, replacing" + ); + } } - /// Store bytes directly. - pub async fn store_bytes(&self, data: &[u8]) -> Result<(ContentHash, u64)> { - use std::io::Cursor; - let cursor = Cursor::new(data); - self.store_stream(cursor).await + // Move temp file to final location + if let Some(parent) = final_path.parent() { + fs::create_dir_all(parent).await?; + } + fs::rename(&temp_path, &final_path).await?; + + info!(hash = %hash, size = total_size, "stored new blob"); + Ok((hash, total_size)) + } + + /// Store a file from a path. + pub async fn store_file(&self, path: &Path) -> Result<(ContentHash, u64)> { + let file = fs::File::open(path).await?; + let reader = BufReader::new(file); + self.store_stream(reader).await + } + + /// Store bytes directly. + pub async fn store_bytes(&self, data: &[u8]) -> Result<(ContentHash, u64)> { + use std::io::Cursor; + let cursor = Cursor::new(data); + self.store_stream(cursor).await + } + + /// Open a blob for reading. + pub async fn open(&self, hash: &ContentHash) -> Result { + let path = self.path(hash); + if !path.exists() { + return Err(PinakesError::BlobNotFound(hash.0.clone())); } - /// Open a blob for reading. - pub async fn open(&self, hash: &ContentHash) -> Result { - let path = self.path(hash); - if !path.exists() { - return Err(PinakesError::BlobNotFound(hash.0.clone())); - } - - if self.verify_on_read { - self.verify(hash).await?; - } - - fs::File::open(&path).await.map_err(|e| PinakesError::Io(e)) + if self.verify_on_read { + self.verify(hash).await?; } - /// Read a blob entirely into memory. - pub async fn read(&self, hash: &ContentHash) -> Result> { - let path = self.path(hash); - if !path.exists() { - return Err(PinakesError::BlobNotFound(hash.0.clone())); - } + fs::File::open(&path).await.map_err(|e| PinakesError::Io(e)) + } - let data = fs::read(&path).await?; - - if self.verify_on_read { - let computed = blake3::hash(&data); - if computed.to_hex().to_string() != hash.0 { - return Err(PinakesError::StorageIntegrity(format!( - "hash mismatch for blob {}", - hash - ))); - } - } - - Ok(data) + /// Read a blob entirely into memory. + pub async fn read(&self, hash: &ContentHash) -> Result> { + let path = self.path(hash); + if !path.exists() { + return Err(PinakesError::BlobNotFound(hash.0.clone())); } - /// Verify the integrity of a stored blob. - pub async fn verify(&self, hash: &ContentHash) -> Result { - let path = self.path(hash); - if !path.exists() { - return Ok(false); - } + let data = fs::read(&path).await?; - let file = fs::File::open(&path).await?; - let mut reader = BufReader::new(file); - let mut hasher = blake3::Hasher::new(); - let mut buf = vec![0u8; 64 * 1024]; - - loop { - let n = reader.read(&mut buf).await?; - if n == 0 { - break; - } - hasher.update(&buf[..n]); - } - - let computed = hasher.finalize().to_hex().to_string(); - if computed != hash.0 { - warn!( - expected = %hash, - computed = %computed, - "blob integrity check failed" - ); - return Err(PinakesError::StorageIntegrity(format!( - "hash mismatch: expected {}, computed {}", - hash, computed - ))); - } - - debug!(hash = %hash, "blob integrity verified"); - Ok(true) + if self.verify_on_read { + let computed = blake3::hash(&data); + if computed.to_hex().to_string() != hash.0 { + return Err(PinakesError::StorageIntegrity(format!( + "hash mismatch for blob {}", + hash + ))); + } } - /// Delete a blob from storage. - pub async fn delete(&self, hash: &ContentHash) -> Result<()> { - let path = self.path(hash); - if path.exists() { - fs::remove_file(&path).await?; - info!(hash = %hash, "deleted blob"); + Ok(data) + } - // Try to remove empty parent directories - if let Some(parent) = path.parent() { - let _ = fs::remove_dir(parent).await; - if let Some(grandparent) = parent.parent() { - let _ = fs::remove_dir(grandparent).await; + /// Verify the integrity of a stored blob. + pub async fn verify(&self, hash: &ContentHash) -> Result { + let path = self.path(hash); + if !path.exists() { + return Ok(false); + } + + let file = fs::File::open(&path).await?; + let mut reader = BufReader::new(file); + let mut hasher = blake3::Hasher::new(); + let mut buf = vec![0u8; 64 * 1024]; + + loop { + let n = reader.read(&mut buf).await?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + + let computed = hasher.finalize().to_hex().to_string(); + if computed != hash.0 { + warn!( + expected = %hash, + computed = %computed, + "blob integrity check failed" + ); + return Err(PinakesError::StorageIntegrity(format!( + "hash mismatch: expected {}, computed {}", + hash, computed + ))); + } + + debug!(hash = %hash, "blob integrity verified"); + Ok(true) + } + + /// Delete a blob from storage. + pub async fn delete(&self, hash: &ContentHash) -> Result<()> { + let path = self.path(hash); + if path.exists() { + fs::remove_file(&path).await?; + info!(hash = %hash, "deleted blob"); + + // Try to remove empty parent directories + if let Some(parent) = path.parent() { + let _ = fs::remove_dir(parent).await; + if let Some(grandparent) = parent.parent() { + let _ = fs::remove_dir(grandparent).await; + } + } + } + Ok(()) + } + + /// Get the size of a stored blob. + pub async fn size(&self, hash: &ContentHash) -> Result { + let path = self.path(hash); + if !path.exists() { + return Err(PinakesError::BlobNotFound(hash.0.clone())); + } + let meta = fs::metadata(&path).await?; + Ok(meta.len()) + } + + /// List all blob hashes in storage. + pub async fn list_all(&self) -> Result> { + let mut hashes = Vec::new(); + + let mut entries = fs::read_dir(&self.root_dir).await?; + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.is_dir() && path.file_name().map(|n| n.len()) == Some(2) { + let mut sub_entries = fs::read_dir(&path).await?; + while let Some(sub_entry) = sub_entries.next_entry().await? { + let sub_path = sub_entry.path(); + if sub_path.is_dir() + && sub_path.file_name().map(|n| n.len()) == Some(2) + { + let mut file_entries = fs::read_dir(&sub_path).await?; + while let Some(file_entry) = file_entries.next_entry().await? { + let file_path = file_entry.path(); + if file_path.is_file() { + if let Some(name) = file_path.file_name() { + hashes + .push(ContentHash::new(name.to_string_lossy().to_string())); } + } } + } } - Ok(()) + } } - /// Get the size of a stored blob. - pub async fn size(&self, hash: &ContentHash) -> Result { - let path = self.path(hash); - if !path.exists() { - return Err(PinakesError::BlobNotFound(hash.0.clone())); - } - let meta = fs::metadata(&path).await?; - Ok(meta.len()) + Ok(hashes) + } + + /// Calculate total storage used by all blobs. + pub async fn total_size(&self) -> Result { + let hashes = self.list_all().await?; + let mut total = 0u64; + for hash in hashes { + if let Ok(size) = self.size(&hash).await { + total += size; + } + } + Ok(total) + } + + /// Clean up any orphaned temp files. + pub async fn cleanup_temp(&self) -> Result { + let temp_dir = self.root_dir.join("temp"); + if !temp_dir.exists() { + return Ok(0); } - /// List all blob hashes in storage. - pub async fn list_all(&self) -> Result> { - let mut hashes = Vec::new(); - - let mut entries = fs::read_dir(&self.root_dir).await?; - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - if path.is_dir() && path.file_name().map(|n| n.len()) == Some(2) { - let mut sub_entries = fs::read_dir(&path).await?; - while let Some(sub_entry) = sub_entries.next_entry().await? { - let sub_path = sub_entry.path(); - if sub_path.is_dir() && sub_path.file_name().map(|n| n.len()) == Some(2) { - let mut file_entries = fs::read_dir(&sub_path).await?; - while let Some(file_entry) = file_entries.next_entry().await? { - let file_path = file_entry.path(); - if file_path.is_file() { - if let Some(name) = file_path.file_name() { - hashes - .push(ContentHash::new(name.to_string_lossy().to_string())); - } - } - } - } - } + let mut count = 0u64; + let mut entries = fs::read_dir(&temp_dir).await?; + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.is_file() { + // Check if temp file is old (> 1 hour) + if let Ok(meta) = fs::metadata(&path).await { + if let Ok(modified) = meta.modified() { + let age = std::time::SystemTime::now() + .duration_since(modified) + .unwrap_or_default(); + if age.as_secs() > 3600 { + let _ = fs::remove_file(&path).await; + count += 1; } + } } - - Ok(hashes) + } } - /// Calculate total storage used by all blobs. - pub async fn total_size(&self) -> Result { - let hashes = self.list_all().await?; - let mut total = 0u64; - for hash in hashes { - if let Ok(size) = self.size(&hash).await { - total += size; - } - } - Ok(total) - } - - /// Clean up any orphaned temp files. - pub async fn cleanup_temp(&self) -> Result { - let temp_dir = self.root_dir.join("temp"); - if !temp_dir.exists() { - return Ok(0); - } - - let mut count = 0u64; - let mut entries = fs::read_dir(&temp_dir).await?; - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - if path.is_file() { - // Check if temp file is old (> 1 hour) - if let Ok(meta) = fs::metadata(&path).await { - if let Ok(modified) = meta.modified() { - let age = std::time::SystemTime::now() - .duration_since(modified) - .unwrap_or_default(); - if age.as_secs() > 3600 { - let _ = fs::remove_file(&path).await; - count += 1; - } - } - } - } - } - - if count > 0 { - info!(count, "cleaned up orphaned temp files"); - } - Ok(count) + if count > 0 { + info!(count, "cleaned up orphaned temp files"); } + Ok(count) + } } #[cfg(test)] mod tests { - use super::*; - use tempfile::tempdir; + use tempfile::tempdir; - #[tokio::test] - async fn test_store_and_retrieve() { - let dir = tempdir().unwrap(); - let service = ManagedStorageService::new(dir.path().to_path_buf(), 1024 * 1024, false); - service.init().await.unwrap(); + use super::*; - let data = b"hello, world!"; - let (hash, size) = service.store_bytes(data).await.unwrap(); + #[tokio::test] + async fn test_store_and_retrieve() { + let dir = tempdir().unwrap(); + let service = + ManagedStorageService::new(dir.path().to_path_buf(), 1024 * 1024, false); + service.init().await.unwrap(); - assert_eq!(size, data.len() as u64); - assert!(service.exists(&hash).await); + let data = b"hello, world!"; + let (hash, size) = service.store_bytes(data).await.unwrap(); - let retrieved = service.read(&hash).await.unwrap(); - assert_eq!(retrieved, data); - } + assert_eq!(size, data.len() as u64); + assert!(service.exists(&hash).await); - #[tokio::test] - async fn test_deduplication() { - let dir = tempdir().unwrap(); - let service = ManagedStorageService::new(dir.path().to_path_buf(), 1024 * 1024, false); - service.init().await.unwrap(); + let retrieved = service.read(&hash).await.unwrap(); + assert_eq!(retrieved, data); + } - let data = b"duplicate content"; - let (hash1, _) = service.store_bytes(data).await.unwrap(); - let (hash2, _) = service.store_bytes(data).await.unwrap(); + #[tokio::test] + async fn test_deduplication() { + let dir = tempdir().unwrap(); + let service = + ManagedStorageService::new(dir.path().to_path_buf(), 1024 * 1024, false); + service.init().await.unwrap(); - assert_eq!(hash1.0, hash2.0); - assert_eq!(service.list_all().await.unwrap().len(), 1); - } + let data = b"duplicate content"; + let (hash1, _) = service.store_bytes(data).await.unwrap(); + let (hash2, _) = service.store_bytes(data).await.unwrap(); - #[tokio::test] - async fn test_verify_integrity() { - let dir = tempdir().unwrap(); - let service = ManagedStorageService::new(dir.path().to_path_buf(), 1024 * 1024, true); - service.init().await.unwrap(); + assert_eq!(hash1.0, hash2.0); + assert_eq!(service.list_all().await.unwrap().len(), 1); + } - let data = b"verify me"; - let (hash, _) = service.store_bytes(data).await.unwrap(); + #[tokio::test] + async fn test_verify_integrity() { + let dir = tempdir().unwrap(); + let service = + ManagedStorageService::new(dir.path().to_path_buf(), 1024 * 1024, true); + service.init().await.unwrap(); - assert!(service.verify(&hash).await.unwrap()); - } + let data = b"verify me"; + let (hash, _) = service.store_bytes(data).await.unwrap(); - #[tokio::test] - async fn test_upload_too_large() { - let dir = tempdir().unwrap(); - let service = ManagedStorageService::new(dir.path().to_path_buf(), 100, false); - service.init().await.unwrap(); + assert!(service.verify(&hash).await.unwrap()); + } - let data = vec![0u8; 200]; - let result = service.store_bytes(&data).await; + #[tokio::test] + async fn test_upload_too_large() { + let dir = tempdir().unwrap(); + let service = + ManagedStorageService::new(dir.path().to_path_buf(), 100, false); + service.init().await.unwrap(); - assert!(matches!(result, Err(PinakesError::UploadTooLarge(_)))); - } + let data = vec![0u8; 200]; + let result = service.store_bytes(&data).await; - #[tokio::test] - async fn test_delete() { - let dir = tempdir().unwrap(); - let service = ManagedStorageService::new(dir.path().to_path_buf(), 1024 * 1024, false); - service.init().await.unwrap(); + assert!(matches!(result, Err(PinakesError::UploadTooLarge(_)))); + } - let data = b"delete me"; - let (hash, _) = service.store_bytes(data).await.unwrap(); - assert!(service.exists(&hash).await); + #[tokio::test] + async fn test_delete() { + let dir = tempdir().unwrap(); + let service = + ManagedStorageService::new(dir.path().to_path_buf(), 1024 * 1024, false); + service.init().await.unwrap(); - service.delete(&hash).await.unwrap(); - assert!(!service.exists(&hash).await); - } + let data = b"delete me"; + let (hash, _) = service.store_bytes(data).await.unwrap(); + assert!(service.exists(&hash).await); + + service.delete(&hash).await.unwrap(); + assert!(!service.exists(&hash).await); + } } diff --git a/crates/pinakes-core/src/media_type/builtin.rs b/crates/pinakes-core/src/media_type/builtin.rs index 92068ff..67f1d68 100644 --- a/crates/pinakes-core/src/media_type/builtin.rs +++ b/crates/pinakes-core/src/media_type/builtin.rs @@ -5,246 +5,250 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum BuiltinMediaType { - // Audio - Mp3, - Flac, - Ogg, - Wav, - Aac, - Opus, + // Audio + Mp3, + Flac, + Ogg, + Wav, + Aac, + Opus, - // Video - Mp4, - Mkv, - Avi, - Webm, + // Video + Mp4, + Mkv, + Avi, + Webm, - // Documents - Pdf, - Epub, - Djvu, + // Documents + Pdf, + Epub, + Djvu, - // Text - Markdown, - PlainText, + // Text + Markdown, + PlainText, - // Images - Jpeg, - Png, - Gif, - Webp, - Svg, - Avif, - Tiff, - Bmp, + // Images + Jpeg, + Png, + Gif, + Webp, + Svg, + Avif, + Tiff, + Bmp, - // RAW Images - Cr2, - Nef, - Arw, - Dng, - Orf, - Rw2, + // RAW Images + Cr2, + Nef, + Arw, + Dng, + Orf, + Rw2, - // HEIC/HEIF - Heic, + // HEIC/HEIF + Heic, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum MediaCategory { - Audio, - Video, - Document, - Text, - Image, + Audio, + Video, + Document, + Text, + Image, } impl BuiltinMediaType { - /// Get the unique ID for this media type - pub fn id(&self) -> String { - format!("{:?}", self).to_lowercase() - } + /// Get the unique ID for this media type + pub fn id(&self) -> String { + format!("{:?}", self).to_lowercase() + } - /// Get the display name for this media type - pub fn name(&self) -> String { - match self { - Self::Mp3 => "MP3 Audio".to_string(), - Self::Flac => "FLAC Audio".to_string(), - Self::Ogg => "OGG Audio".to_string(), - Self::Wav => "WAV Audio".to_string(), - Self::Aac => "AAC Audio".to_string(), - Self::Opus => "Opus Audio".to_string(), - Self::Mp4 => "MP4 Video".to_string(), - Self::Mkv => "MKV Video".to_string(), - Self::Avi => "AVI Video".to_string(), - Self::Webm => "WebM Video".to_string(), - Self::Pdf => "PDF Document".to_string(), - Self::Epub => "EPUB eBook".to_string(), - Self::Djvu => "DjVu Document".to_string(), - Self::Markdown => "Markdown".to_string(), - Self::PlainText => "Plain Text".to_string(), - Self::Jpeg => "JPEG Image".to_string(), - Self::Png => "PNG Image".to_string(), - Self::Gif => "GIF Image".to_string(), - Self::Webp => "WebP Image".to_string(), - Self::Svg => "SVG Image".to_string(), - Self::Avif => "AVIF Image".to_string(), - Self::Tiff => "TIFF Image".to_string(), - Self::Bmp => "BMP Image".to_string(), - Self::Cr2 => "Canon RAW (CR2)".to_string(), - Self::Nef => "Nikon RAW (NEF)".to_string(), - Self::Arw => "Sony RAW (ARW)".to_string(), - Self::Dng => "Adobe DNG RAW".to_string(), - Self::Orf => "Olympus RAW (ORF)".to_string(), - Self::Rw2 => "Panasonic RAW (RW2)".to_string(), - Self::Heic => "HEIC Image".to_string(), - } + /// Get the display name for this media type + pub fn name(&self) -> String { + match self { + Self::Mp3 => "MP3 Audio".to_string(), + Self::Flac => "FLAC Audio".to_string(), + Self::Ogg => "OGG Audio".to_string(), + Self::Wav => "WAV Audio".to_string(), + Self::Aac => "AAC Audio".to_string(), + Self::Opus => "Opus Audio".to_string(), + Self::Mp4 => "MP4 Video".to_string(), + Self::Mkv => "MKV Video".to_string(), + Self::Avi => "AVI Video".to_string(), + Self::Webm => "WebM Video".to_string(), + Self::Pdf => "PDF Document".to_string(), + Self::Epub => "EPUB eBook".to_string(), + Self::Djvu => "DjVu Document".to_string(), + Self::Markdown => "Markdown".to_string(), + Self::PlainText => "Plain Text".to_string(), + Self::Jpeg => "JPEG Image".to_string(), + Self::Png => "PNG Image".to_string(), + Self::Gif => "GIF Image".to_string(), + Self::Webp => "WebP Image".to_string(), + Self::Svg => "SVG Image".to_string(), + Self::Avif => "AVIF Image".to_string(), + Self::Tiff => "TIFF Image".to_string(), + Self::Bmp => "BMP Image".to_string(), + Self::Cr2 => "Canon RAW (CR2)".to_string(), + Self::Nef => "Nikon RAW (NEF)".to_string(), + Self::Arw => "Sony RAW (ARW)".to_string(), + Self::Dng => "Adobe DNG RAW".to_string(), + Self::Orf => "Olympus RAW (ORF)".to_string(), + Self::Rw2 => "Panasonic RAW (RW2)".to_string(), + Self::Heic => "HEIC Image".to_string(), } + } - pub fn from_extension(ext: &str) -> Option { - match ext.to_ascii_lowercase().as_str() { - "mp3" => Some(Self::Mp3), - "flac" => Some(Self::Flac), - "ogg" | "oga" => Some(Self::Ogg), - "wav" => Some(Self::Wav), - "aac" | "m4a" => Some(Self::Aac), - "opus" => Some(Self::Opus), - "mp4" | "m4v" => Some(Self::Mp4), - "mkv" => Some(Self::Mkv), - "avi" => Some(Self::Avi), - "webm" => Some(Self::Webm), - "pdf" => Some(Self::Pdf), - "epub" => Some(Self::Epub), - "djvu" => Some(Self::Djvu), - "md" | "markdown" => Some(Self::Markdown), - "txt" | "text" => Some(Self::PlainText), - "jpg" | "jpeg" => Some(Self::Jpeg), - "png" => Some(Self::Png), - "gif" => Some(Self::Gif), - "webp" => Some(Self::Webp), - "svg" => Some(Self::Svg), - "avif" => Some(Self::Avif), - "tiff" | "tif" => Some(Self::Tiff), - "bmp" => Some(Self::Bmp), - "cr2" => Some(Self::Cr2), - "nef" => Some(Self::Nef), - "arw" => Some(Self::Arw), - "dng" => Some(Self::Dng), - "orf" => Some(Self::Orf), - "rw2" => Some(Self::Rw2), - "heic" | "heif" => Some(Self::Heic), - _ => None, - } + pub fn from_extension(ext: &str) -> Option { + match ext.to_ascii_lowercase().as_str() { + "mp3" => Some(Self::Mp3), + "flac" => Some(Self::Flac), + "ogg" | "oga" => Some(Self::Ogg), + "wav" => Some(Self::Wav), + "aac" | "m4a" => Some(Self::Aac), + "opus" => Some(Self::Opus), + "mp4" | "m4v" => Some(Self::Mp4), + "mkv" => Some(Self::Mkv), + "avi" => Some(Self::Avi), + "webm" => Some(Self::Webm), + "pdf" => Some(Self::Pdf), + "epub" => Some(Self::Epub), + "djvu" => Some(Self::Djvu), + "md" | "markdown" => Some(Self::Markdown), + "txt" | "text" => Some(Self::PlainText), + "jpg" | "jpeg" => Some(Self::Jpeg), + "png" => Some(Self::Png), + "gif" => Some(Self::Gif), + "webp" => Some(Self::Webp), + "svg" => Some(Self::Svg), + "avif" => Some(Self::Avif), + "tiff" | "tif" => Some(Self::Tiff), + "bmp" => Some(Self::Bmp), + "cr2" => Some(Self::Cr2), + "nef" => Some(Self::Nef), + "arw" => Some(Self::Arw), + "dng" => Some(Self::Dng), + "orf" => Some(Self::Orf), + "rw2" => Some(Self::Rw2), + "heic" | "heif" => Some(Self::Heic), + _ => None, } + } - pub fn from_path(path: &Path) -> Option { - path.extension() - .and_then(|e| e.to_str()) - .and_then(Self::from_extension) - } + pub fn from_path(path: &Path) -> Option { + path + .extension() + .and_then(|e| e.to_str()) + .and_then(Self::from_extension) + } - pub fn mime_type(&self) -> &'static str { - match self { - Self::Mp3 => "audio/mpeg", - Self::Flac => "audio/flac", - Self::Ogg => "audio/ogg", - Self::Wav => "audio/wav", - Self::Aac => "audio/aac", - Self::Opus => "audio/opus", - Self::Mp4 => "video/mp4", - Self::Mkv => "video/x-matroska", - Self::Avi => "video/x-msvideo", - Self::Webm => "video/webm", - Self::Pdf => "application/pdf", - Self::Epub => "application/epub+zip", - Self::Djvu => "image/vnd.djvu", - Self::Markdown => "text/markdown", - Self::PlainText => "text/plain", - Self::Jpeg => "image/jpeg", - Self::Png => "image/png", - Self::Gif => "image/gif", - Self::Webp => "image/webp", - Self::Svg => "image/svg+xml", - Self::Avif => "image/avif", - Self::Tiff => "image/tiff", - Self::Bmp => "image/bmp", - Self::Cr2 => "image/x-canon-cr2", - Self::Nef => "image/x-nikon-nef", - Self::Arw => "image/x-sony-arw", - Self::Dng => "image/x-adobe-dng", - Self::Orf => "image/x-olympus-orf", - Self::Rw2 => "image/x-panasonic-rw2", - Self::Heic => "image/heic", - } + pub fn mime_type(&self) -> &'static str { + match self { + Self::Mp3 => "audio/mpeg", + Self::Flac => "audio/flac", + Self::Ogg => "audio/ogg", + Self::Wav => "audio/wav", + Self::Aac => "audio/aac", + Self::Opus => "audio/opus", + Self::Mp4 => "video/mp4", + Self::Mkv => "video/x-matroska", + Self::Avi => "video/x-msvideo", + Self::Webm => "video/webm", + Self::Pdf => "application/pdf", + Self::Epub => "application/epub+zip", + Self::Djvu => "image/vnd.djvu", + Self::Markdown => "text/markdown", + Self::PlainText => "text/plain", + Self::Jpeg => "image/jpeg", + Self::Png => "image/png", + Self::Gif => "image/gif", + Self::Webp => "image/webp", + Self::Svg => "image/svg+xml", + Self::Avif => "image/avif", + Self::Tiff => "image/tiff", + Self::Bmp => "image/bmp", + Self::Cr2 => "image/x-canon-cr2", + Self::Nef => "image/x-nikon-nef", + Self::Arw => "image/x-sony-arw", + Self::Dng => "image/x-adobe-dng", + Self::Orf => "image/x-olympus-orf", + Self::Rw2 => "image/x-panasonic-rw2", + Self::Heic => "image/heic", } + } - pub fn category(&self) -> MediaCategory { - match self { - Self::Mp3 | Self::Flac | Self::Ogg | Self::Wav | Self::Aac | Self::Opus => { - MediaCategory::Audio - } - Self::Mp4 | Self::Mkv | Self::Avi | Self::Webm => MediaCategory::Video, - Self::Pdf | Self::Epub | Self::Djvu => MediaCategory::Document, - Self::Markdown | Self::PlainText => MediaCategory::Text, - Self::Jpeg - | Self::Png - | Self::Gif - | Self::Webp - | Self::Svg - | Self::Avif - | Self::Tiff - | Self::Bmp - | Self::Cr2 - | Self::Nef - | Self::Arw - | Self::Dng - | Self::Orf - | Self::Rw2 - | Self::Heic => MediaCategory::Image, - } + pub fn category(&self) -> MediaCategory { + match self { + Self::Mp3 + | Self::Flac + | Self::Ogg + | Self::Wav + | Self::Aac + | Self::Opus => MediaCategory::Audio, + Self::Mp4 | Self::Mkv | Self::Avi | Self::Webm => MediaCategory::Video, + Self::Pdf | Self::Epub | Self::Djvu => MediaCategory::Document, + Self::Markdown | Self::PlainText => MediaCategory::Text, + Self::Jpeg + | Self::Png + | Self::Gif + | Self::Webp + | Self::Svg + | Self::Avif + | Self::Tiff + | Self::Bmp + | Self::Cr2 + | Self::Nef + | Self::Arw + | Self::Dng + | Self::Orf + | Self::Rw2 + | Self::Heic => MediaCategory::Image, } + } - pub fn extensions(&self) -> &'static [&'static str] { - match self { - Self::Mp3 => &["mp3"], - Self::Flac => &["flac"], - Self::Ogg => &["ogg", "oga"], - Self::Wav => &["wav"], - Self::Aac => &["aac", "m4a"], - Self::Opus => &["opus"], - Self::Mp4 => &["mp4", "m4v"], - Self::Mkv => &["mkv"], - Self::Avi => &["avi"], - Self::Webm => &["webm"], - Self::Pdf => &["pdf"], - Self::Epub => &["epub"], - Self::Djvu => &["djvu"], - Self::Markdown => &["md", "markdown"], - Self::PlainText => &["txt", "text"], - Self::Jpeg => &["jpg", "jpeg"], - Self::Png => &["png"], - Self::Gif => &["gif"], - Self::Webp => &["webp"], - Self::Svg => &["svg"], - Self::Avif => &["avif"], - Self::Tiff => &["tiff", "tif"], - Self::Bmp => &["bmp"], - Self::Cr2 => &["cr2"], - Self::Nef => &["nef"], - Self::Arw => &["arw"], - Self::Dng => &["dng"], - Self::Orf => &["orf"], - Self::Rw2 => &["rw2"], - Self::Heic => &["heic", "heif"], - } + pub fn extensions(&self) -> &'static [&'static str] { + match self { + Self::Mp3 => &["mp3"], + Self::Flac => &["flac"], + Self::Ogg => &["ogg", "oga"], + Self::Wav => &["wav"], + Self::Aac => &["aac", "m4a"], + Self::Opus => &["opus"], + Self::Mp4 => &["mp4", "m4v"], + Self::Mkv => &["mkv"], + Self::Avi => &["avi"], + Self::Webm => &["webm"], + Self::Pdf => &["pdf"], + Self::Epub => &["epub"], + Self::Djvu => &["djvu"], + Self::Markdown => &["md", "markdown"], + Self::PlainText => &["txt", "text"], + Self::Jpeg => &["jpg", "jpeg"], + Self::Png => &["png"], + Self::Gif => &["gif"], + Self::Webp => &["webp"], + Self::Svg => &["svg"], + Self::Avif => &["avif"], + Self::Tiff => &["tiff", "tif"], + Self::Bmp => &["bmp"], + Self::Cr2 => &["cr2"], + Self::Nef => &["nef"], + Self::Arw => &["arw"], + Self::Dng => &["dng"], + Self::Orf => &["orf"], + Self::Rw2 => &["rw2"], + Self::Heic => &["heic", "heif"], } + } - /// Returns true if this is a RAW image format. - pub fn is_raw(&self) -> bool { - matches!( - self, - Self::Cr2 | Self::Nef | Self::Arw | Self::Dng | Self::Orf | Self::Rw2 - ) - } + /// Returns true if this is a RAW image format. + pub fn is_raw(&self) -> bool { + matches!( + self, + Self::Cr2 | Self::Nef | Self::Arw | Self::Dng | Self::Orf | Self::Rw2 + ) + } } diff --git a/crates/pinakes-core/src/media_type/mod.rs b/crates/pinakes-core/src/media_type/mod.rs index 678f8f9..7c5ed9f 100644 --- a/crates/pinakes-core/src/media_type/mod.rs +++ b/crates/pinakes-core/src/media_type/mod.rs @@ -3,9 +3,10 @@ //! This module provides an extensible media type system that supports both //! built-in media types and plugin-registered custom types. -use serde::{Deserialize, Serialize}; use std::path::Path; +use serde::{Deserialize, Serialize}; + pub mod builtin; pub mod registry; @@ -16,217 +17,248 @@ pub use registry::{MediaTypeDescriptor, MediaTypeRegistry}; #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(untagged)] pub enum MediaType { - /// Built-in media type (backward compatible) - Builtin(BuiltinMediaType), + /// Built-in media type (backward compatible) + Builtin(BuiltinMediaType), - /// Custom media type from a plugin - Custom(String), + /// Custom media type from a plugin + Custom(String), } impl MediaType { - /// Create a new custom media type - pub fn custom(id: impl Into) -> Self { - Self::Custom(id.into()) + /// Create a new custom media type + pub fn custom(id: impl Into) -> Self { + Self::Custom(id.into()) + } + + /// Get the type ID as a string + pub fn id(&self) -> String { + match self { + Self::Builtin(b) => b.id(), + Self::Custom(id) => id.clone(), } + } - /// Get the type ID as a string - pub fn id(&self) -> String { - match self { - Self::Builtin(b) => b.id(), - Self::Custom(id) => id.clone(), - } + /// Get the display name for this media type + /// For custom types without a registry, returns the ID as the name + pub fn name(&self) -> String { + match self { + Self::Builtin(b) => b.name(), + Self::Custom(id) => id.clone(), } + } - /// Get the display name for this media type - /// For custom types without a registry, returns the ID as the name - pub fn name(&self) -> String { - match self { - Self::Builtin(b) => b.name(), - Self::Custom(id) => id.clone(), - } - } - - /// Get the display name for this media type with registry support - pub fn name_with_registry(&self, registry: &MediaTypeRegistry) -> String { - match self { - Self::Builtin(b) => b.name(), - Self::Custom(id) => registry - .get(id) - .map(|d| d.name.clone()) - .unwrap_or_else(|| id.clone()), - } - } - - /// Get the category for this media type - /// For custom types without a registry, returns MediaCategory::Document as default - pub fn category(&self) -> MediaCategory { - match self { - Self::Builtin(b) => b.category(), - Self::Custom(_) => MediaCategory::Document, - } - } - - /// Get the category for this media type with registry support - pub fn category_with_registry(&self, registry: &MediaTypeRegistry) -> MediaCategory { - match self { - Self::Builtin(b) => b.category(), - Self::Custom(id) => registry - .get(id) - .and_then(|d| d.category) - .unwrap_or(MediaCategory::Document), - } - } - - /// Get the MIME type - /// For custom types without a registry, returns "application/octet-stream" - pub fn mime_type(&self) -> String { - match self { - Self::Builtin(b) => b.mime_type().to_string(), - Self::Custom(_) => "application/octet-stream".to_string(), - } - } - - /// Get the MIME type with registry support - pub fn mime_type_with_registry(&self, registry: &MediaTypeRegistry) -> String { - match self { - Self::Builtin(b) => b.mime_type().to_string(), - Self::Custom(id) => registry - .get(id) - .and_then(|d| d.mime_types.first().cloned()) - .unwrap_or_else(|| "application/octet-stream".to_string()), - } - } - - /// Get file extensions - /// For custom types without a registry, returns an empty vec - pub fn extensions(&self) -> Vec { - match self { - Self::Builtin(b) => b.extensions().iter().map(|s| s.to_string()).collect(), - Self::Custom(_) => vec![], - } - } - - /// Get file extensions with registry support - pub fn extensions_with_registry(&self, registry: &MediaTypeRegistry) -> Vec { - match self { - Self::Builtin(b) => b.extensions().iter().map(|s| s.to_string()).collect(), - Self::Custom(id) => registry - .get(id) - .map(|d| d.extensions.clone()) - .unwrap_or_default(), - } - } - - /// Check if this is a RAW image format - pub fn is_raw(&self) -> bool { - match self { - Self::Builtin(b) => b.is_raw(), - Self::Custom(_) => false, - } - } - - /// Resolve a media type from file extension (built-in types only) - /// Use from_extension_with_registry for custom types - pub fn from_extension(ext: &str) -> Option { - BuiltinMediaType::from_extension(ext).map(Self::Builtin) - } - - /// Resolve a media type from file extension with registry (includes custom types) - pub fn from_extension_with_registry(ext: &str, registry: &MediaTypeRegistry) -> Option { - // Try built-in types first - if let Some(builtin) = BuiltinMediaType::from_extension(ext) { - return Some(Self::Builtin(builtin)); - } - - // Try registered custom types + /// Get the display name for this media type with registry support + pub fn name_with_registry(&self, registry: &MediaTypeRegistry) -> String { + match self { + Self::Builtin(b) => b.name(), + Self::Custom(id) => { registry - .get_by_extension(ext) - .map(|desc| Self::Custom(desc.id.clone())) + .get(id) + .map(|d| d.name.clone()) + .unwrap_or_else(|| id.clone()) + }, + } + } + + /// Get the category for this media type + /// For custom types without a registry, returns MediaCategory::Document as + /// default + pub fn category(&self) -> MediaCategory { + match self { + Self::Builtin(b) => b.category(), + Self::Custom(_) => MediaCategory::Document, + } + } + + /// Get the category for this media type with registry support + pub fn category_with_registry( + &self, + registry: &MediaTypeRegistry, + ) -> MediaCategory { + match self { + Self::Builtin(b) => b.category(), + Self::Custom(id) => { + registry + .get(id) + .and_then(|d| d.category) + .unwrap_or(MediaCategory::Document) + }, + } + } + + /// Get the MIME type + /// For custom types without a registry, returns "application/octet-stream" + pub fn mime_type(&self) -> String { + match self { + Self::Builtin(b) => b.mime_type().to_string(), + Self::Custom(_) => "application/octet-stream".to_string(), + } + } + + /// Get the MIME type with registry support + pub fn mime_type_with_registry( + &self, + registry: &MediaTypeRegistry, + ) -> String { + match self { + Self::Builtin(b) => b.mime_type().to_string(), + Self::Custom(id) => { + registry + .get(id) + .and_then(|d| d.mime_types.first().cloned()) + .unwrap_or_else(|| "application/octet-stream".to_string()) + }, + } + } + + /// Get file extensions + /// For custom types without a registry, returns an empty vec + pub fn extensions(&self) -> Vec { + match self { + Self::Builtin(b) => { + b.extensions().iter().map(|s| s.to_string()).collect() + }, + Self::Custom(_) => vec![], + } + } + + /// Get file extensions with registry support + pub fn extensions_with_registry( + &self, + registry: &MediaTypeRegistry, + ) -> Vec { + match self { + Self::Builtin(b) => { + b.extensions().iter().map(|s| s.to_string()).collect() + }, + Self::Custom(id) => { + registry + .get(id) + .map(|d| d.extensions.clone()) + .unwrap_or_default() + }, + } + } + + /// Check if this is a RAW image format + pub fn is_raw(&self) -> bool { + match self { + Self::Builtin(b) => b.is_raw(), + Self::Custom(_) => false, + } + } + + /// Resolve a media type from file extension (built-in types only) + /// Use from_extension_with_registry for custom types + pub fn from_extension(ext: &str) -> Option { + BuiltinMediaType::from_extension(ext).map(Self::Builtin) + } + + /// Resolve a media type from file extension with registry (includes custom + /// types) + pub fn from_extension_with_registry( + ext: &str, + registry: &MediaTypeRegistry, + ) -> Option { + // Try built-in types first + if let Some(builtin) = BuiltinMediaType::from_extension(ext) { + return Some(Self::Builtin(builtin)); } - /// Resolve a media type from file path (built-in types only) - /// Use from_path_with_registry for custom types - pub fn from_path(path: &Path) -> Option { - path.extension() - .and_then(|e| e.to_str()) - .and_then(Self::from_extension) - } + // Try registered custom types + registry + .get_by_extension(ext) + .map(|desc| Self::Custom(desc.id.clone())) + } - /// Resolve a media type from file path with registry (includes custom types) - pub fn from_path_with_registry(path: &Path, registry: &MediaTypeRegistry) -> Option { - path.extension() - .and_then(|e| e.to_str()) - .and_then(|ext| Self::from_extension_with_registry(ext, registry)) - } + /// Resolve a media type from file path (built-in types only) + /// Use from_path_with_registry for custom types + pub fn from_path(path: &Path) -> Option { + path + .extension() + .and_then(|e| e.to_str()) + .and_then(Self::from_extension) + } + + /// Resolve a media type from file path with registry (includes custom types) + pub fn from_path_with_registry( + path: &Path, + registry: &MediaTypeRegistry, + ) -> Option { + path + .extension() + .and_then(|e| e.to_str()) + .and_then(|ext| Self::from_extension_with_registry(ext, registry)) + } } // Implement From for easier conversion impl From for MediaType { - fn from(builtin: BuiltinMediaType) -> Self { - Self::Builtin(builtin) - } + fn from(builtin: BuiltinMediaType) -> Self { + Self::Builtin(builtin) + } } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn test_builtin_media_type() { - let mt = MediaType::Builtin(BuiltinMediaType::Mp3); + #[test] + fn test_builtin_media_type() { + let mt = MediaType::Builtin(BuiltinMediaType::Mp3); - assert_eq!(mt.id(), "mp3"); - assert_eq!(mt.mime_type(), "audio/mpeg"); - assert_eq!(mt.category(), MediaCategory::Audio); - } + assert_eq!(mt.id(), "mp3"); + assert_eq!(mt.mime_type(), "audio/mpeg"); + assert_eq!(mt.category(), MediaCategory::Audio); + } - #[test] - fn test_custom_media_type() { - let mut registry = MediaTypeRegistry::new(); + #[test] + fn test_custom_media_type() { + let mut registry = MediaTypeRegistry::new(); - let descriptor = MediaTypeDescriptor { - id: "heif".to_string(), - name: "HEIF Image".to_string(), - category: Some(MediaCategory::Image), - extensions: vec!["heif".to_string()], - mime_types: vec!["image/heif".to_string()], - plugin_id: Some("heif-plugin".to_string()), - }; + let descriptor = MediaTypeDescriptor { + id: "heif".to_string(), + name: "HEIF Image".to_string(), + category: Some(MediaCategory::Image), + extensions: vec!["heif".to_string()], + mime_types: vec!["image/heif".to_string()], + plugin_id: Some("heif-plugin".to_string()), + }; - registry.register(descriptor).unwrap(); + registry.register(descriptor).unwrap(); - let mt = MediaType::custom("heif"); - assert_eq!(mt.id(), "heif"); - assert_eq!(mt.mime_type_with_registry(®istry), "image/heif"); - assert_eq!(mt.category_with_registry(®istry), MediaCategory::Image); - } + let mt = MediaType::custom("heif"); + assert_eq!(mt.id(), "heif"); + assert_eq!(mt.mime_type_with_registry(®istry), "image/heif"); + assert_eq!(mt.category_with_registry(®istry), MediaCategory::Image); + } - #[test] - fn test_from_extension_builtin() { - let registry = MediaTypeRegistry::new(); - let mt = MediaType::from_extension_with_registry("mp3", ®istry); + #[test] + fn test_from_extension_builtin() { + let registry = MediaTypeRegistry::new(); + let mt = MediaType::from_extension_with_registry("mp3", ®istry); - assert!(mt.is_some()); - assert_eq!(mt.unwrap(), MediaType::Builtin(BuiltinMediaType::Mp3)); - } + assert!(mt.is_some()); + assert_eq!(mt.unwrap(), MediaType::Builtin(BuiltinMediaType::Mp3)); + } - #[test] - fn test_from_extension_custom() { - let mut registry = MediaTypeRegistry::new(); + #[test] + fn test_from_extension_custom() { + let mut registry = MediaTypeRegistry::new(); - let descriptor = MediaTypeDescriptor { - id: "customformat".to_string(), - name: "Custom Format".to_string(), - category: Some(MediaCategory::Image), - extensions: vec!["xyz".to_string()], - mime_types: vec!["application/x-custom".to_string()], - plugin_id: Some("custom-plugin".to_string()), - }; + let descriptor = MediaTypeDescriptor { + id: "customformat".to_string(), + name: "Custom Format".to_string(), + category: Some(MediaCategory::Image), + extensions: vec!["xyz".to_string()], + mime_types: vec!["application/x-custom".to_string()], + plugin_id: Some("custom-plugin".to_string()), + }; - registry.register(descriptor).unwrap(); + registry.register(descriptor).unwrap(); - let mt = MediaType::from_extension_with_registry("xyz", ®istry); - assert!(mt.is_some()); - assert_eq!(mt.unwrap(), MediaType::custom("customformat")); - } + let mt = MediaType::from_extension_with_registry("xyz", ®istry); + assert!(mt.is_some()); + assert_eq!(mt.unwrap(), MediaType::custom("customformat")); + } } diff --git a/crates/pinakes-core/src/media_type/registry.rs b/crates/pinakes-core/src/media_type/registry.rs index 232acf4..a759f7d 100644 --- a/crates/pinakes-core/src/media_type/registry.rs +++ b/crates/pinakes-core/src/media_type/registry.rs @@ -1,285 +1,290 @@ //! Media type registry for managing both built-in and custom media types +use std::collections::HashMap; + use anyhow::{Result, anyhow}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; use super::MediaCategory; /// Descriptor for a media type (built-in or custom) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MediaTypeDescriptor { - /// Unique identifier - pub id: String, + /// Unique identifier + pub id: String, - /// Display name - pub name: String, + /// Display name + pub name: String, - /// Category - pub category: Option, + /// Category + pub category: Option, - /// File extensions - pub extensions: Vec, + /// File extensions + pub extensions: Vec, - /// MIME types - pub mime_types: Vec, + /// MIME types + pub mime_types: Vec, - /// Plugin that registered this type (None for built-in types) - pub plugin_id: Option, + /// Plugin that registered this type (None for built-in types) + pub plugin_id: Option, } /// Registry for media types #[derive(Debug, Clone)] pub struct MediaTypeRegistry { - /// Map of media type ID to descriptor - types: HashMap, + /// Map of media type ID to descriptor + types: HashMap, - /// Map of extension to media type ID - extension_map: HashMap, + /// Map of extension to media type ID + extension_map: HashMap, } impl MediaTypeRegistry { - /// Create a new empty registry - pub fn new() -> Self { - Self { - types: HashMap::new(), - extension_map: HashMap::new(), - } + /// Create a new empty registry + pub fn new() -> Self { + Self { + types: HashMap::new(), + extension_map: HashMap::new(), + } + } + + /// Register a new media type + pub fn register(&mut self, descriptor: MediaTypeDescriptor) -> Result<()> { + // Check if ID is already registered + if self.types.contains_key(&descriptor.id) { + return Err(anyhow!("Media type already registered: {}", descriptor.id)); } - /// Register a new media type - pub fn register(&mut self, descriptor: MediaTypeDescriptor) -> Result<()> { - // Check if ID is already registered - if self.types.contains_key(&descriptor.id) { - return Err(anyhow!("Media type already registered: {}", descriptor.id)); - } - - // Register extensions - for ext in &descriptor.extensions { - let ext_lower = ext.to_lowercase(); - if self.extension_map.contains_key(&ext_lower) { - // Extension already registered - this is OK, we'll use the first one - // In a more sophisticated system, we might track multiple types per extension - continue; - } - self.extension_map.insert(ext_lower, descriptor.id.clone()); - } - - // Register the type - self.types.insert(descriptor.id.clone(), descriptor); - - Ok(()) + // Register extensions + for ext in &descriptor.extensions { + let ext_lower = ext.to_lowercase(); + if self.extension_map.contains_key(&ext_lower) { + // Extension already registered - this is OK, we'll use the first one + // In a more sophisticated system, we might track multiple types per + // extension + continue; + } + self.extension_map.insert(ext_lower, descriptor.id.clone()); } - /// Unregister a media type - pub fn unregister(&mut self, id: &str) -> Result<()> { - let descriptor = self - .types - .remove(id) - .ok_or_else(|| anyhow!("Media type not found: {}", id))?; + // Register the type + self.types.insert(descriptor.id.clone(), descriptor); - // Remove extensions - for ext in &descriptor.extensions { - let ext_lower = ext.to_lowercase(); - if self.extension_map.get(&ext_lower) == Some(&descriptor.id) { - self.extension_map.remove(&ext_lower); - } - } + Ok(()) + } - Ok(()) + /// Unregister a media type + pub fn unregister(&mut self, id: &str) -> Result<()> { + let descriptor = self + .types + .remove(id) + .ok_or_else(|| anyhow!("Media type not found: {}", id))?; + + // Remove extensions + for ext in &descriptor.extensions { + let ext_lower = ext.to_lowercase(); + if self.extension_map.get(&ext_lower) == Some(&descriptor.id) { + self.extension_map.remove(&ext_lower); + } } - /// Get a media type descriptor by ID - pub fn get(&self, id: &str) -> Option<&MediaTypeDescriptor> { - self.types.get(id) + Ok(()) + } + + /// Get a media type descriptor by ID + pub fn get(&self, id: &str) -> Option<&MediaTypeDescriptor> { + self.types.get(id) + } + + /// Get a media type by file extension + pub fn get_by_extension(&self, ext: &str) -> Option<&MediaTypeDescriptor> { + let ext_lower = ext.to_lowercase(); + self + .extension_map + .get(&ext_lower) + .and_then(|id| self.types.get(id)) + } + + /// List all registered media types + pub fn list_all(&self) -> Vec<&MediaTypeDescriptor> { + self.types.values().collect() + } + + /// List media types from a specific plugin + pub fn list_by_plugin(&self, plugin_id: &str) -> Vec<&MediaTypeDescriptor> { + self + .types + .values() + .filter(|d| d.plugin_id.as_deref() == Some(plugin_id)) + .collect() + } + + /// List built-in media types (plugin_id is None) + pub fn list_builtin(&self) -> Vec<&MediaTypeDescriptor> { + self + .types + .values() + .filter(|d| d.plugin_id.is_none()) + .collect() + } + + /// Get count of registered types + pub fn count(&self) -> usize { + self.types.len() + } + + /// Check if a media type is registered + pub fn contains(&self, id: &str) -> bool { + self.types.contains_key(id) + } + + /// Unregister all types from a specific plugin + pub fn unregister_plugin(&mut self, plugin_id: &str) -> Result { + let type_ids: Vec = self + .types + .values() + .filter(|d| d.plugin_id.as_deref() == Some(plugin_id)) + .map(|d| d.id.clone()) + .collect(); + + let count = type_ids.len(); + + for id in type_ids { + self.unregister(&id)?; } - /// Get a media type by file extension - pub fn get_by_extension(&self, ext: &str) -> Option<&MediaTypeDescriptor> { - let ext_lower = ext.to_lowercase(); - self.extension_map - .get(&ext_lower) - .and_then(|id| self.types.get(id)) - } - - /// List all registered media types - pub fn list_all(&self) -> Vec<&MediaTypeDescriptor> { - self.types.values().collect() - } - - /// List media types from a specific plugin - pub fn list_by_plugin(&self, plugin_id: &str) -> Vec<&MediaTypeDescriptor> { - self.types - .values() - .filter(|d| d.plugin_id.as_deref() == Some(plugin_id)) - .collect() - } - - /// List built-in media types (plugin_id is None) - pub fn list_builtin(&self) -> Vec<&MediaTypeDescriptor> { - self.types - .values() - .filter(|d| d.plugin_id.is_none()) - .collect() - } - - /// Get count of registered types - pub fn count(&self) -> usize { - self.types.len() - } - - /// Check if a media type is registered - pub fn contains(&self, id: &str) -> bool { - self.types.contains_key(id) - } - - /// Unregister all types from a specific plugin - pub fn unregister_plugin(&mut self, plugin_id: &str) -> Result { - let type_ids: Vec = self - .types - .values() - .filter(|d| d.plugin_id.as_deref() == Some(plugin_id)) - .map(|d| d.id.clone()) - .collect(); - - let count = type_ids.len(); - - for id in type_ids { - self.unregister(&id)?; - } - - Ok(count) - } + Ok(count) + } } impl Default for MediaTypeRegistry { - fn default() -> Self { - Self::new() - } + fn default() -> Self { + Self::new() + } } #[cfg(test)] mod tests { - use super::*; + use super::*; - fn create_test_descriptor(id: &str, ext: &str) -> MediaTypeDescriptor { - MediaTypeDescriptor { - id: id.to_string(), - name: format!("{} Type", id), - category: Some(MediaCategory::Document), - extensions: vec![ext.to_string()], - mime_types: vec![format!("application/{}", id)], - plugin_id: Some("test-plugin".to_string()), - } + fn create_test_descriptor(id: &str, ext: &str) -> MediaTypeDescriptor { + MediaTypeDescriptor { + id: id.to_string(), + name: format!("{} Type", id), + category: Some(MediaCategory::Document), + extensions: vec![ext.to_string()], + mime_types: vec![format!("application/{}", id)], + plugin_id: Some("test-plugin".to_string()), + } + } + + #[test] + fn test_register_and_get() { + let mut registry = MediaTypeRegistry::new(); + let descriptor = create_test_descriptor("test", "tst"); + + registry.register(descriptor.clone()).unwrap(); + + let retrieved = registry.get("test").unwrap(); + assert_eq!(retrieved.id, "test"); + assert_eq!(retrieved.name, "test Type"); + } + + #[test] + fn test_register_duplicate() { + let mut registry = MediaTypeRegistry::new(); + let descriptor = create_test_descriptor("test", "tst"); + + registry.register(descriptor.clone()).unwrap(); + let result = registry.register(descriptor); + + assert!(result.is_err()); + } + + #[test] + fn test_get_by_extension() { + let mut registry = MediaTypeRegistry::new(); + let descriptor = create_test_descriptor("test", "tst"); + + registry.register(descriptor).unwrap(); + + let retrieved = registry.get_by_extension("tst").unwrap(); + assert_eq!(retrieved.id, "test"); + + // Test case insensitivity + let retrieved = registry.get_by_extension("TST").unwrap(); + assert_eq!(retrieved.id, "test"); + } + + #[test] + fn test_unregister() { + let mut registry = MediaTypeRegistry::new(); + let descriptor = create_test_descriptor("test", "tst"); + + registry.register(descriptor).unwrap(); + assert!(registry.contains("test")); + + registry.unregister("test").unwrap(); + assert!(!registry.contains("test")); + + // Extension should also be removed + assert!(registry.get_by_extension("tst").is_none()); + } + + #[test] + fn test_list_by_plugin() { + let mut registry = MediaTypeRegistry::new(); + + let desc1 = MediaTypeDescriptor { + id: "type1".to_string(), + name: "Type 1".to_string(), + category: Some(MediaCategory::Document), + extensions: vec!["t1".to_string()], + mime_types: vec!["application/type1".to_string()], + plugin_id: Some("plugin1".to_string()), + }; + + let desc2 = MediaTypeDescriptor { + id: "type2".to_string(), + name: "Type 2".to_string(), + category: Some(MediaCategory::Document), + extensions: vec!["t2".to_string()], + mime_types: vec!["application/type2".to_string()], + plugin_id: Some("plugin2".to_string()), + }; + + registry.register(desc1).unwrap(); + registry.register(desc2).unwrap(); + + let plugin1_types = registry.list_by_plugin("plugin1"); + assert_eq!(plugin1_types.len(), 1); + assert_eq!(plugin1_types[0].id, "type1"); + + let plugin2_types = registry.list_by_plugin("plugin2"); + assert_eq!(plugin2_types.len(), 1); + assert_eq!(plugin2_types[0].id, "type2"); + } + + #[test] + fn test_unregister_plugin() { + let mut registry = MediaTypeRegistry::new(); + + for i in 1..=3 { + let desc = MediaTypeDescriptor { + id: format!("type{}", i), + name: format!("Type {}", i), + category: Some(MediaCategory::Document), + extensions: vec![format!("t{}", i)], + mime_types: vec![format!("application/type{}", i)], + plugin_id: Some("test-plugin".to_string()), + }; + registry.register(desc).unwrap(); } - #[test] - fn test_register_and_get() { - let mut registry = MediaTypeRegistry::new(); - let descriptor = create_test_descriptor("test", "tst"); + assert_eq!(registry.count(), 3); - registry.register(descriptor.clone()).unwrap(); - - let retrieved = registry.get("test").unwrap(); - assert_eq!(retrieved.id, "test"); - assert_eq!(retrieved.name, "test Type"); - } - - #[test] - fn test_register_duplicate() { - let mut registry = MediaTypeRegistry::new(); - let descriptor = create_test_descriptor("test", "tst"); - - registry.register(descriptor.clone()).unwrap(); - let result = registry.register(descriptor); - - assert!(result.is_err()); - } - - #[test] - fn test_get_by_extension() { - let mut registry = MediaTypeRegistry::new(); - let descriptor = create_test_descriptor("test", "tst"); - - registry.register(descriptor).unwrap(); - - let retrieved = registry.get_by_extension("tst").unwrap(); - assert_eq!(retrieved.id, "test"); - - // Test case insensitivity - let retrieved = registry.get_by_extension("TST").unwrap(); - assert_eq!(retrieved.id, "test"); - } - - #[test] - fn test_unregister() { - let mut registry = MediaTypeRegistry::new(); - let descriptor = create_test_descriptor("test", "tst"); - - registry.register(descriptor).unwrap(); - assert!(registry.contains("test")); - - registry.unregister("test").unwrap(); - assert!(!registry.contains("test")); - - // Extension should also be removed - assert!(registry.get_by_extension("tst").is_none()); - } - - #[test] - fn test_list_by_plugin() { - let mut registry = MediaTypeRegistry::new(); - - let desc1 = MediaTypeDescriptor { - id: "type1".to_string(), - name: "Type 1".to_string(), - category: Some(MediaCategory::Document), - extensions: vec!["t1".to_string()], - mime_types: vec!["application/type1".to_string()], - plugin_id: Some("plugin1".to_string()), - }; - - let desc2 = MediaTypeDescriptor { - id: "type2".to_string(), - name: "Type 2".to_string(), - category: Some(MediaCategory::Document), - extensions: vec!["t2".to_string()], - mime_types: vec!["application/type2".to_string()], - plugin_id: Some("plugin2".to_string()), - }; - - registry.register(desc1).unwrap(); - registry.register(desc2).unwrap(); - - let plugin1_types = registry.list_by_plugin("plugin1"); - assert_eq!(plugin1_types.len(), 1); - assert_eq!(plugin1_types[0].id, "type1"); - - let plugin2_types = registry.list_by_plugin("plugin2"); - assert_eq!(plugin2_types.len(), 1); - assert_eq!(plugin2_types[0].id, "type2"); - } - - #[test] - fn test_unregister_plugin() { - let mut registry = MediaTypeRegistry::new(); - - for i in 1..=3 { - let desc = MediaTypeDescriptor { - id: format!("type{}", i), - name: format!("Type {}", i), - category: Some(MediaCategory::Document), - extensions: vec![format!("t{}", i)], - mime_types: vec![format!("application/type{}", i)], - plugin_id: Some("test-plugin".to_string()), - }; - registry.register(desc).unwrap(); - } - - assert_eq!(registry.count(), 3); - - let removed = registry.unregister_plugin("test-plugin").unwrap(); - assert_eq!(removed, 3); - assert_eq!(registry.count(), 0); - } + let removed = registry.unregister_plugin("test-plugin").unwrap(); + assert_eq!(removed, 3); + assert_eq!(registry.count(), 0); + } } diff --git a/crates/pinakes-core/src/metadata/audio.rs b/crates/pinakes-core/src/metadata/audio.rs index dd169f2..960544d 100644 --- a/crates/pinakes-core/src/metadata/audio.rs +++ b/crates/pinakes-core/src/metadata/audio.rs @@ -1,81 +1,91 @@ use std::path::Path; -use lofty::file::{AudioFile, TaggedFileExt}; -use lofty::tag::Accessor; - -use crate::error::{PinakesError, Result}; -use crate::media_type::{BuiltinMediaType, MediaType}; +use lofty::{ + file::{AudioFile, TaggedFileExt}, + tag::Accessor, +}; use super::{ExtractedMetadata, MetadataExtractor}; +use crate::{ + error::{PinakesError, Result}, + media_type::{BuiltinMediaType, MediaType}, +}; pub struct AudioExtractor; impl MetadataExtractor for AudioExtractor { - fn extract(&self, path: &Path) -> Result { - let tagged_file = lofty::read_from_path(path) - .map_err(|e| PinakesError::MetadataExtraction(format!("audio metadata: {e}")))?; + fn extract(&self, path: &Path) -> Result { + let tagged_file = lofty::read_from_path(path).map_err(|e| { + PinakesError::MetadataExtraction(format!("audio metadata: {e}")) + })?; - let mut meta = ExtractedMetadata::default(); + let mut meta = ExtractedMetadata::default(); - if let Some(tag) = tagged_file - .primary_tag() - .or_else(|| tagged_file.first_tag()) - { - meta.title = tag.title().map(|s| s.to_string()); - meta.artist = tag.artist().map(|s| s.to_string()); - meta.album = tag.album().map(|s| s.to_string()); - meta.genre = tag.genre().map(|s| s.to_string()); - meta.year = tag.date().map(|ts| ts.year as i32); - } - - if let Some(tag) = tagged_file - .primary_tag() - .or_else(|| tagged_file.first_tag()) - { - if let Some(track) = tag.track() { - meta.extra - .insert("track_number".to_string(), track.to_string()); - } - if let Some(disc) = tag.disk() { - meta.extra - .insert("disc_number".to_string(), disc.to_string()); - } - if let Some(comment) = tag.comment() { - meta.extra - .insert("comment".to_string(), comment.to_string()); - } - } - - let properties = tagged_file.properties(); - let duration = properties.duration(); - if !duration.is_zero() { - meta.duration_secs = Some(duration.as_secs_f64()); - } - - if let Some(bitrate) = properties.audio_bitrate() { - meta.extra - .insert("bitrate".to_string(), format!("{bitrate} kbps")); - } - if let Some(sample_rate) = properties.sample_rate() { - meta.extra - .insert("sample_rate".to_string(), format!("{sample_rate} Hz")); - } - if let Some(channels) = properties.channels() { - meta.extra - .insert("channels".to_string(), channels.to_string()); - } - - Ok(meta) + if let Some(tag) = tagged_file + .primary_tag() + .or_else(|| tagged_file.first_tag()) + { + meta.title = tag.title().map(|s| s.to_string()); + meta.artist = tag.artist().map(|s| s.to_string()); + meta.album = tag.album().map(|s| s.to_string()); + meta.genre = tag.genre().map(|s| s.to_string()); + meta.year = tag.date().map(|ts| ts.year as i32); } - fn supported_types(&self) -> Vec { - vec![ - MediaType::Builtin(BuiltinMediaType::Mp3), - MediaType::Builtin(BuiltinMediaType::Flac), - MediaType::Builtin(BuiltinMediaType::Ogg), - MediaType::Builtin(BuiltinMediaType::Wav), - MediaType::Builtin(BuiltinMediaType::Aac), - MediaType::Builtin(BuiltinMediaType::Opus), - ] + if let Some(tag) = tagged_file + .primary_tag() + .or_else(|| tagged_file.first_tag()) + { + if let Some(track) = tag.track() { + meta + .extra + .insert("track_number".to_string(), track.to_string()); + } + if let Some(disc) = tag.disk() { + meta + .extra + .insert("disc_number".to_string(), disc.to_string()); + } + if let Some(comment) = tag.comment() { + meta + .extra + .insert("comment".to_string(), comment.to_string()); + } } + + let properties = tagged_file.properties(); + let duration = properties.duration(); + if !duration.is_zero() { + meta.duration_secs = Some(duration.as_secs_f64()); + } + + if let Some(bitrate) = properties.audio_bitrate() { + meta + .extra + .insert("bitrate".to_string(), format!("{bitrate} kbps")); + } + if let Some(sample_rate) = properties.sample_rate() { + meta + .extra + .insert("sample_rate".to_string(), format!("{sample_rate} Hz")); + } + if let Some(channels) = properties.channels() { + meta + .extra + .insert("channels".to_string(), channels.to_string()); + } + + Ok(meta) + } + + fn supported_types(&self) -> Vec { + vec![ + MediaType::Builtin(BuiltinMediaType::Mp3), + MediaType::Builtin(BuiltinMediaType::Flac), + MediaType::Builtin(BuiltinMediaType::Ogg), + MediaType::Builtin(BuiltinMediaType::Wav), + MediaType::Builtin(BuiltinMediaType::Aac), + MediaType::Builtin(BuiltinMediaType::Opus), + ] + } } diff --git a/crates/pinakes-core/src/metadata/document.rs b/crates/pinakes-core/src/metadata/document.rs index 9ec5b80..b548177 100644 --- a/crates/pinakes-core/src/metadata/document.rs +++ b/crates/pinakes-core/src/metadata/document.rs @@ -1,358 +1,367 @@ use std::path::Path; -use crate::error::{PinakesError, Result}; -use crate::media_type::{BuiltinMediaType, MediaType}; - use super::{ExtractedMetadata, MetadataExtractor}; +use crate::{ + error::{PinakesError, Result}, + media_type::{BuiltinMediaType, MediaType}, +}; pub struct DocumentExtractor; impl MetadataExtractor for DocumentExtractor { - fn extract(&self, path: &Path) -> Result { - match MediaType::from_path(path) { - Some(MediaType::Builtin(BuiltinMediaType::Pdf)) => extract_pdf(path), - Some(MediaType::Builtin(BuiltinMediaType::Epub)) => extract_epub(path), - Some(MediaType::Builtin(BuiltinMediaType::Djvu)) => extract_djvu(path), - _ => Ok(ExtractedMetadata::default()), - } + fn extract(&self, path: &Path) -> Result { + match MediaType::from_path(path) { + Some(MediaType::Builtin(BuiltinMediaType::Pdf)) => extract_pdf(path), + Some(MediaType::Builtin(BuiltinMediaType::Epub)) => extract_epub(path), + Some(MediaType::Builtin(BuiltinMediaType::Djvu)) => extract_djvu(path), + _ => Ok(ExtractedMetadata::default()), } + } - fn supported_types(&self) -> Vec { - vec![ - MediaType::Builtin(BuiltinMediaType::Pdf), - MediaType::Builtin(BuiltinMediaType::Epub), - MediaType::Builtin(BuiltinMediaType::Djvu), - ] - } + fn supported_types(&self) -> Vec { + vec![ + MediaType::Builtin(BuiltinMediaType::Pdf), + MediaType::Builtin(BuiltinMediaType::Epub), + MediaType::Builtin(BuiltinMediaType::Djvu), + ] + } } fn extract_pdf(path: &Path) -> Result { - let doc = lopdf::Document::load(path) - .map_err(|e| PinakesError::MetadataExtraction(format!("PDF load: {e}")))?; + let doc = lopdf::Document::load(path) + .map_err(|e| PinakesError::MetadataExtraction(format!("PDF load: {e}")))?; - let mut meta = ExtractedMetadata::default(); - let mut book_meta = crate::model::ExtractedBookMetadata::default(); + let mut meta = ExtractedMetadata::default(); + let mut book_meta = crate::model::ExtractedBookMetadata::default(); - // Find the Info dictionary via the trailer - if let Ok(info_ref) = doc.trailer.get(b"Info") { - let info_obj = if let Ok(reference) = info_ref.as_reference() { - doc.get_object(reference).ok() - } else { - Some(info_ref) - }; + // Find the Info dictionary via the trailer + if let Ok(info_ref) = doc.trailer.get(b"Info") { + let info_obj = if let Ok(reference) = info_ref.as_reference() { + doc.get_object(reference).ok() + } else { + Some(info_ref) + }; - if let Some(obj) = info_obj - && let Ok(dict) = obj.as_dict() - { - if let Ok(title) = dict.get(b"Title") { - meta.title = pdf_object_to_string(title); - } - if let Ok(author) = dict.get(b"Author") { - let author_str = pdf_object_to_string(author); - meta.artist = author_str.clone(); - - // Parse multiple authors if separated by semicolon, comma, or "and" - if let Some(authors_str) = author_str { - let author_names: Vec = authors_str - .split(&[';', ','][..]) - .flat_map(|part| part.split(" and ")) - .map(|name| name.trim().to_string()) - .filter(|name| !name.is_empty()) - .collect(); - - book_meta.authors = author_names - .into_iter() - .enumerate() - .map(|(pos, name)| { - let mut author = crate::model::AuthorInfo::new(name); - author.position = pos as i32; - author - }) - .collect(); - } - } - if let Ok(subject) = dict.get(b"Subject") { - meta.description = pdf_object_to_string(subject); - } - if let Ok(creator) = dict.get(b"Creator") { - meta.extra.insert( - "creator".to_string(), - pdf_object_to_string(creator).unwrap_or_default(), - ); - } - if let Ok(producer) = dict.get(b"Producer") { - meta.extra.insert( - "producer".to_string(), - pdf_object_to_string(producer).unwrap_or_default(), - ); - } - } - } - - // Page count - let pages = doc.get_pages(); - let page_count = pages.len(); - if page_count > 0 { - book_meta.page_count = Some(page_count as i32); - } - - // Try to extract ISBN from first few pages - // Extract text from up to the first 5 pages and search for ISBN patterns - let mut extracted_text = String::new(); - let max_pages = page_count.min(5); - - for (_page_num, page_id) in pages.iter().take(max_pages) { - if let Ok(content) = doc.get_page_content(*page_id) { - // PDF content streams contain raw operators, but may have text strings - if let Ok(text) = std::str::from_utf8(&content) { - extracted_text.push_str(text); - extracted_text.push(' '); - } - } - } - - // Extract ISBN from the text - if let Some(isbn) = crate::books::extract_isbn_from_text(&extracted_text) - && let Ok(normalized) = crate::books::normalize_isbn(&isbn) + if let Some(obj) = info_obj + && let Ok(dict) = obj.as_dict() { - book_meta.isbn13 = Some(normalized); - book_meta.isbn = Some(isbn); + if let Ok(title) = dict.get(b"Title") { + meta.title = pdf_object_to_string(title); + } + if let Ok(author) = dict.get(b"Author") { + let author_str = pdf_object_to_string(author); + meta.artist = author_str.clone(); + + // Parse multiple authors if separated by semicolon, comma, or "and" + if let Some(authors_str) = author_str { + let author_names: Vec = authors_str + .split(&[';', ','][..]) + .flat_map(|part| part.split(" and ")) + .map(|name| name.trim().to_string()) + .filter(|name| !name.is_empty()) + .collect(); + + book_meta.authors = author_names + .into_iter() + .enumerate() + .map(|(pos, name)| { + let mut author = crate::model::AuthorInfo::new(name); + author.position = pos as i32; + author + }) + .collect(); + } + } + if let Ok(subject) = dict.get(b"Subject") { + meta.description = pdf_object_to_string(subject); + } + if let Ok(creator) = dict.get(b"Creator") { + meta.extra.insert( + "creator".to_string(), + pdf_object_to_string(creator).unwrap_or_default(), + ); + } + if let Ok(producer) = dict.get(b"Producer") { + meta.extra.insert( + "producer".to_string(), + pdf_object_to_string(producer).unwrap_or_default(), + ); + } } + } - // Set format - book_meta.format = Some("pdf".to_string()); + // Page count + let pages = doc.get_pages(); + let page_count = pages.len(); + if page_count > 0 { + book_meta.page_count = Some(page_count as i32); + } - meta.book_metadata = Some(book_meta); - Ok(meta) + // Try to extract ISBN from first few pages + // Extract text from up to the first 5 pages and search for ISBN patterns + let mut extracted_text = String::new(); + let max_pages = page_count.min(5); + + for (_page_num, page_id) in pages.iter().take(max_pages) { + if let Ok(content) = doc.get_page_content(*page_id) { + // PDF content streams contain raw operators, but may have text strings + if let Ok(text) = std::str::from_utf8(&content) { + extracted_text.push_str(text); + extracted_text.push(' '); + } + } + } + + // Extract ISBN from the text + if let Some(isbn) = crate::books::extract_isbn_from_text(&extracted_text) + && let Ok(normalized) = crate::books::normalize_isbn(&isbn) + { + book_meta.isbn13 = Some(normalized); + book_meta.isbn = Some(isbn); + } + + // Set format + book_meta.format = Some("pdf".to_string()); + + meta.book_metadata = Some(book_meta); + Ok(meta) } fn pdf_object_to_string(obj: &lopdf::Object) -> Option { - match obj { - lopdf::Object::String(bytes, _) => Some(String::from_utf8_lossy(bytes).into_owned()), - lopdf::Object::Name(name) => Some(String::from_utf8_lossy(name).into_owned()), - _ => None, - } + match obj { + lopdf::Object::String(bytes, _) => { + Some(String::from_utf8_lossy(bytes).into_owned()) + }, + lopdf::Object::Name(name) => { + Some(String::from_utf8_lossy(name).into_owned()) + }, + _ => None, + } } fn extract_epub(path: &Path) -> Result { - let mut doc = epub::doc::EpubDoc::new(path) - .map_err(|e| PinakesError::MetadataExtraction(format!("EPUB parse: {e}")))?; + let mut doc = epub::doc::EpubDoc::new(path).map_err(|e| { + PinakesError::MetadataExtraction(format!("EPUB parse: {e}")) + })?; - let mut meta = ExtractedMetadata { - title: doc.mdata("title").map(|item| item.value.clone()), - artist: doc.mdata("creator").map(|item| item.value.clone()), - description: doc.mdata("description").map(|item| item.value.clone()), - ..Default::default() - }; + let mut meta = ExtractedMetadata { + title: doc.mdata("title").map(|item| item.value.clone()), + artist: doc.mdata("creator").map(|item| item.value.clone()), + description: doc.mdata("description").map(|item| item.value.clone()), + ..Default::default() + }; - let mut book_meta = crate::model::ExtractedBookMetadata::default(); + let mut book_meta = crate::model::ExtractedBookMetadata::default(); - // Extract basic metadata - if let Some(lang) = doc.mdata("language") { - book_meta.language = Some(lang.value.clone()); + // Extract basic metadata + if let Some(lang) = doc.mdata("language") { + book_meta.language = Some(lang.value.clone()); + } + if let Some(publisher) = doc.mdata("publisher") { + book_meta.publisher = Some(publisher.value.clone()); + } + if let Some(date) = doc.mdata("date") { + // Try to parse as YYYY-MM-DD or just YYYY + if let Ok(parsed_date) = + chrono::NaiveDate::parse_from_str(&date.value, "%Y-%m-%d") + { + book_meta.publication_date = Some(parsed_date); + } else if let Ok(year) = date.value.parse::() { + book_meta.publication_date = chrono::NaiveDate::from_ymd_opt(year, 1, 1); } - if let Some(publisher) = doc.mdata("publisher") { - book_meta.publisher = Some(publisher.value.clone()); + } + + // Extract authors - iterate through all metadata items + let mut authors = Vec::new(); + let mut position = 0; + for item in &doc.metadata { + if item.property == "creator" || item.property == "dc:creator" { + let mut author = crate::model::AuthorInfo::new(item.value.clone()); + author.position = position; + position += 1; + + // Check for file-as in refinements + if let Some(file_as_ref) = item.refinement("file-as") { + author.file_as = Some(file_as_ref.value.clone()); + } + + // Check for role in refinements + if let Some(role_ref) = item.refinement("role") { + author.role = role_ref.value.clone(); + } + + authors.push(author); } - if let Some(date) = doc.mdata("date") { - // Try to parse as YYYY-MM-DD or just YYYY - if let Ok(parsed_date) = chrono::NaiveDate::parse_from_str(&date.value, "%Y-%m-%d") { - book_meta.publication_date = Some(parsed_date); - } else if let Ok(year) = date.value.parse::() { - book_meta.publication_date = chrono::NaiveDate::from_ymd_opt(year, 1, 1); - } + } + book_meta.authors = authors; + + // Extract ISBNs from identifiers + let mut identifiers = std::collections::HashMap::new(); + for item in &doc.metadata { + if item.property == "identifier" || item.property == "dc:identifier" { + // Try to get scheme from refinements + let scheme = item + .refinement("identifier-type") + .map(|r| r.value.to_lowercase()); + + let id_type = match scheme.as_deref() { + Some("isbn") => "isbn", + Some("isbn-10") | Some("isbn10") => "isbn", + Some("isbn-13") | Some("isbn13") => "isbn13", + Some("asin") => "asin", + Some("doi") => "doi", + _ => { + // Fallback: detect from value pattern + if item.value.len() == 10 + || item.value.len() == 13 + || item.value.contains('-') && item.value.len() < 20 + { + "isbn" + } else { + "other" + } + }, + }; + + // Try to normalize ISBN + if (id_type == "isbn" || id_type == "isbn13") + && let Ok(normalized) = crate::books::normalize_isbn(&item.value) + { + book_meta.isbn13 = Some(normalized.clone()); + book_meta.isbn = Some(item.value.clone()); + } + + identifiers + .entry(id_type.to_string()) + .or_insert_with(Vec::new) + .push(item.value.clone()); + } + } + book_meta.identifiers = identifiers; + + // Extract Calibre series metadata by parsing the content.opf file + // Try common OPF locations + let opf_paths = vec!["OEBPS/content.opf", "content.opf", "OPS/content.opf"]; + let mut opf_data = None; + for path in opf_paths { + if let Some(data) = doc.get_resource_str_by_path(path) { + opf_data = Some(data); + break; + } + } + + if let Some(opf_content) = opf_data { + // Look for + if let Some(series_start) = opf_content.find("name=\"calibre:series\"") + && let Some(content_start) = + opf_content[series_start..].find("content=\"") + { + let after_content = &opf_content[series_start + content_start + 9..]; + if let Some(quote_end) = after_content.find('"') { + book_meta.series_name = Some(after_content[..quote_end].to_string()); + } } - // Extract authors - iterate through all metadata items - let mut authors = Vec::new(); - let mut position = 0; - for item in &doc.metadata { - if item.property == "creator" || item.property == "dc:creator" { - let mut author = crate::model::AuthorInfo::new(item.value.clone()); - author.position = position; - position += 1; - - // Check for file-as in refinements - if let Some(file_as_ref) = item.refinement("file-as") { - author.file_as = Some(file_as_ref.value.clone()); - } - - // Check for role in refinements - if let Some(role_ref) = item.refinement("role") { - author.role = role_ref.value.clone(); - } - - authors.push(author); - } + // Look for + if let Some(index_start) = opf_content.find("name=\"calibre:series_index\"") + && let Some(content_start) = opf_content[index_start..].find("content=\"") + { + let after_content = &opf_content[index_start + content_start + 9..]; + if let Some(quote_end) = after_content.find('"') + && let Ok(index) = after_content[..quote_end].parse::() + { + book_meta.series_index = Some(index); + } } - book_meta.authors = authors; + } - // Extract ISBNs from identifiers - let mut identifiers = std::collections::HashMap::new(); - for item in &doc.metadata { - if item.property == "identifier" || item.property == "dc:identifier" { - // Try to get scheme from refinements - let scheme = item - .refinement("identifier-type") - .map(|r| r.value.to_lowercase()); + // Set format + book_meta.format = Some("epub".to_string()); - let id_type = match scheme.as_deref() { - Some("isbn") => "isbn", - Some("isbn-10") | Some("isbn10") => "isbn", - Some("isbn-13") | Some("isbn13") => "isbn13", - Some("asin") => "asin", - Some("doi") => "doi", - _ => { - // Fallback: detect from value pattern - if item.value.len() == 10 - || item.value.len() == 13 - || item.value.contains('-') && item.value.len() < 20 - { - "isbn" - } else { - "other" - } - } - }; - - // Try to normalize ISBN - if (id_type == "isbn" || id_type == "isbn13") - && let Ok(normalized) = crate::books::normalize_isbn(&item.value) - { - book_meta.isbn13 = Some(normalized.clone()); - book_meta.isbn = Some(item.value.clone()); - } - - identifiers - .entry(id_type.to_string()) - .or_insert_with(Vec::new) - .push(item.value.clone()); - } - } - book_meta.identifiers = identifiers; - - // Extract Calibre series metadata by parsing the content.opf file - // Try common OPF locations - let opf_paths = vec!["OEBPS/content.opf", "content.opf", "OPS/content.opf"]; - let mut opf_data = None; - for path in opf_paths { - if let Some(data) = doc.get_resource_str_by_path(path) { - opf_data = Some(data); - break; - } - } - - if let Some(opf_content) = opf_data { - // Look for - if let Some(series_start) = opf_content.find("name=\"calibre:series\"") - && let Some(content_start) = opf_content[series_start..].find("content=\"") - { - let after_content = &opf_content[series_start + content_start + 9..]; - if let Some(quote_end) = after_content.find('"') { - book_meta.series_name = Some(after_content[..quote_end].to_string()); - } - } - - // Look for - if let Some(index_start) = opf_content.find("name=\"calibre:series_index\"") - && let Some(content_start) = opf_content[index_start..].find("content=\"") - { - let after_content = &opf_content[index_start + content_start + 9..]; - if let Some(quote_end) = after_content.find('"') - && let Ok(index) = after_content[..quote_end].parse::() - { - book_meta.series_index = Some(index); - } - } - } - - // Set format - book_meta.format = Some("epub".to_string()); - - meta.book_metadata = Some(book_meta); - Ok(meta) + meta.book_metadata = Some(book_meta); + Ok(meta) } fn extract_djvu(path: &Path) -> Result { - // DjVu files contain metadata in SEXPR (S-expression) format within - // ANTa/ANTz chunks, or in the DIRM chunk. We parse the raw bytes to - // extract any metadata fields we can find. - let data = std::fs::read(path) - .map_err(|e| PinakesError::MetadataExtraction(format!("DjVu read: {e}")))?; + // DjVu files contain metadata in SEXPR (S-expression) format within + // ANTa/ANTz chunks, or in the DIRM chunk. We parse the raw bytes to + // extract any metadata fields we can find. + let data = std::fs::read(path) + .map_err(|e| PinakesError::MetadataExtraction(format!("DjVu read: {e}")))?; - let mut meta = ExtractedMetadata::default(); + let mut meta = ExtractedMetadata::default(); - // DjVu files start with "AT&T" magic followed by FORM:DJVU or FORM:DJVM - if data.len() < 16 { - return Ok(meta); + // DjVu files start with "AT&T" magic followed by FORM:DJVU or FORM:DJVM + if data.len() < 16 { + return Ok(meta); + } + + // Search for metadata annotations in the file. DjVu metadata is stored + // as S-expressions like (metadata (key "value") ...) within ANTa chunks. + let content = String::from_utf8_lossy(&data); + + // Look for (metadata ...) blocks + if let Some(meta_start) = content.find("(metadata") { + let remainder = &content[meta_start..]; + // Extract key-value pairs like (title "Some Title") + extract_djvu_field(remainder, "title", &mut meta.title); + extract_djvu_field(remainder, "author", &mut meta.artist); + + let mut desc = None; + extract_djvu_field(remainder, "subject", &mut desc); + if desc.is_none() { + extract_djvu_field(remainder, "description", &mut desc); + } + meta.description = desc; + + let mut year_str = None; + extract_djvu_field(remainder, "year", &mut year_str); + if let Some(ref y) = year_str { + meta.year = y.parse().ok(); } - // Search for metadata annotations in the file. DjVu metadata is stored - // as S-expressions like (metadata (key "value") ...) within ANTa chunks. - let content = String::from_utf8_lossy(&data); - - // Look for (metadata ...) blocks - if let Some(meta_start) = content.find("(metadata") { - let remainder = &content[meta_start..]; - // Extract key-value pairs like (title "Some Title") - extract_djvu_field(remainder, "title", &mut meta.title); - extract_djvu_field(remainder, "author", &mut meta.artist); - - let mut desc = None; - extract_djvu_field(remainder, "subject", &mut desc); - if desc.is_none() { - extract_djvu_field(remainder, "description", &mut desc); - } - meta.description = desc; - - let mut year_str = None; - extract_djvu_field(remainder, "year", &mut year_str); - if let Some(ref y) = year_str { - meta.year = y.parse().ok(); - } - - let mut creator = None; - extract_djvu_field(remainder, "creator", &mut creator); - if let Some(c) = creator { - meta.extra.insert("creator".to_string(), c); - } + let mut creator = None; + extract_djvu_field(remainder, "creator", &mut creator); + if let Some(c) = creator { + meta.extra.insert("creator".to_string(), c); } + } - // Also check for booklet-style metadata that some DjVu encoders write - // outside the metadata SEXPR - if meta.title.is_none() - && let Some(title_start) = content.find("(bookmarks") - { - let remainder = &content[title_start..]; - // First bookmark title is often the document title - if let Some(q1) = remainder.find('"') { - let after_q1 = &remainder[q1 + 1..]; - if let Some(q2) = after_q1.find('"') { - let val = &after_q1[..q2]; - if !val.is_empty() { - meta.title = Some(val.to_string()); - } - } + // Also check for booklet-style metadata that some DjVu encoders write + // outside the metadata SEXPR + if meta.title.is_none() + && let Some(title_start) = content.find("(bookmarks") + { + let remainder = &content[title_start..]; + // First bookmark title is often the document title + if let Some(q1) = remainder.find('"') { + let after_q1 = &remainder[q1 + 1..]; + if let Some(q2) = after_q1.find('"') { + let val = &after_q1[..q2]; + if !val.is_empty() { + meta.title = Some(val.to_string()); } + } } + } - Ok(meta) + Ok(meta) } fn extract_djvu_field(sexpr: &str, key: &str, out: &mut Option) { - // Look for patterns like (key "value") in the S-expression - let pattern = format!("({key}"); - if let Some(start) = sexpr.find(&pattern) { - let remainder = &sexpr[start + pattern.len()..]; - // Find the quoted value - if let Some(q1) = remainder.find('"') { - let after_q1 = &remainder[q1 + 1..]; - if let Some(q2) = after_q1.find('"') { - let val = &after_q1[..q2]; - if !val.is_empty() { - *out = Some(val.to_string()); - } - } + // Look for patterns like (key "value") in the S-expression + let pattern = format!("({key}"); + if let Some(start) = sexpr.find(&pattern) { + let remainder = &sexpr[start + pattern.len()..]; + // Find the quoted value + if let Some(q1) = remainder.find('"') { + let after_q1 = &remainder[q1 + 1..]; + if let Some(q2) = after_q1.find('"') { + let val = &after_q1[..q2]; + if !val.is_empty() { + *out = Some(val.to_string()); } + } } + } } diff --git a/crates/pinakes-core/src/metadata/image.rs b/crates/pinakes-core/src/metadata/image.rs index d675362..a39f3bd 100644 --- a/crates/pinakes-core/src/metadata/image.rs +++ b/crates/pinakes-core/src/metadata/image.rs @@ -1,263 +1,297 @@ use std::path::Path; -use crate::error::Result; -use crate::media_type::{BuiltinMediaType, MediaType}; - use super::{ExtractedMetadata, MetadataExtractor}; +use crate::{ + error::Result, + media_type::{BuiltinMediaType, MediaType}, +}; pub struct ImageExtractor; impl MetadataExtractor for ImageExtractor { - fn extract(&self, path: &Path) -> Result { - let mut meta = ExtractedMetadata::default(); + fn extract(&self, path: &Path) -> Result { + let mut meta = ExtractedMetadata::default(); - let file = std::fs::File::open(path)?; - let mut buf_reader = std::io::BufReader::new(&file); + let file = std::fs::File::open(path)?; + let mut buf_reader = std::io::BufReader::new(&file); - let exif_data = match exif::Reader::new().read_from_container(&mut buf_reader) { - Ok(exif) => exif, - Err(_) => return Ok(meta), - }; + let exif_data = + match exif::Reader::new().read_from_container(&mut buf_reader) { + Ok(exif) => exif, + Err(_) => return Ok(meta), + }; - // Image dimensions - if let Some(width) = exif_data - .get_field(exif::Tag::PixelXDimension, exif::In::PRIMARY) - .or_else(|| exif_data.get_field(exif::Tag::ImageWidth, exif::In::PRIMARY)) - && let Some(w) = field_to_u32(width) - { - meta.extra.insert("width".to_string(), w.to_string()); - } - if let Some(height) = exif_data - .get_field(exif::Tag::PixelYDimension, exif::In::PRIMARY) - .or_else(|| exif_data.get_field(exif::Tag::ImageLength, exif::In::PRIMARY)) - && let Some(h) = field_to_u32(height) - { - meta.extra.insert("height".to_string(), h.to_string()); - } - - // Camera make and model - set both in top-level fields and extra - if let Some(make) = exif_data.get_field(exif::Tag::Make, exif::In::PRIMARY) { - let val = make.display_value().to_string().trim().to_string(); - if !val.is_empty() { - meta.camera_make = Some(val.clone()); - meta.extra.insert("camera_make".to_string(), val); - } - } - if let Some(model) = exif_data.get_field(exif::Tag::Model, exif::In::PRIMARY) { - let val = model.display_value().to_string().trim().to_string(); - if !val.is_empty() { - meta.camera_model = Some(val.clone()); - meta.extra.insert("camera_model".to_string(), val); - } - } - - // Date taken - parse EXIF date format (YYYY:MM:DD HH:MM:SS) - if let Some(date) = exif_data - .get_field(exif::Tag::DateTimeOriginal, exif::In::PRIMARY) - .or_else(|| exif_data.get_field(exif::Tag::DateTime, exif::In::PRIMARY)) - { - let val = date.display_value().to_string(); - if !val.is_empty() { - // Try parsing EXIF format: "YYYY:MM:DD HH:MM:SS" - if let Some(dt) = parse_exif_datetime(&val) { - meta.date_taken = Some(dt); - } - meta.extra.insert("date_taken".to_string(), val); - } - } - - // GPS coordinates - set both in top-level fields and extra - if let (Some(lat), Some(lat_ref), Some(lon), Some(lon_ref)) = ( - exif_data.get_field(exif::Tag::GPSLatitude, exif::In::PRIMARY), - exif_data.get_field(exif::Tag::GPSLatitudeRef, exif::In::PRIMARY), - exif_data.get_field(exif::Tag::GPSLongitude, exif::In::PRIMARY), - exif_data.get_field(exif::Tag::GPSLongitudeRef, exif::In::PRIMARY), - ) && let (Some(lat_val), Some(lon_val)) = - (dms_to_decimal(lat, lat_ref), dms_to_decimal(lon, lon_ref)) - { - meta.latitude = Some(lat_val); - meta.longitude = Some(lon_val); - meta.extra - .insert("gps_latitude".to_string(), format!("{lat_val:.6}")); - meta.extra - .insert("gps_longitude".to_string(), format!("{lon_val:.6}")); - } - - // Exposure info - if let Some(iso) = - exif_data.get_field(exif::Tag::PhotographicSensitivity, exif::In::PRIMARY) - { - let val = iso.display_value().to_string(); - if !val.is_empty() { - meta.extra.insert("iso".to_string(), val); - } - } - if let Some(exposure) = exif_data.get_field(exif::Tag::ExposureTime, exif::In::PRIMARY) { - let val = exposure.display_value().to_string(); - if !val.is_empty() { - meta.extra.insert("exposure_time".to_string(), val); - } - } - if let Some(aperture) = exif_data.get_field(exif::Tag::FNumber, exif::In::PRIMARY) { - let val = aperture.display_value().to_string(); - if !val.is_empty() { - meta.extra.insert("f_number".to_string(), val); - } - } - if let Some(focal) = exif_data.get_field(exif::Tag::FocalLength, exif::In::PRIMARY) { - let val = focal.display_value().to_string(); - if !val.is_empty() { - meta.extra.insert("focal_length".to_string(), val); - } - } - - // Lens model - if let Some(lens) = exif_data.get_field(exif::Tag::LensModel, exif::In::PRIMARY) { - let val = lens.display_value().to_string(); - if !val.is_empty() && val != "\"\"" { - meta.extra - .insert("lens_model".to_string(), val.trim_matches('"').to_string()); - } - } - - // Flash - if let Some(flash) = exif_data.get_field(exif::Tag::Flash, exif::In::PRIMARY) { - let val = flash.display_value().to_string(); - if !val.is_empty() { - meta.extra.insert("flash".to_string(), val); - } - } - - // Orientation - if let Some(orientation) = exif_data.get_field(exif::Tag::Orientation, exif::In::PRIMARY) { - let val = orientation.display_value().to_string(); - if !val.is_empty() { - meta.extra.insert("orientation".to_string(), val); - } - } - - // Software - if let Some(software) = exif_data.get_field(exif::Tag::Software, exif::In::PRIMARY) { - let val = software.display_value().to_string(); - if !val.is_empty() { - meta.extra.insert("software".to_string(), val); - } - } - - // Image description as title - if let Some(desc) = exif_data.get_field(exif::Tag::ImageDescription, exif::In::PRIMARY) { - let val = desc.display_value().to_string(); - if !val.is_empty() && val != "\"\"" { - meta.title = Some(val.trim_matches('"').to_string()); - } - } - - // Artist - if let Some(artist) = exif_data.get_field(exif::Tag::Artist, exif::In::PRIMARY) { - let val = artist.display_value().to_string(); - if !val.is_empty() && val != "\"\"" { - meta.artist = Some(val.trim_matches('"').to_string()); - } - } - - // Copyright as description - if let Some(copyright) = exif_data.get_field(exif::Tag::Copyright, exif::In::PRIMARY) { - let val = copyright.display_value().to_string(); - if !val.is_empty() && val != "\"\"" { - meta.description = Some(val.trim_matches('"').to_string()); - } - } - - Ok(meta) + // Image dimensions + if let Some(width) = exif_data + .get_field(exif::Tag::PixelXDimension, exif::In::PRIMARY) + .or_else(|| exif_data.get_field(exif::Tag::ImageWidth, exif::In::PRIMARY)) + && let Some(w) = field_to_u32(width) + { + meta.extra.insert("width".to_string(), w.to_string()); + } + if let Some(height) = exif_data + .get_field(exif::Tag::PixelYDimension, exif::In::PRIMARY) + .or_else(|| { + exif_data.get_field(exif::Tag::ImageLength, exif::In::PRIMARY) + }) + && let Some(h) = field_to_u32(height) + { + meta.extra.insert("height".to_string(), h.to_string()); } - fn supported_types(&self) -> Vec { - vec![ - MediaType::Builtin(BuiltinMediaType::Jpeg), - MediaType::Builtin(BuiltinMediaType::Png), - MediaType::Builtin(BuiltinMediaType::Gif), - MediaType::Builtin(BuiltinMediaType::Webp), - MediaType::Builtin(BuiltinMediaType::Avif), - MediaType::Builtin(BuiltinMediaType::Tiff), - MediaType::Builtin(BuiltinMediaType::Bmp), - // RAW formats (TIFF-based, kamadak-exif handles these) - MediaType::Builtin(BuiltinMediaType::Cr2), - MediaType::Builtin(BuiltinMediaType::Nef), - MediaType::Builtin(BuiltinMediaType::Arw), - MediaType::Builtin(BuiltinMediaType::Dng), - MediaType::Builtin(BuiltinMediaType::Orf), - MediaType::Builtin(BuiltinMediaType::Rw2), - // HEIC - MediaType::Builtin(BuiltinMediaType::Heic), - ] + // Camera make and model - set both in top-level fields and extra + if let Some(make) = exif_data.get_field(exif::Tag::Make, exif::In::PRIMARY) + { + let val = make.display_value().to_string().trim().to_string(); + if !val.is_empty() { + meta.camera_make = Some(val.clone()); + meta.extra.insert("camera_make".to_string(), val); + } } + if let Some(model) = + exif_data.get_field(exif::Tag::Model, exif::In::PRIMARY) + { + let val = model.display_value().to_string().trim().to_string(); + if !val.is_empty() { + meta.camera_model = Some(val.clone()); + meta.extra.insert("camera_model".to_string(), val); + } + } + + // Date taken - parse EXIF date format (YYYY:MM:DD HH:MM:SS) + if let Some(date) = exif_data + .get_field(exif::Tag::DateTimeOriginal, exif::In::PRIMARY) + .or_else(|| exif_data.get_field(exif::Tag::DateTime, exif::In::PRIMARY)) + { + let val = date.display_value().to_string(); + if !val.is_empty() { + // Try parsing EXIF format: "YYYY:MM:DD HH:MM:SS" + if let Some(dt) = parse_exif_datetime(&val) { + meta.date_taken = Some(dt); + } + meta.extra.insert("date_taken".to_string(), val); + } + } + + // GPS coordinates - set both in top-level fields and extra + if let (Some(lat), Some(lat_ref), Some(lon), Some(lon_ref)) = ( + exif_data.get_field(exif::Tag::GPSLatitude, exif::In::PRIMARY), + exif_data.get_field(exif::Tag::GPSLatitudeRef, exif::In::PRIMARY), + exif_data.get_field(exif::Tag::GPSLongitude, exif::In::PRIMARY), + exif_data.get_field(exif::Tag::GPSLongitudeRef, exif::In::PRIMARY), + ) && let (Some(lat_val), Some(lon_val)) = + (dms_to_decimal(lat, lat_ref), dms_to_decimal(lon, lon_ref)) + { + meta.latitude = Some(lat_val); + meta.longitude = Some(lon_val); + meta + .extra + .insert("gps_latitude".to_string(), format!("{lat_val:.6}")); + meta + .extra + .insert("gps_longitude".to_string(), format!("{lon_val:.6}")); + } + + // Exposure info + if let Some(iso) = + exif_data.get_field(exif::Tag::PhotographicSensitivity, exif::In::PRIMARY) + { + let val = iso.display_value().to_string(); + if !val.is_empty() { + meta.extra.insert("iso".to_string(), val); + } + } + if let Some(exposure) = + exif_data.get_field(exif::Tag::ExposureTime, exif::In::PRIMARY) + { + let val = exposure.display_value().to_string(); + if !val.is_empty() { + meta.extra.insert("exposure_time".to_string(), val); + } + } + if let Some(aperture) = + exif_data.get_field(exif::Tag::FNumber, exif::In::PRIMARY) + { + let val = aperture.display_value().to_string(); + if !val.is_empty() { + meta.extra.insert("f_number".to_string(), val); + } + } + if let Some(focal) = + exif_data.get_field(exif::Tag::FocalLength, exif::In::PRIMARY) + { + let val = focal.display_value().to_string(); + if !val.is_empty() { + meta.extra.insert("focal_length".to_string(), val); + } + } + + // Lens model + if let Some(lens) = + exif_data.get_field(exif::Tag::LensModel, exif::In::PRIMARY) + { + let val = lens.display_value().to_string(); + if !val.is_empty() && val != "\"\"" { + meta + .extra + .insert("lens_model".to_string(), val.trim_matches('"').to_string()); + } + } + + // Flash + if let Some(flash) = + exif_data.get_field(exif::Tag::Flash, exif::In::PRIMARY) + { + let val = flash.display_value().to_string(); + if !val.is_empty() { + meta.extra.insert("flash".to_string(), val); + } + } + + // Orientation + if let Some(orientation) = + exif_data.get_field(exif::Tag::Orientation, exif::In::PRIMARY) + { + let val = orientation.display_value().to_string(); + if !val.is_empty() { + meta.extra.insert("orientation".to_string(), val); + } + } + + // Software + if let Some(software) = + exif_data.get_field(exif::Tag::Software, exif::In::PRIMARY) + { + let val = software.display_value().to_string(); + if !val.is_empty() { + meta.extra.insert("software".to_string(), val); + } + } + + // Image description as title + if let Some(desc) = + exif_data.get_field(exif::Tag::ImageDescription, exif::In::PRIMARY) + { + let val = desc.display_value().to_string(); + if !val.is_empty() && val != "\"\"" { + meta.title = Some(val.trim_matches('"').to_string()); + } + } + + // Artist + if let Some(artist) = + exif_data.get_field(exif::Tag::Artist, exif::In::PRIMARY) + { + let val = artist.display_value().to_string(); + if !val.is_empty() && val != "\"\"" { + meta.artist = Some(val.trim_matches('"').to_string()); + } + } + + // Copyright as description + if let Some(copyright) = + exif_data.get_field(exif::Tag::Copyright, exif::In::PRIMARY) + { + let val = copyright.display_value().to_string(); + if !val.is_empty() && val != "\"\"" { + meta.description = Some(val.trim_matches('"').to_string()); + } + } + + Ok(meta) + } + + fn supported_types(&self) -> Vec { + vec![ + MediaType::Builtin(BuiltinMediaType::Jpeg), + MediaType::Builtin(BuiltinMediaType::Png), + MediaType::Builtin(BuiltinMediaType::Gif), + MediaType::Builtin(BuiltinMediaType::Webp), + MediaType::Builtin(BuiltinMediaType::Avif), + MediaType::Builtin(BuiltinMediaType::Tiff), + MediaType::Builtin(BuiltinMediaType::Bmp), + // RAW formats (TIFF-based, kamadak-exif handles these) + MediaType::Builtin(BuiltinMediaType::Cr2), + MediaType::Builtin(BuiltinMediaType::Nef), + MediaType::Builtin(BuiltinMediaType::Arw), + MediaType::Builtin(BuiltinMediaType::Dng), + MediaType::Builtin(BuiltinMediaType::Orf), + MediaType::Builtin(BuiltinMediaType::Rw2), + // HEIC + MediaType::Builtin(BuiltinMediaType::Heic), + ] + } } fn field_to_u32(field: &exif::Field) -> Option { - match &field.value { - exif::Value::Long(v) => v.first().copied(), - exif::Value::Short(v) => v.first().map(|&x| x as u32), - _ => None, - } + match &field.value { + exif::Value::Long(v) => v.first().copied(), + exif::Value::Short(v) => v.first().map(|&x| x as u32), + _ => None, + } } -fn dms_to_decimal(dms_field: &exif::Field, ref_field: &exif::Field) -> Option { - if let exif::Value::Rational(ref rationals) = dms_field.value - && rationals.len() >= 3 - { - let degrees = rationals[0].to_f64(); - let minutes = rationals[1].to_f64(); - let seconds = rationals[2].to_f64(); - let mut decimal = degrees + minutes / 60.0 + seconds / 3600.0; +fn dms_to_decimal( + dms_field: &exif::Field, + ref_field: &exif::Field, +) -> Option { + if let exif::Value::Rational(ref rationals) = dms_field.value + && rationals.len() >= 3 + { + let degrees = rationals[0].to_f64(); + let minutes = rationals[1].to_f64(); + let seconds = rationals[2].to_f64(); + let mut decimal = degrees + minutes / 60.0 + seconds / 3600.0; - let ref_str = ref_field.display_value().to_string(); - if ref_str.contains('S') || ref_str.contains('W') { - decimal = -decimal; - } - - return Some(decimal); + let ref_str = ref_field.display_value().to_string(); + if ref_str.contains('S') || ref_str.contains('W') { + decimal = -decimal; } - None + + return Some(decimal); + } + None } /// Parse EXIF datetime format: "YYYY:MM:DD HH:MM:SS" fn parse_exif_datetime(s: &str) -> Option> { - use chrono::NaiveDateTime; + use chrono::NaiveDateTime; - // EXIF format is "YYYY:MM:DD HH:MM:SS" - let s = s.trim().trim_matches('"'); + // EXIF format is "YYYY:MM:DD HH:MM:SS" + let s = s.trim().trim_matches('"'); - // Try standard EXIF format - if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y:%m:%d %H:%M:%S") { - return Some(dt.and_utc()); - } + // Try standard EXIF format + if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y:%m:%d %H:%M:%S") { + return Some(dt.and_utc()); + } - // Try ISO format as fallback - if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") { - return Some(dt.and_utc()); - } + // Try ISO format as fallback + if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") { + return Some(dt.and_utc()); + } - None + None } /// Generate a perceptual hash for an image file. -/// Uses DCT (Discrete Cosine Transform) hash algorithm for robust similarity detection. -/// Returns a hex-encoded hash string, or None if the image cannot be processed. +/// Uses DCT (Discrete Cosine Transform) hash algorithm for robust similarity +/// detection. Returns a hex-encoded hash string, or None if the image cannot be +/// processed. pub fn generate_perceptual_hash(path: &Path) -> Option { - use image_hasher::{HashAlg, HasherConfig}; + use image_hasher::{HashAlg, HasherConfig}; - // Open and decode the image - let img = image::open(path).ok()?; + // Open and decode the image + let img = image::open(path).ok()?; - // Create hasher with DCT algorithm (good for finding similar images) - let hasher = HasherConfig::new() + // Create hasher with DCT algorithm (good for finding similar images) + let hasher = HasherConfig::new() .hash_alg(HashAlg::DoubleGradient) .hash_size(8, 8) // 64-bit hash .to_hasher(); - // Generate hash - let hash = hasher.hash_image(&img); + // Generate hash + let hash = hasher.hash_image(&img); - // Convert to hex string for storage - Some(hash.to_base64()) + // Convert to hex string for storage + Some(hash.to_base64()) } diff --git a/crates/pinakes-core/src/metadata/markdown.rs b/crates/pinakes-core/src/metadata/markdown.rs index b17901c..155a7e6 100644 --- a/crates/pinakes-core/src/metadata/markdown.rs +++ b/crates/pinakes-core/src/metadata/markdown.rs @@ -1,43 +1,45 @@ use std::path::Path; -use crate::error::Result; -use crate::media_type::{BuiltinMediaType, MediaType}; - use super::{ExtractedMetadata, MetadataExtractor}; +use crate::{ + error::Result, + media_type::{BuiltinMediaType, MediaType}, +}; pub struct MarkdownExtractor; impl MetadataExtractor for MarkdownExtractor { - fn extract(&self, path: &Path) -> Result { - let content = std::fs::read_to_string(path)?; - let parsed = gray_matter::Matter::::new().parse(&content); + fn extract(&self, path: &Path) -> Result { + let content = std::fs::read_to_string(path)?; + let parsed = + gray_matter::Matter::::new().parse(&content); - let mut meta = ExtractedMetadata::default(); + let mut meta = ExtractedMetadata::default(); - if let Some(data) = parsed.ok().and_then(|p| p.data) - && let gray_matter::Pod::Hash(map) = data - { - if let Some(gray_matter::Pod::String(title)) = map.get("title") { - meta.title = Some(title.clone()); - } - if let Some(gray_matter::Pod::String(author)) = map.get("author") { - meta.artist = Some(author.clone()); - } - if let Some(gray_matter::Pod::String(desc)) = map.get("description") { - meta.description = Some(desc.clone()); - } - if let Some(gray_matter::Pod::String(date)) = map.get("date") { - meta.extra.insert("date".to_string(), date.clone()); - } - } - - Ok(meta) + if let Some(data) = parsed.ok().and_then(|p| p.data) + && let gray_matter::Pod::Hash(map) = data + { + if let Some(gray_matter::Pod::String(title)) = map.get("title") { + meta.title = Some(title.clone()); + } + if let Some(gray_matter::Pod::String(author)) = map.get("author") { + meta.artist = Some(author.clone()); + } + if let Some(gray_matter::Pod::String(desc)) = map.get("description") { + meta.description = Some(desc.clone()); + } + if let Some(gray_matter::Pod::String(date)) = map.get("date") { + meta.extra.insert("date".to_string(), date.clone()); + } } - fn supported_types(&self) -> Vec { - vec![ - MediaType::Builtin(BuiltinMediaType::Markdown), - MediaType::Builtin(BuiltinMediaType::PlainText), - ] - } + Ok(meta) + } + + fn supported_types(&self) -> Vec { + vec![ + MediaType::Builtin(BuiltinMediaType::Markdown), + MediaType::Builtin(BuiltinMediaType::PlainText), + ] + } } diff --git a/crates/pinakes-core/src/metadata/mod.rs b/crates/pinakes-core/src/metadata/mod.rs index ba623c1..b622dba 100644 --- a/crates/pinakes-core/src/metadata/mod.rs +++ b/crates/pinakes-core/src/metadata/mod.rs @@ -4,53 +4,57 @@ pub mod image; pub mod markdown; pub mod video; -use std::collections::HashMap; -use std::path::Path; +use std::{collections::HashMap, path::Path}; -use crate::error::Result; -use crate::media_type::MediaType; -use crate::model::ExtractedBookMetadata; +use crate::{ + error::Result, + media_type::MediaType, + model::ExtractedBookMetadata, +}; #[derive(Debug, Clone, Default)] pub struct ExtractedMetadata { - pub title: Option, - pub artist: Option, - pub album: Option, - pub genre: Option, - pub year: Option, - pub duration_secs: Option, - pub description: Option, - pub extra: HashMap, - pub book_metadata: Option, + pub title: Option, + pub artist: Option, + pub album: Option, + pub genre: Option, + pub year: Option, + pub duration_secs: Option, + pub description: Option, + pub extra: HashMap, + pub book_metadata: Option, - // Photo-specific metadata - pub date_taken: Option>, - pub latitude: Option, - pub longitude: Option, - pub camera_make: Option, - pub camera_model: Option, - pub rating: Option, + // Photo-specific metadata + pub date_taken: Option>, + pub latitude: Option, + pub longitude: Option, + pub camera_make: Option, + pub camera_model: Option, + pub rating: Option, } pub trait MetadataExtractor: Send + Sync { - fn extract(&self, path: &Path) -> Result; - fn supported_types(&self) -> Vec; + fn extract(&self, path: &Path) -> Result; + fn supported_types(&self) -> Vec; } -pub fn extract_metadata(path: &Path, media_type: MediaType) -> Result { - let extractors: Vec> = vec![ - Box::new(audio::AudioExtractor), - Box::new(document::DocumentExtractor), - Box::new(video::VideoExtractor), - Box::new(markdown::MarkdownExtractor), - Box::new(image::ImageExtractor), - ]; +pub fn extract_metadata( + path: &Path, + media_type: MediaType, +) -> Result { + let extractors: Vec> = vec![ + Box::new(audio::AudioExtractor), + Box::new(document::DocumentExtractor), + Box::new(video::VideoExtractor), + Box::new(markdown::MarkdownExtractor), + Box::new(image::ImageExtractor), + ]; - for extractor in &extractors { - if extractor.supported_types().contains(&media_type) { - return extractor.extract(path); - } + for extractor in &extractors { + if extractor.supported_types().contains(&media_type) { + return extractor.extract(path); } + } - Ok(ExtractedMetadata::default()) + Ok(ExtractedMetadata::default()) } diff --git a/crates/pinakes-core/src/metadata/video.rs b/crates/pinakes-core/src/metadata/video.rs index 7a3f9ba..0e6b827 100644 --- a/crates/pinakes-core/src/metadata/video.rs +++ b/crates/pinakes-core/src/metadata/video.rs @@ -1,118 +1,128 @@ use std::path::Path; -use crate::error::{PinakesError, Result}; -use crate::media_type::{BuiltinMediaType, MediaType}; - use super::{ExtractedMetadata, MetadataExtractor}; +use crate::{ + error::{PinakesError, Result}, + media_type::{BuiltinMediaType, MediaType}, +}; pub struct VideoExtractor; impl MetadataExtractor for VideoExtractor { - fn extract(&self, path: &Path) -> Result { - match MediaType::from_path(path) { - Some(MediaType::Builtin(BuiltinMediaType::Mkv)) => extract_mkv(path), - Some(MediaType::Builtin(BuiltinMediaType::Mp4)) => extract_mp4(path), - _ => Ok(ExtractedMetadata::default()), - } + fn extract(&self, path: &Path) -> Result { + match MediaType::from_path(path) { + Some(MediaType::Builtin(BuiltinMediaType::Mkv)) => extract_mkv(path), + Some(MediaType::Builtin(BuiltinMediaType::Mp4)) => extract_mp4(path), + _ => Ok(ExtractedMetadata::default()), } + } - fn supported_types(&self) -> Vec { - vec![ - MediaType::Builtin(BuiltinMediaType::Mp4), - MediaType::Builtin(BuiltinMediaType::Mkv), - ] - } + fn supported_types(&self) -> Vec { + vec![ + MediaType::Builtin(BuiltinMediaType::Mp4), + MediaType::Builtin(BuiltinMediaType::Mkv), + ] + } } fn extract_mkv(path: &Path) -> Result { - let file = std::fs::File::open(path)?; - let mkv = matroska::Matroska::open(file) - .map_err(|e| PinakesError::MetadataExtraction(format!("MKV parse: {e}")))?; + let file = std::fs::File::open(path)?; + let mkv = matroska::Matroska::open(file) + .map_err(|e| PinakesError::MetadataExtraction(format!("MKV parse: {e}")))?; - let mut meta = ExtractedMetadata { - title: mkv.info.title.clone(), - duration_secs: mkv.info.duration.map(|dur| dur.as_secs_f64()), - ..Default::default() - }; + let mut meta = ExtractedMetadata { + title: mkv.info.title.clone(), + duration_secs: mkv.info.duration.map(|dur| dur.as_secs_f64()), + ..Default::default() + }; - // Extract resolution and codec info from tracks - for track in &mkv.tracks { - match &track.settings { - matroska::Settings::Video(v) => { - meta.extra.insert( - "resolution".to_string(), - format!("{}x{}", v.pixel_width, v.pixel_height), - ); - if !track.codec_id.is_empty() { - meta.extra - .insert("video_codec".to_string(), track.codec_id.clone()); - } - } - matroska::Settings::Audio(a) => { - meta.extra.insert( - "sample_rate".to_string(), - format!("{} Hz", a.sample_rate as u32), - ); - meta.extra - .insert("channels".to_string(), a.channels.to_string()); - if !track.codec_id.is_empty() { - meta.extra - .insert("audio_codec".to_string(), track.codec_id.clone()); - } - } - _ => {} + // Extract resolution and codec info from tracks + for track in &mkv.tracks { + match &track.settings { + matroska::Settings::Video(v) => { + meta.extra.insert( + "resolution".to_string(), + format!("{}x{}", v.pixel_width, v.pixel_height), + ); + if !track.codec_id.is_empty() { + meta + .extra + .insert("video_codec".to_string(), track.codec_id.clone()); } + }, + matroska::Settings::Audio(a) => { + meta.extra.insert( + "sample_rate".to_string(), + format!("{} Hz", a.sample_rate as u32), + ); + meta + .extra + .insert("channels".to_string(), a.channels.to_string()); + if !track.codec_id.is_empty() { + meta + .extra + .insert("audio_codec".to_string(), track.codec_id.clone()); + } + }, + _ => {}, } + } - Ok(meta) + Ok(meta) } fn extract_mp4(path: &Path) -> Result { - use lofty::file::{AudioFile, TaggedFileExt}; - use lofty::tag::Accessor; + use lofty::{ + file::{AudioFile, TaggedFileExt}, + tag::Accessor, + }; - let tagged_file = lofty::read_from_path(path) - .map_err(|e| PinakesError::MetadataExtraction(format!("MP4 metadata: {e}")))?; + let tagged_file = lofty::read_from_path(path).map_err(|e| { + PinakesError::MetadataExtraction(format!("MP4 metadata: {e}")) + })?; - let mut meta = ExtractedMetadata::default(); + let mut meta = ExtractedMetadata::default(); - if let Some(tag) = tagged_file - .primary_tag() - .or_else(|| tagged_file.first_tag()) - { - meta.title = tag - .title() - .map(|s: std::borrow::Cow<'_, str>| s.to_string()); - meta.artist = tag - .artist() - .map(|s: std::borrow::Cow<'_, str>| s.to_string()); - meta.album = tag - .album() - .map(|s: std::borrow::Cow<'_, str>| s.to_string()); - meta.genre = tag - .genre() - .map(|s: std::borrow::Cow<'_, str>| s.to_string()); - meta.year = tag.date().map(|ts| ts.year as i32); - } + if let Some(tag) = tagged_file + .primary_tag() + .or_else(|| tagged_file.first_tag()) + { + meta.title = tag + .title() + .map(|s: std::borrow::Cow<'_, str>| s.to_string()); + meta.artist = tag + .artist() + .map(|s: std::borrow::Cow<'_, str>| s.to_string()); + meta.album = tag + .album() + .map(|s: std::borrow::Cow<'_, str>| s.to_string()); + meta.genre = tag + .genre() + .map(|s: std::borrow::Cow<'_, str>| s.to_string()); + meta.year = tag.date().map(|ts| ts.year as i32); + } - let properties = tagged_file.properties(); - let duration = properties.duration(); - if !duration.is_zero() { - meta.duration_secs = Some(duration.as_secs_f64()); - } + let properties = tagged_file.properties(); + let duration = properties.duration(); + if !duration.is_zero() { + meta.duration_secs = Some(duration.as_secs_f64()); + } - if let Some(bitrate) = properties.audio_bitrate() { - meta.extra - .insert("audio_bitrate".to_string(), format!("{bitrate} kbps")); - } - if let Some(sample_rate) = properties.sample_rate() { - meta.extra - .insert("sample_rate".to_string(), format!("{sample_rate} Hz")); - } - if let Some(channels) = properties.channels() { - meta.extra - .insert("channels".to_string(), channels.to_string()); - } + if let Some(bitrate) = properties.audio_bitrate() { + meta + .extra + .insert("audio_bitrate".to_string(), format!("{bitrate} kbps")); + } + if let Some(sample_rate) = properties.sample_rate() { + meta + .extra + .insert("sample_rate".to_string(), format!("{sample_rate} Hz")); + } + if let Some(channels) = properties.channels() { + meta + .extra + .insert("channels".to_string(), channels.to_string()); + } - Ok(meta) + Ok(meta) } diff --git a/crates/pinakes-core/src/model.rs b/crates/pinakes-core/src/model.rs index 628b7cf..68570d6 100644 --- a/crates/pinakes-core/src/model.rs +++ b/crates/pinakes-core/src/model.rs @@ -1,6 +1,4 @@ -use std::collections::HashMap; -use std::fmt; -use std::path::PathBuf; +use std::{collections::HashMap, fmt, path::PathBuf}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -12,482 +10,487 @@ use crate::media_type::MediaType; pub struct MediaId(pub Uuid); impl MediaId { - pub fn new() -> Self { - Self(Uuid::now_v7()) - } + pub fn new() -> Self { + Self(Uuid::now_v7()) + } } impl fmt::Display for MediaId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } } impl Default for MediaId { - fn default() -> Self { - Self::new() - } + fn default() -> Self { + Self::new() + } } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct ContentHash(pub String); impl ContentHash { - pub fn new(hex: String) -> Self { - Self(hex) - } + pub fn new(hex: String) -> Self { + Self(hex) + } } impl fmt::Display for ContentHash { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } } // ===== Managed Storage Types ===== /// Storage mode for media items -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, +)] #[serde(rename_all = "lowercase")] pub enum StorageMode { - /// File exists on disk, referenced by path - #[default] - External, - /// File is stored in managed content-addressable storage - Managed, + /// File exists on disk, referenced by path + #[default] + External, + /// File is stored in managed content-addressable storage + Managed, } impl fmt::Display for StorageMode { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::External => write!(f, "external"), - Self::Managed => write!(f, "managed"), - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::External => write!(f, "external"), + Self::Managed => write!(f, "managed"), } + } } impl std::str::FromStr for StorageMode { - type Err = String; + type Err = String; - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "external" => Ok(Self::External), - "managed" => Ok(Self::Managed), - _ => Err(format!("unknown storage mode: {}", s)), - } + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "external" => Ok(Self::External), + "managed" => Ok(Self::Managed), + _ => Err(format!("unknown storage mode: {}", s)), } + } } /// A blob stored in managed storage (content-addressable) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ManagedBlob { - pub content_hash: ContentHash, - pub file_size: u64, - pub mime_type: String, - pub reference_count: u32, - pub stored_at: DateTime, - pub last_verified: Option>, + pub content_hash: ContentHash, + pub file_size: u64, + pub mime_type: String, + pub reference_count: u32, + pub stored_at: DateTime, + pub last_verified: Option>, } /// Result of uploading a file to managed storage #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UploadResult { - pub media_id: MediaId, - pub content_hash: ContentHash, - pub was_duplicate: bool, - pub file_size: u64, + pub media_id: MediaId, + pub content_hash: ContentHash, + pub was_duplicate: bool, + pub file_size: u64, } /// Statistics about managed storage #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ManagedStorageStats { - pub total_blobs: u64, - pub total_size_bytes: u64, - pub unique_size_bytes: u64, - pub deduplication_ratio: f64, - pub managed_media_count: u64, - pub orphaned_blobs: u64, + pub total_blobs: u64, + pub total_size_bytes: u64, + pub unique_size_bytes: u64, + pub deduplication_ratio: f64, + pub managed_media_count: u64, + pub orphaned_blobs: u64, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MediaItem { - pub id: MediaId, - pub path: PathBuf, - pub file_name: String, - pub media_type: MediaType, - pub content_hash: ContentHash, - pub file_size: u64, - pub title: Option, - pub artist: Option, - pub album: Option, - pub genre: Option, - pub year: Option, - pub duration_secs: Option, - pub description: Option, - pub thumbnail_path: Option, - pub custom_fields: HashMap, - /// File modification time (Unix timestamp in seconds), used for incremental scanning - pub file_mtime: Option, + pub id: MediaId, + pub path: PathBuf, + pub file_name: String, + pub media_type: MediaType, + pub content_hash: ContentHash, + pub file_size: u64, + pub title: Option, + pub artist: Option, + pub album: Option, + pub genre: Option, + pub year: Option, + pub duration_secs: Option, + pub description: Option, + pub thumbnail_path: Option, + pub custom_fields: HashMap, + /// File modification time (Unix timestamp in seconds), used for incremental + /// scanning + pub file_mtime: Option, - // Photo-specific metadata - pub date_taken: Option>, - pub latitude: Option, - pub longitude: Option, - pub camera_make: Option, - pub camera_model: Option, - pub rating: Option, - pub perceptual_hash: Option, + // Photo-specific metadata + pub date_taken: Option>, + pub latitude: Option, + pub longitude: Option, + pub camera_make: Option, + pub camera_model: Option, + pub rating: Option, + pub perceptual_hash: Option, - // Managed storage fields - /// How the file is stored (external on disk or managed in content-addressable storage) - #[serde(default)] - pub storage_mode: StorageMode, - /// Original filename for uploaded files (preserved separately from file_name) - pub original_filename: Option, - /// When the file was uploaded to managed storage - pub uploaded_at: Option>, - /// Storage key for looking up the blob (usually same as content_hash) - pub storage_key: Option, + // Managed storage fields + /// How the file is stored (external on disk or managed in + /// content-addressable storage) + #[serde(default)] + pub storage_mode: StorageMode, + /// Original filename for uploaded files (preserved separately from + /// file_name) + pub original_filename: Option, + /// When the file was uploaded to managed storage + pub uploaded_at: Option>, + /// Storage key for looking up the blob (usually same as content_hash) + pub storage_key: Option, - pub created_at: DateTime, - pub updated_at: DateTime, + pub created_at: DateTime, + pub updated_at: DateTime, - /// Soft delete timestamp. If set, the item is in the trash. - pub deleted_at: Option>, + /// Soft delete timestamp. If set, the item is in the trash. + pub deleted_at: Option>, - /// When markdown links were last extracted from this file. - pub links_extracted_at: Option>, + /// When markdown links were last extracted from this file. + pub links_extracted_at: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CustomField { - pub field_type: CustomFieldType, - pub value: String, + pub field_type: CustomFieldType, + pub value: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum CustomFieldType { - Text, - Number, - Date, - Boolean, + Text, + Number, + Date, + Boolean, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Tag { - pub id: Uuid, - pub name: String, - pub parent_id: Option, - pub created_at: DateTime, + pub id: Uuid, + pub name: String, + pub parent_id: Option, + pub created_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Collection { - pub id: Uuid, - pub name: String, - pub description: Option, - pub kind: CollectionKind, - pub filter_query: Option, - pub created_at: DateTime, - pub updated_at: DateTime, + pub id: Uuid, + pub name: String, + pub description: Option, + pub kind: CollectionKind, + pub filter_query: Option, + pub created_at: DateTime, + pub updated_at: DateTime, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum CollectionKind { - Manual, - Virtual, + Manual, + Virtual, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CollectionMember { - pub collection_id: Uuid, - pub media_id: MediaId, - pub position: i32, - pub added_at: DateTime, + pub collection_id: Uuid, + pub media_id: MediaId, + pub position: i32, + pub added_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuditEntry { - pub id: Uuid, - pub media_id: Option, - pub action: AuditAction, - pub details: Option, - pub timestamp: DateTime, + pub id: Uuid, + pub media_id: Option, + pub action: AuditAction, + pub details: Option, + pub timestamp: DateTime, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AuditAction { - // Media actions - Imported, - Updated, - Deleted, - Tagged, - Untagged, - AddedToCollection, - RemovedFromCollection, - Opened, - Scanned, + // Media actions + Imported, + Updated, + Deleted, + Tagged, + Untagged, + AddedToCollection, + RemovedFromCollection, + Opened, + Scanned, - // Authentication actions - LoginSuccess, - LoginFailed, - Logout, - SessionExpired, + // Authentication actions + LoginSuccess, + LoginFailed, + Logout, + SessionExpired, - // Authorization actions - PermissionDenied, - RoleChanged, - LibraryAccessGranted, - LibraryAccessRevoked, + // Authorization actions + PermissionDenied, + RoleChanged, + LibraryAccessGranted, + LibraryAccessRevoked, - // User management - UserCreated, - UserUpdated, - UserDeleted, + // User management + UserCreated, + UserUpdated, + UserDeleted, - // Plugin actions - PluginInstalled, - PluginUninstalled, - PluginEnabled, - PluginDisabled, + // Plugin actions + PluginInstalled, + PluginUninstalled, + PluginEnabled, + PluginDisabled, - // Configuration actions - ConfigChanged, - RootDirectoryAdded, - RootDirectoryRemoved, + // Configuration actions + ConfigChanged, + RootDirectoryAdded, + RootDirectoryRemoved, - // Social/Sharing actions - ShareLinkCreated, - ShareLinkAccessed, + // Social/Sharing actions + ShareLinkCreated, + ShareLinkAccessed, - // System actions - DatabaseVacuumed, - DatabaseCleared, - ExportCompleted, - IntegrityCheckCompleted, + // System actions + DatabaseVacuumed, + DatabaseCleared, + ExportCompleted, + IntegrityCheckCompleted, } impl fmt::Display for AuditAction { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let s = match self { - // Media actions - Self::Imported => "imported", - Self::Updated => "updated", - Self::Deleted => "deleted", - Self::Tagged => "tagged", - Self::Untagged => "untagged", - Self::AddedToCollection => "added_to_collection", - Self::RemovedFromCollection => "removed_from_collection", - Self::Opened => "opened", - Self::Scanned => "scanned", + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + // Media actions + Self::Imported => "imported", + Self::Updated => "updated", + Self::Deleted => "deleted", + Self::Tagged => "tagged", + Self::Untagged => "untagged", + Self::AddedToCollection => "added_to_collection", + Self::RemovedFromCollection => "removed_from_collection", + Self::Opened => "opened", + Self::Scanned => "scanned", - // Authentication actions - Self::LoginSuccess => "login_success", - Self::LoginFailed => "login_failed", - Self::Logout => "logout", - Self::SessionExpired => "session_expired", + // Authentication actions + Self::LoginSuccess => "login_success", + Self::LoginFailed => "login_failed", + Self::Logout => "logout", + Self::SessionExpired => "session_expired", - // Authorization actions - Self::PermissionDenied => "permission_denied", - Self::RoleChanged => "role_changed", - Self::LibraryAccessGranted => "library_access_granted", - Self::LibraryAccessRevoked => "library_access_revoked", + // Authorization actions + Self::PermissionDenied => "permission_denied", + Self::RoleChanged => "role_changed", + Self::LibraryAccessGranted => "library_access_granted", + Self::LibraryAccessRevoked => "library_access_revoked", - // User management - Self::UserCreated => "user_created", - Self::UserUpdated => "user_updated", - Self::UserDeleted => "user_deleted", + // User management + Self::UserCreated => "user_created", + Self::UserUpdated => "user_updated", + Self::UserDeleted => "user_deleted", - // Plugin actions - Self::PluginInstalled => "plugin_installed", - Self::PluginUninstalled => "plugin_uninstalled", - Self::PluginEnabled => "plugin_enabled", - Self::PluginDisabled => "plugin_disabled", + // Plugin actions + Self::PluginInstalled => "plugin_installed", + Self::PluginUninstalled => "plugin_uninstalled", + Self::PluginEnabled => "plugin_enabled", + Self::PluginDisabled => "plugin_disabled", - // Configuration actions - Self::ConfigChanged => "config_changed", - Self::RootDirectoryAdded => "root_directory_added", - Self::RootDirectoryRemoved => "root_directory_removed", + // Configuration actions + Self::ConfigChanged => "config_changed", + Self::RootDirectoryAdded => "root_directory_added", + Self::RootDirectoryRemoved => "root_directory_removed", - // Social/Sharing actions - Self::ShareLinkCreated => "share_link_created", - Self::ShareLinkAccessed => "share_link_accessed", + // Social/Sharing actions + Self::ShareLinkCreated => "share_link_created", + Self::ShareLinkAccessed => "share_link_accessed", - // System actions - Self::DatabaseVacuumed => "database_vacuumed", - Self::DatabaseCleared => "database_cleared", - Self::ExportCompleted => "export_completed", - Self::IntegrityCheckCompleted => "integrity_check_completed", - }; - write!(f, "{s}") - } + // System actions + Self::DatabaseVacuumed => "database_vacuumed", + Self::DatabaseCleared => "database_cleared", + Self::ExportCompleted => "export_completed", + Self::IntegrityCheckCompleted => "integrity_check_completed", + }; + write!(f, "{s}") + } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Pagination { - pub offset: u64, - pub limit: u64, - pub sort: Option, + pub offset: u64, + pub limit: u64, + pub sort: Option, } impl Pagination { - pub fn new(offset: u64, limit: u64, sort: Option) -> Self { - Self { - offset, - limit, - sort, - } + pub fn new(offset: u64, limit: u64, sort: Option) -> Self { + Self { + offset, + limit, + sort, } + } } impl Default for Pagination { - fn default() -> Self { - Self { - offset: 0, - limit: 50, - sort: None, - } + fn default() -> Self { + Self { + offset: 0, + limit: 50, + sort: None, } + } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SavedSearch { - pub id: Uuid, - pub name: String, - pub query: String, - pub sort_order: Option, - pub created_at: DateTime, + pub id: Uuid, + pub name: String, + pub query: String, + pub sort_order: Option, + pub created_at: DateTime, } // Book Management Types #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BookMetadata { - pub media_id: MediaId, - pub isbn: Option, - pub isbn13: Option, - pub publisher: Option, - pub language: Option, - pub page_count: Option, - pub publication_date: Option, - pub series_name: Option, - pub series_index: Option, - pub format: Option, - pub authors: Vec, - pub identifiers: HashMap>, - pub created_at: DateTime, - pub updated_at: DateTime, + pub media_id: MediaId, + pub isbn: Option, + pub isbn13: Option, + pub publisher: Option, + pub language: Option, + pub page_count: Option, + pub publication_date: Option, + pub series_name: Option, + pub series_index: Option, + pub format: Option, + pub authors: Vec, + pub identifiers: HashMap>, + pub created_at: DateTime, + pub updated_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct AuthorInfo { - pub name: String, - pub role: String, - pub file_as: Option, - pub position: i32, + pub name: String, + pub role: String, + pub file_as: Option, + pub position: i32, } impl AuthorInfo { - pub fn new(name: String) -> Self { - Self { - name, - role: "author".to_string(), - file_as: None, - position: 0, - } + pub fn new(name: String) -> Self { + Self { + name, + role: "author".to_string(), + file_as: None, + position: 0, } + } - pub fn with_role(mut self, role: String) -> Self { - self.role = role; - self - } + pub fn with_role(mut self, role: String) -> Self { + self.role = role; + self + } - pub fn with_file_as(mut self, file_as: String) -> Self { - self.file_as = Some(file_as); - self - } + pub fn with_file_as(mut self, file_as: String) -> Self { + self.file_as = Some(file_as); + self + } - pub fn with_position(mut self, position: i32) -> Self { - self.position = position; - self - } + pub fn with_position(mut self, position: i32) -> Self { + self.position = position; + self + } } /// Book metadata extracted from files (without database-specific fields) #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ExtractedBookMetadata { - pub isbn: Option, - pub isbn13: Option, - pub publisher: Option, - pub language: Option, - pub page_count: Option, - pub publication_date: Option, - pub series_name: Option, - pub series_index: Option, - pub format: Option, - pub authors: Vec, - pub identifiers: HashMap>, + pub isbn: Option, + pub isbn13: Option, + pub publisher: Option, + pub language: Option, + pub page_count: Option, + pub publication_date: Option, + pub series_name: Option, + pub series_index: Option, + pub format: Option, + pub authors: Vec, + pub identifiers: HashMap>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ReadingProgress { - pub media_id: MediaId, - pub user_id: Uuid, - pub current_page: i32, - pub total_pages: Option, - pub progress_percent: f64, - pub last_read_at: DateTime, + pub media_id: MediaId, + pub user_id: Uuid, + pub current_page: i32, + pub total_pages: Option, + pub progress_percent: f64, + pub last_read_at: DateTime, } impl ReadingProgress { - pub fn new( - media_id: MediaId, - user_id: Uuid, - current_page: i32, - total_pages: Option, - ) -> Self { - 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 - }; + pub fn new( + media_id: MediaId, + user_id: Uuid, + current_page: i32, + total_pages: Option, + ) -> Self { + 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 + }; - Self { - media_id, - user_id, - current_page, - total_pages, - progress_percent, - last_read_at: Utc::now(), - } + Self { + media_id, + user_id, + current_page, + total_pages, + progress_percent, + last_read_at: Utc::now(), } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ReadingStatus { - ToRead, - Reading, - Completed, - Abandoned, + ToRead, + Reading, + Completed, + Abandoned, } impl fmt::Display for ReadingStatus { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::ToRead => write!(f, "to_read"), - Self::Reading => write!(f, "reading"), - Self::Completed => write!(f, "completed"), - Self::Abandoned => write!(f, "abandoned"), - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ToRead => write!(f, "to_read"), + Self::Reading => write!(f, "reading"), + Self::Completed => write!(f, "completed"), + Self::Abandoned => write!(f, "abandoned"), } + } } // ===== Markdown Links (Obsidian-style) ===== @@ -496,93 +499,93 @@ impl fmt::Display for ReadingStatus { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum LinkType { - /// Wikilink: [[target]] or [[target|display]] - Wikilink, - /// Markdown link: [text](path) - MarkdownLink, - /// Embed: ![[target]] - Embed, + /// Wikilink: [[target]] or [[target|display]] + Wikilink, + /// Markdown link: [text](path) + MarkdownLink, + /// Embed: ![[target]] + Embed, } impl fmt::Display for LinkType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Wikilink => write!(f, "wikilink"), - Self::MarkdownLink => write!(f, "markdown_link"), - Self::Embed => write!(f, "embed"), - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Wikilink => write!(f, "wikilink"), + Self::MarkdownLink => write!(f, "markdown_link"), + Self::Embed => write!(f, "embed"), } + } } impl std::str::FromStr for LinkType { - type Err = String; + type Err = String; - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "wikilink" => Ok(Self::Wikilink), - "markdown_link" => Ok(Self::MarkdownLink), - "embed" => Ok(Self::Embed), - _ => Err(format!("unknown link type: {}", s)), - } + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "wikilink" => Ok(Self::Wikilink), + "markdown_link" => Ok(Self::MarkdownLink), + "embed" => Ok(Self::Embed), + _ => Err(format!("unknown link type: {}", s)), } + } } /// A markdown link extracted from a file #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MarkdownLink { - pub id: Uuid, - pub source_media_id: MediaId, - /// Raw link target as written in the source (wikilink name or path) - pub target_path: String, - /// Resolved target media_id (None if unresolved) - pub target_media_id: Option, - pub link_type: LinkType, - /// Display text for the link - pub link_text: Option, - /// Line number in source file (1-indexed) - pub line_number: Option, - /// Surrounding text for backlink preview - pub context: Option, - pub created_at: DateTime, + pub id: Uuid, + pub source_media_id: MediaId, + /// Raw link target as written in the source (wikilink name or path) + pub target_path: String, + /// Resolved target media_id (None if unresolved) + pub target_media_id: Option, + pub link_type: LinkType, + /// Display text for the link + pub link_text: Option, + /// Line number in source file (1-indexed) + pub line_number: Option, + /// Surrounding text for backlink preview + pub context: Option, + pub created_at: DateTime, } /// Information about a backlink (incoming link) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BacklinkInfo { - pub link_id: Uuid, - pub source_id: MediaId, - pub source_title: Option, - pub source_path: String, - pub link_text: Option, - pub line_number: Option, - pub context: Option, - pub link_type: LinkType, + pub link_id: Uuid, + pub source_id: MediaId, + pub source_title: Option, + pub source_path: String, + pub link_text: Option, + pub line_number: Option, + pub context: Option, + pub link_type: LinkType, } /// Graph data for visualization #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct GraphData { - pub nodes: Vec, - pub edges: Vec, + pub nodes: Vec, + pub edges: Vec, } /// A node in the graph visualization #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GraphNode { - pub id: String, - pub label: String, - pub title: Option, - pub media_type: String, - /// Number of outgoing links from this node - pub link_count: u32, - /// Number of incoming links to this node - pub backlink_count: u32, + pub id: String, + pub label: String, + pub title: Option, + pub media_type: String, + /// Number of outgoing links from this node + pub link_count: u32, + /// Number of incoming links to this node + pub backlink_count: u32, } /// An edge (link) in the graph visualization #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GraphEdge { - pub source: String, - pub target: String, - pub link_type: LinkType, + pub source: String, + pub target: String, + pub link_type: LinkType, } diff --git a/crates/pinakes-core/src/opener.rs b/crates/pinakes-core/src/opener.rs index 10df99c..7681a87 100644 --- a/crates/pinakes-core/src/opener.rs +++ b/crates/pinakes-core/src/opener.rs @@ -1,79 +1,78 @@ -use std::path::Path; -use std::process::Command; +use std::{path::Path, process::Command}; use crate::error::{PinakesError, Result}; pub trait Opener: Send + Sync { - fn open(&self, path: &Path) -> Result<()>; + fn open(&self, path: &Path) -> Result<()>; } /// Linux opener using xdg-open pub struct XdgOpener; impl Opener for XdgOpener { - fn open(&self, path: &Path) -> Result<()> { - let status = Command::new("xdg-open") - .arg(path) - .status() - .map_err(|e| PinakesError::InvalidOperation(format!("failed to run xdg-open: {e}")))?; - if status.success() { - Ok(()) - } else { - Err(PinakesError::InvalidOperation(format!( - "xdg-open exited with status {status}" - ))) - } + fn open(&self, path: &Path) -> Result<()> { + let status = Command::new("xdg-open").arg(path).status().map_err(|e| { + PinakesError::InvalidOperation(format!("failed to run xdg-open: {e}")) + })?; + if status.success() { + Ok(()) + } else { + Err(PinakesError::InvalidOperation(format!( + "xdg-open exited with status {status}" + ))) } + } } /// macOS opener using the `open` command pub struct MacOpener; impl Opener for MacOpener { - fn open(&self, path: &Path) -> Result<()> { - let status = Command::new("open") - .arg(path) - .status() - .map_err(|e| PinakesError::InvalidOperation(format!("failed to run open: {e}")))?; - if status.success() { - Ok(()) - } else { - Err(PinakesError::InvalidOperation(format!( - "open exited with status {status}" - ))) - } + fn open(&self, path: &Path) -> Result<()> { + let status = Command::new("open").arg(path).status().map_err(|e| { + PinakesError::InvalidOperation(format!("failed to run open: {e}")) + })?; + if status.success() { + Ok(()) + } else { + Err(PinakesError::InvalidOperation(format!( + "open exited with status {status}" + ))) } + } } /// Windows opener using `cmd /c start` pub struct WindowsOpener; impl Opener for WindowsOpener { - fn open(&self, path: &Path) -> Result<()> { - let status = Command::new("cmd") - .args(["/C", "start", ""]) - .arg(path) - .status() - .map_err(|e| { - PinakesError::InvalidOperation(format!("failed to run cmd /c start: {e}")) - })?; - if status.success() { - Ok(()) - } else { - Err(PinakesError::InvalidOperation(format!( - "cmd /c start exited with status {status}" - ))) - } + fn open(&self, path: &Path) -> Result<()> { + let status = Command::new("cmd") + .args(["/C", "start", ""]) + .arg(path) + .status() + .map_err(|e| { + PinakesError::InvalidOperation(format!( + "failed to run cmd /c start: {e}" + )) + })?; + if status.success() { + Ok(()) + } else { + Err(PinakesError::InvalidOperation(format!( + "cmd /c start exited with status {status}" + ))) } + } } /// Returns the platform-appropriate opener. pub fn default_opener() -> Box { - if cfg!(target_os = "macos") { - Box::new(MacOpener) - } else if cfg!(target_os = "windows") { - Box::new(WindowsOpener) - } else { - Box::new(XdgOpener) - } + if cfg!(target_os = "macos") { + Box::new(MacOpener) + } else if cfg!(target_os = "windows") { + Box::new(WindowsOpener) + } else { + Box::new(XdgOpener) + } } diff --git a/crates/pinakes-core/src/path_validation.rs b/crates/pinakes-core/src/path_validation.rs index 3fd8fa2..3a89b3b 100644 --- a/crates/pinakes-core/src/path_validation.rs +++ b/crates/pinakes-core/src/path_validation.rs @@ -37,72 +37,81 @@ use crate::error::{PinakesError, Result}; /// /// ```no_run /// use std::path::PathBuf; +/// /// use pinakes_core::path_validation::validate_path; /// -/// let allowed_roots = vec![PathBuf::from("/media"), PathBuf::from("/home/user/documents")]; +/// let allowed_roots = vec![ +/// PathBuf::from("/media"), +/// PathBuf::from("/home/user/documents"), +/// ]; /// let path = PathBuf::from("/media/music/song.mp3"); /// /// let validated = validate_path(&path, &allowed_roots).unwrap(); /// ``` -pub fn validate_path(path: &Path, allowed_roots: &[PathBuf]) -> Result { - // Handle the case where no roots are configured - if allowed_roots.is_empty() { - return Err(PinakesError::PathNotAllowed( - "no allowed roots configured".to_string(), - )); - } +pub fn validate_path( + path: &Path, + allowed_roots: &[PathBuf], +) -> Result { + // Handle the case where no roots are configured + if allowed_roots.is_empty() { + return Err(PinakesError::PathNotAllowed( + "no allowed roots configured".to_string(), + )); + } - // First check if the path exists - if !path.exists() { - return Err(PinakesError::PathNotAllowed(format!( - "path does not exist: {}", - path.display() - ))); - } + // First check if the path exists + if !path.exists() { + return Err(PinakesError::PathNotAllowed(format!( + "path does not exist: {}", + path.display() + ))); + } - // Canonicalize to resolve symlinks and relative components - let canonical = path.canonicalize().map_err(|e| { - PinakesError::PathNotAllowed(format!( - "failed to canonicalize path {}: {}", - path.display(), - e - )) - })?; + // Canonicalize to resolve symlinks and relative components + let canonical = path.canonicalize().map_err(|e| { + PinakesError::PathNotAllowed(format!( + "failed to canonicalize path {}: {}", + path.display(), + e + )) + })?; - // Check if the canonical path is within any allowed root - let canonical_roots: Vec = allowed_roots - .iter() - .filter_map(|root| root.canonicalize().ok()) - .collect(); + // Check if the canonical path is within any allowed root + let canonical_roots: Vec = allowed_roots + .iter() + .filter_map(|root| root.canonicalize().ok()) + .collect(); - if canonical_roots.is_empty() { - return Err(PinakesError::PathNotAllowed( - "no accessible allowed roots".to_string(), - )); - } + if canonical_roots.is_empty() { + return Err(PinakesError::PathNotAllowed( + "no accessible allowed roots".to_string(), + )); + } - let is_allowed = canonical_roots - .iter() - .any(|root| canonical.starts_with(root)); + let is_allowed = canonical_roots + .iter() + .any(|root| canonical.starts_with(root)); - if is_allowed { - Ok(canonical) - } else { - Err(PinakesError::PathNotAllowed(format!( - "path {} is outside allowed roots", - path.display() - ))) - } + if is_allowed { + Ok(canonical) + } else { + Err(PinakesError::PathNotAllowed(format!( + "path {} is outside allowed roots", + path.display() + ))) + } } /// Validates a path relative to a single root directory. /// -/// This is a convenience wrapper for `validate_path` when you only have one root. +/// This is a convenience wrapper for `validate_path` when you only have one +/// root. pub fn validate_path_single_root(path: &Path, root: &Path) -> Result { - validate_path(path, &[root.to_path_buf()]) + validate_path(path, &[root.to_path_buf()]) } -/// Checks if a path appears to contain traversal sequences without canonicalizing. +/// Checks if a path appears to contain traversal sequences without +/// canonicalizing. /// /// This is a quick pre-check that can reject obviously malicious paths without /// hitting the filesystem. It should be used in addition to `validate_path`, @@ -117,11 +126,11 @@ pub fn validate_path_single_root(path: &Path, root: &Path) -> Result { /// `true` if the path appears safe (no obvious traversal sequences), /// `false` if it contains suspicious patterns. pub fn path_looks_safe(path: &str) -> bool { - // Reject paths with obvious traversal patterns - !path.contains("..") - && !path.contains("//") - && !path.starts_with('/') - && path.chars().filter(|c| *c == '/').count() < 50 // Reasonable depth limit + // Reject paths with obvious traversal patterns + !path.contains("..") + && !path.contains("//") + && !path.starts_with('/') + && path.chars().filter(|c| *c == '/').count() < 50 // Reasonable depth limit } /// Sanitizes a filename by removing or replacing dangerous characters. @@ -140,26 +149,27 @@ pub fn path_looks_safe(path: &str) -> bool { /// /// A sanitized filename safe for use on most filesystems. pub fn sanitize_filename(filename: &str) -> String { - let sanitized: String = filename - .chars() - .filter(|c| { - // Allow alphanumeric, common punctuation, and unicode letters - c.is_alphanumeric() || matches!(*c, '-' | '_' | '.' | ' ' | '(' | ')' | '[' | ']') - }) - .collect(); + let sanitized: String = filename + .chars() + .filter(|c| { + // Allow alphanumeric, common punctuation, and unicode letters + c.is_alphanumeric() + || matches!(*c, '-' | '_' | '.' | ' ' | '(' | ')' | '[' | ']') + }) + .collect(); - // Remove leading dots to prevent hidden files - let sanitized = sanitized.trim_start_matches('.'); + // Remove leading dots to prevent hidden files + let sanitized = sanitized.trim_start_matches('.'); - // Remove leading/trailing whitespace - let sanitized = sanitized.trim(); + // Remove leading/trailing whitespace + let sanitized = sanitized.trim(); - // Ensure the filename isn't empty after sanitization - if sanitized.is_empty() { - "unnamed".to_string() - } else { - sanitized.to_string() - } + // Ensure the filename isn't empty after sanitization + if sanitized.is_empty() { + "unnamed".to_string() + } else { + sanitized.to_string() + } } /// Joins a base path with a relative path safely. @@ -174,137 +184,140 @@ pub fn sanitize_filename(filename: &str) -> String { /// /// # Returns /// -/// The joined path if safe, or an error if the relative path would escape the base. +/// The joined path if safe, or an error if the relative path would escape the +/// base. pub fn safe_join(base: &Path, relative: &str) -> Result { - // Reject absolute paths in the relative component - if relative.starts_with('/') || relative.starts_with('\\') { + // Reject absolute paths in the relative component + if relative.starts_with('/') || relative.starts_with('\\') { + return Err(PinakesError::PathNotAllowed( + "relative path cannot be absolute".to_string(), + )); + } + + // Reject paths with .. traversal + if relative.contains("..") { + return Err(PinakesError::PathNotAllowed( + "relative path cannot contain '..'".to_string(), + )); + } + + // Build the path and validate it stays within base + let joined = base.join(relative); + + // Canonicalize base for comparison + let canonical_base = base.canonicalize().map_err(|e| { + PinakesError::PathNotAllowed(format!( + "failed to canonicalize base {}: {}", + base.display(), + e + )) + })?; + + // The joined path might not exist yet, so we can't canonicalize it directly. + // Instead, we check each component + let mut current = canonical_base.clone(); + for component in Path::new(relative).components() { + use std::path::Component; + match component { + Component::Normal(name) => { + current = current.join(name); + }, + Component::ParentDir => { return Err(PinakesError::PathNotAllowed( - "relative path cannot be absolute".to_string(), + "path traversal detected".to_string(), )); - } - - // Reject paths with .. traversal - if relative.contains("..") { + }, + Component::CurDir => continue, + _ => { return Err(PinakesError::PathNotAllowed( - "relative path cannot contain '..'".to_string(), + "invalid path component".to_string(), )); + }, } + } - // Build the path and validate it stays within base - let joined = base.join(relative); - - // Canonicalize base for comparison - let canonical_base = base.canonicalize().map_err(|e| { - PinakesError::PathNotAllowed(format!( - "failed to canonicalize base {}: {}", - base.display(), - e - )) - })?; - - // The joined path might not exist yet, so we can't canonicalize it directly. - // Instead, we check each component - let mut current = canonical_base.clone(); - for component in Path::new(relative).components() { - use std::path::Component; - match component { - Component::Normal(name) => { - current = current.join(name); - } - Component::ParentDir => { - return Err(PinakesError::PathNotAllowed( - "path traversal detected".to_string(), - )); - } - Component::CurDir => continue, - _ => { - return Err(PinakesError::PathNotAllowed( - "invalid path component".to_string(), - )); - } - } - } - - Ok(joined) + Ok(joined) } #[cfg(test)] mod tests { - use super::*; - use std::fs; - use tempfile::TempDir; + use std::fs; - fn setup_test_dirs() -> TempDir { - let temp = TempDir::new().unwrap(); - fs::create_dir_all(temp.path().join("allowed")).unwrap(); - fs::create_dir_all(temp.path().join("forbidden")).unwrap(); - fs::write(temp.path().join("allowed/file.txt"), "test").unwrap(); - fs::write(temp.path().join("forbidden/secret.txt"), "secret").unwrap(); - temp - } + use tempfile::TempDir; - #[test] - fn test_validate_path_allowed() { - let temp = setup_test_dirs(); - let allowed_roots = vec![temp.path().join("allowed")]; - let path = temp.path().join("allowed/file.txt"); + use super::*; - let result = validate_path(&path, &allowed_roots); - assert!(result.is_ok()); - } + fn setup_test_dirs() -> TempDir { + let temp = TempDir::new().unwrap(); + fs::create_dir_all(temp.path().join("allowed")).unwrap(); + fs::create_dir_all(temp.path().join("forbidden")).unwrap(); + fs::write(temp.path().join("allowed/file.txt"), "test").unwrap(); + fs::write(temp.path().join("forbidden/secret.txt"), "secret").unwrap(); + temp + } - #[test] - fn test_validate_path_forbidden() { - let temp = setup_test_dirs(); - let allowed_roots = vec![temp.path().join("allowed")]; - let path = temp.path().join("forbidden/secret.txt"); + #[test] + fn test_validate_path_allowed() { + let temp = setup_test_dirs(); + let allowed_roots = vec![temp.path().join("allowed")]; + let path = temp.path().join("allowed/file.txt"); - let result = validate_path(&path, &allowed_roots); - assert!(result.is_err()); - } + let result = validate_path(&path, &allowed_roots); + assert!(result.is_ok()); + } - #[test] - fn test_validate_path_traversal() { - let temp = setup_test_dirs(); - let allowed_roots = vec![temp.path().join("allowed")]; - let path = temp.path().join("allowed/../forbidden/secret.txt"); + #[test] + fn test_validate_path_forbidden() { + let temp = setup_test_dirs(); + let allowed_roots = vec![temp.path().join("allowed")]; + let path = temp.path().join("forbidden/secret.txt"); - let result = validate_path(&path, &allowed_roots); - assert!(result.is_err()); - } + let result = validate_path(&path, &allowed_roots); + assert!(result.is_err()); + } - #[test] - fn test_sanitize_filename() { - assert_eq!(sanitize_filename("normal.txt"), "normal.txt"); - assert_eq!(sanitize_filename("../../../etc/passwd"), "etcpasswd"); - assert_eq!(sanitize_filename(".hidden"), "hidden"); - assert_eq!(sanitize_filename("filebad:chars"), "filewithbadchars"); - assert_eq!(sanitize_filename(""), "unnamed"); - assert_eq!(sanitize_filename("..."), "unnamed"); - } + #[test] + fn test_validate_path_traversal() { + let temp = setup_test_dirs(); + let allowed_roots = vec![temp.path().join("allowed")]; + let path = temp.path().join("allowed/../forbidden/secret.txt"); - #[test] - fn test_path_looks_safe() { - assert!(path_looks_safe("normal/path/file.txt")); - assert!(!path_looks_safe("../../../etc/passwd")); - assert!(!path_looks_safe("path//double/slash")); - } + let result = validate_path(&path, &allowed_roots); + assert!(result.is_err()); + } - #[test] - fn test_safe_join() { - let temp = TempDir::new().unwrap(); - let base = temp.path(); + #[test] + fn test_sanitize_filename() { + assert_eq!(sanitize_filename("normal.txt"), "normal.txt"); + assert_eq!(sanitize_filename("../../../etc/passwd"), "etcpasswd"); + assert_eq!(sanitize_filename(".hidden"), "hidden"); + assert_eq!(sanitize_filename("filebad:chars"), "filewithbadchars"); + assert_eq!(sanitize_filename(""), "unnamed"); + assert_eq!(sanitize_filename("..."), "unnamed"); + } - // Valid join - let result = safe_join(base, "subdir/file.txt"); - assert!(result.is_ok()); + #[test] + fn test_path_looks_safe() { + assert!(path_looks_safe("normal/path/file.txt")); + assert!(!path_looks_safe("../../../etc/passwd")); + assert!(!path_looks_safe("path//double/slash")); + } - // Traversal attempt - let result = safe_join(base, "../etc/passwd"); - assert!(result.is_err()); + #[test] + fn test_safe_join() { + let temp = TempDir::new().unwrap(); + let base = temp.path(); - // Absolute path attempt - let result = safe_join(base, "/etc/passwd"); - assert!(result.is_err()); - } + // Valid join + let result = safe_join(base, "subdir/file.txt"); + assert!(result.is_ok()); + + // Traversal attempt + let result = safe_join(base, "../etc/passwd"); + assert!(result.is_err()); + + // Absolute path attempt + let result = safe_join(base, "/etc/passwd"); + assert!(result.is_err()); + } } diff --git a/crates/pinakes-core/src/playlists.rs b/crates/pinakes-core/src/playlists.rs index b45c4be..7be2a75 100644 --- a/crates/pinakes-core/src/playlists.rs +++ b/crates/pinakes-core/src/playlists.rs @@ -4,28 +4,27 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::model::MediaId; -use crate::users::UserId; +use crate::{model::MediaId, users::UserId}; /// A user-owned playlist of media items. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Playlist { - pub id: Uuid, - pub owner_id: UserId, - pub name: String, - pub description: Option, - pub is_public: bool, - pub is_smart: bool, - pub filter_query: Option, - pub created_at: DateTime, - pub updated_at: DateTime, + pub id: Uuid, + pub owner_id: UserId, + pub name: String, + pub description: Option, + pub is_public: bool, + pub is_smart: bool, + pub filter_query: Option, + pub created_at: DateTime, + pub updated_at: DateTime, } /// An item within a playlist at a specific position. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PlaylistItem { - pub playlist_id: Uuid, - pub media_id: MediaId, - pub position: i32, - pub added_at: DateTime, + pub playlist_id: Uuid, + pub media_id: MediaId, + pub position: i32, + pub added_at: DateTime, } diff --git a/crates/pinakes-core/src/plugin/loader.rs b/crates/pinakes-core/src/plugin/loader.rs index 0c80f18..3aa509e 100644 --- a/crates/pinakes-core/src/plugin/loader.rs +++ b/crates/pinakes-core/src/plugin/loader.rs @@ -1,334 +1,345 @@ //! Plugin loader for discovering and loading plugins from the filesystem +use std::path::{Path, PathBuf}; + use anyhow::{Result, anyhow}; use pinakes_plugin_api::PluginManifest; -use std::path::{Path, PathBuf}; use tracing::{debug, info, warn}; use walkdir::WalkDir; /// Plugin loader handles discovery and loading of plugins from directories pub struct PluginLoader { - /// Directories to search for plugins - plugin_dirs: Vec, + /// Directories to search for plugins + plugin_dirs: Vec, } impl PluginLoader { - /// Create a new plugin loader - pub fn new(plugin_dirs: Vec) -> Self { - Self { plugin_dirs } + /// Create a new plugin loader + pub fn new(plugin_dirs: Vec) -> Self { + Self { plugin_dirs } + } + + /// Discover all plugins in configured directories + pub async fn discover_plugins(&self) -> Result> { + let mut manifests = Vec::new(); + + for dir in &self.plugin_dirs { + if !dir.exists() { + warn!("Plugin directory does not exist: {:?}", dir); + continue; + } + + info!("Discovering plugins in: {:?}", dir); + + match self.discover_in_directory(dir).await { + Ok(found) => { + info!("Found {} plugins in {:?}", found.len(), dir); + manifests.extend(found); + }, + Err(e) => { + warn!("Error discovering plugins in {:?}: {}", dir, e); + }, + } } - /// Discover all plugins in configured directories - pub async fn discover_plugins(&self) -> Result> { - let mut manifests = Vec::new(); + Ok(manifests) + } - for dir in &self.plugin_dirs { - if !dir.exists() { - warn!("Plugin directory does not exist: {:?}", dir); - continue; - } + /// Discover plugins in a specific directory + async fn discover_in_directory( + &self, + dir: &Path, + ) -> Result> { + let mut manifests = Vec::new(); - info!("Discovering plugins in: {:?}", dir); - - match self.discover_in_directory(dir).await { - Ok(found) => { - info!("Found {} plugins in {:?}", found.len(), dir); - manifests.extend(found); - } - Err(e) => { - warn!("Error discovering plugins in {:?}: {}", dir, e); - } - } - } - - Ok(manifests) - } - - /// Discover plugins in a specific directory - async fn discover_in_directory(&self, dir: &Path) -> Result> { - let mut manifests = Vec::new(); - - // Walk the directory looking for plugin.toml files - for entry in WalkDir::new(dir) + // Walk the directory looking for plugin.toml files + for entry in WalkDir::new(dir) .max_depth(3) // Don't go too deep .follow_links(false) - { - let entry = match entry { - Ok(e) => e, - Err(e) => { - warn!("Error reading directory entry: {}", e); - continue; - } - }; + { + let entry = match entry { + Ok(e) => e, + Err(e) => { + warn!("Error reading directory entry: {}", e); + continue; + }, + }; - let path = entry.path(); + let path = entry.path(); - // Look for plugin.toml files - if path.file_name() == Some(std::ffi::OsStr::new("plugin.toml")) { - debug!("Found plugin manifest: {:?}", path); + // Look for plugin.toml files + if path.file_name() == Some(std::ffi::OsStr::new("plugin.toml")) { + debug!("Found plugin manifest: {:?}", path); - match PluginManifest::from_file(path) { - Ok(manifest) => { - info!("Loaded manifest for plugin: {}", manifest.plugin.name); - manifests.push(manifest); - } - Err(e) => { - warn!("Failed to load manifest from {:?}: {}", path, e); - } - } - } + match PluginManifest::from_file(path) { + Ok(manifest) => { + info!("Loaded manifest for plugin: {}", manifest.plugin.name); + manifests.push(manifest); + }, + Err(e) => { + warn!("Failed to load manifest from {:?}: {}", path, e); + }, } - - Ok(manifests) + } } - /// Resolve the WASM binary path from a manifest - pub fn resolve_wasm_path(&self, manifest: &PluginManifest) -> Result { - // The WASM path in the manifest is relative to the manifest file - // We need to search for it in the plugin directories + Ok(manifests) + } - for dir in &self.plugin_dirs { - // Look for a directory matching the plugin name - let plugin_dir = dir.join(&manifest.plugin.name); - if !plugin_dir.exists() { - continue; - } + /// Resolve the WASM binary path from a manifest + pub fn resolve_wasm_path( + &self, + manifest: &PluginManifest, + ) -> Result { + // The WASM path in the manifest is relative to the manifest file + // We need to search for it in the plugin directories - // Check for plugin.toml in this directory - let manifest_path = plugin_dir.join("plugin.toml"); - if !manifest_path.exists() { - continue; - } + for dir in &self.plugin_dirs { + // Look for a directory matching the plugin name + let plugin_dir = dir.join(&manifest.plugin.name); + if !plugin_dir.exists() { + continue; + } - // Resolve WASM path relative to this directory - let wasm_path = plugin_dir.join(&manifest.plugin.binary.wasm); - if wasm_path.exists() { - // Verify the resolved path is within the plugin directory (prevent path traversal) - let canonical_wasm = wasm_path - .canonicalize() - .map_err(|e| anyhow!("Failed to canonicalize WASM path: {}", e))?; - let canonical_plugin_dir = plugin_dir - .canonicalize() - .map_err(|e| anyhow!("Failed to canonicalize plugin dir: {}", e))?; - if !canonical_wasm.starts_with(&canonical_plugin_dir) { - return Err(anyhow!( - "WASM binary path escapes plugin directory: {:?}", - wasm_path - )); - } - return Ok(canonical_wasm); - } + // Check for plugin.toml in this directory + let manifest_path = plugin_dir.join("plugin.toml"); + if !manifest_path.exists() { + continue; + } + + // Resolve WASM path relative to this directory + let wasm_path = plugin_dir.join(&manifest.plugin.binary.wasm); + if wasm_path.exists() { + // Verify the resolved path is within the plugin directory (prevent path + // traversal) + let canonical_wasm = wasm_path + .canonicalize() + .map_err(|e| anyhow!("Failed to canonicalize WASM path: {}", e))?; + let canonical_plugin_dir = plugin_dir + .canonicalize() + .map_err(|e| anyhow!("Failed to canonicalize plugin dir: {}", e))?; + if !canonical_wasm.starts_with(&canonical_plugin_dir) { + return Err(anyhow!( + "WASM binary path escapes plugin directory: {:?}", + wasm_path + )); } - - Err(anyhow!( - "WASM binary not found for plugin: {}", - manifest.plugin.name - )) + return Ok(canonical_wasm); + } } - /// Download a plugin from a URL - pub async fn download_plugin(&self, url: &str) -> Result { - // Only allow HTTPS downloads - if !url.starts_with("https://") { - return Err(anyhow!( - "Only HTTPS URLs are allowed for plugin downloads: {}", - url - )); - } + Err(anyhow!( + "WASM binary not found for plugin: {}", + manifest.plugin.name + )) + } - let dest_dir = self - .plugin_dirs - .first() - .ok_or_else(|| anyhow!("No plugin directories configured"))?; - - std::fs::create_dir_all(dest_dir)?; - - // Download the archive with timeout and size limits - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(300)) - .build() - .map_err(|e| anyhow!("Failed to build HTTP client: {}", e))?; - - let response = client - .get(url) - .send() - .await - .map_err(|e| anyhow!("Failed to download plugin: {}", e))?; - - if !response.status().is_success() { - return Err(anyhow!( - "Plugin download failed with status: {}", - response.status() - )); - } - - // Check content-length header before downloading - const MAX_PLUGIN_SIZE: u64 = 100 * 1024 * 1024; // 100 MB - if let Some(content_length) = response.content_length() - && content_length > MAX_PLUGIN_SIZE - { - return Err(anyhow!( - "Plugin archive too large: {} bytes (max {} bytes)", - content_length, - MAX_PLUGIN_SIZE - )); - } - - let bytes = response - .bytes() - .await - .map_err(|e| anyhow!("Failed to read plugin response: {}", e))?; - - // Check actual size after download - if bytes.len() as u64 > MAX_PLUGIN_SIZE { - return Err(anyhow!( - "Plugin archive too large: {} bytes (max {} bytes)", - bytes.len(), - MAX_PLUGIN_SIZE - )); - } - - // Write archive to a unique temp file - let temp_archive = dest_dir.join(format!(".download-{}.tar.gz", uuid::Uuid::now_v7())); - std::fs::write(&temp_archive, &bytes)?; - - // Extract using tar with -C to target directory - let canonical_dest = dest_dir - .canonicalize() - .map_err(|e| anyhow!("Failed to canonicalize dest dir: {}", e))?; - let output = std::process::Command::new("tar") - .args([ - "xzf", - &temp_archive.to_string_lossy(), - "-C", - &canonical_dest.to_string_lossy(), - ]) - .output() - .map_err(|e| anyhow!("Failed to extract plugin archive: {}", e))?; - - // Clean up the archive - let _ = std::fs::remove_file(&temp_archive); - - if !output.status.success() { - return Err(anyhow!( - "Failed to extract plugin archive: {}", - String::from_utf8_lossy(&output.stderr) - )); - } - - // Validate that all extracted files are within dest_dir - for entry in WalkDir::new(&canonical_dest).follow_links(false) { - let entry = entry?; - let entry_canonical = entry.path().canonicalize()?; - if !entry_canonical.starts_with(&canonical_dest) { - return Err(anyhow!( - "Extracted file escapes destination directory: {:?}", - entry.path() - )); - } - } - - // Find the extracted plugin directory by looking for plugin.toml - for entry in WalkDir::new(dest_dir).max_depth(2).follow_links(false) { - let entry = entry?; - if entry.file_name() == "plugin.toml" { - let plugin_dir = entry - .path() - .parent() - .ok_or_else(|| anyhow!("Invalid plugin.toml location"))?; - - // Validate the manifest - let manifest = PluginManifest::from_file(entry.path())?; - info!("Downloaded and extracted plugin: {}", manifest.plugin.name); - - return Ok(plugin_dir.to_path_buf()); - } - } - - Err(anyhow!( - "No plugin.toml found after extracting archive from: {}", - url - )) + /// Download a plugin from a URL + pub async fn download_plugin(&self, url: &str) -> Result { + // Only allow HTTPS downloads + if !url.starts_with("https://") { + return Err(anyhow!( + "Only HTTPS URLs are allowed for plugin downloads: {}", + url + )); } - /// Validate a plugin package - pub fn validate_plugin_package(&self, path: &Path) -> Result<()> { - // Check that the path exists - if !path.exists() { - return Err(anyhow!("Plugin path does not exist: {:?}", path)); - } + let dest_dir = self + .plugin_dirs + .first() + .ok_or_else(|| anyhow!("No plugin directories configured"))?; - // Check for plugin.toml - let manifest_path = path.join("plugin.toml"); - if !manifest_path.exists() { - return Err(anyhow!("Missing plugin.toml in {:?}", path)); - } + std::fs::create_dir_all(dest_dir)?; - // Parse and validate manifest - let manifest = PluginManifest::from_file(&manifest_path)?; + // Download the archive with timeout and size limits + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(300)) + .build() + .map_err(|e| anyhow!("Failed to build HTTP client: {}", e))?; - // Check that WASM binary exists - let wasm_path = path.join(&manifest.plugin.binary.wasm); - if !wasm_path.exists() { - return Err(anyhow!( - "WASM binary not found: {}", - manifest.plugin.binary.wasm - )); - } + let response = client + .get(url) + .send() + .await + .map_err(|e| anyhow!("Failed to download plugin: {}", e))?; - // Verify the WASM path is within the plugin directory (prevent path traversal) - let canonical_wasm = wasm_path.canonicalize()?; - let canonical_path = path.canonicalize()?; - if !canonical_wasm.starts_with(&canonical_path) { - return Err(anyhow!( - "WASM binary path escapes plugin directory: {:?}", - wasm_path - )); - } - - // Validate WASM file - let wasm_bytes = std::fs::read(&wasm_path)?; - if wasm_bytes.len() < 4 || &wasm_bytes[0..4] != b"\0asm" { - return Err(anyhow!("Invalid WASM file: {:?}", wasm_path)); - } - - Ok(()) + if !response.status().is_success() { + return Err(anyhow!( + "Plugin download failed with status: {}", + response.status() + )); } - /// Get plugin directory path for a given plugin name - pub fn get_plugin_dir(&self, plugin_name: &str) -> Option { - for dir in &self.plugin_dirs { - let plugin_dir = dir.join(plugin_name); - if plugin_dir.exists() { - return Some(plugin_dir); - } - } - None + // Check content-length header before downloading + const MAX_PLUGIN_SIZE: u64 = 100 * 1024 * 1024; // 100 MB + if let Some(content_length) = response.content_length() + && content_length > MAX_PLUGIN_SIZE + { + return Err(anyhow!( + "Plugin archive too large: {} bytes (max {} bytes)", + content_length, + MAX_PLUGIN_SIZE + )); } + + let bytes = response + .bytes() + .await + .map_err(|e| anyhow!("Failed to read plugin response: {}", e))?; + + // Check actual size after download + if bytes.len() as u64 > MAX_PLUGIN_SIZE { + return Err(anyhow!( + "Plugin archive too large: {} bytes (max {} bytes)", + bytes.len(), + MAX_PLUGIN_SIZE + )); + } + + // Write archive to a unique temp file + let temp_archive = + dest_dir.join(format!(".download-{}.tar.gz", uuid::Uuid::now_v7())); + std::fs::write(&temp_archive, &bytes)?; + + // Extract using tar with -C to target directory + let canonical_dest = dest_dir + .canonicalize() + .map_err(|e| anyhow!("Failed to canonicalize dest dir: {}", e))?; + let output = std::process::Command::new("tar") + .args([ + "xzf", + &temp_archive.to_string_lossy(), + "-C", + &canonical_dest.to_string_lossy(), + ]) + .output() + .map_err(|e| anyhow!("Failed to extract plugin archive: {}", e))?; + + // Clean up the archive + let _ = std::fs::remove_file(&temp_archive); + + if !output.status.success() { + return Err(anyhow!( + "Failed to extract plugin archive: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + + // Validate that all extracted files are within dest_dir + for entry in WalkDir::new(&canonical_dest).follow_links(false) { + let entry = entry?; + let entry_canonical = entry.path().canonicalize()?; + if !entry_canonical.starts_with(&canonical_dest) { + return Err(anyhow!( + "Extracted file escapes destination directory: {:?}", + entry.path() + )); + } + } + + // Find the extracted plugin directory by looking for plugin.toml + for entry in WalkDir::new(dest_dir).max_depth(2).follow_links(false) { + let entry = entry?; + if entry.file_name() == "plugin.toml" { + let plugin_dir = entry + .path() + .parent() + .ok_or_else(|| anyhow!("Invalid plugin.toml location"))?; + + // Validate the manifest + let manifest = PluginManifest::from_file(entry.path())?; + info!("Downloaded and extracted plugin: {}", manifest.plugin.name); + + return Ok(plugin_dir.to_path_buf()); + } + } + + Err(anyhow!( + "No plugin.toml found after extracting archive from: {}", + url + )) + } + + /// Validate a plugin package + pub fn validate_plugin_package(&self, path: &Path) -> Result<()> { + // Check that the path exists + if !path.exists() { + return Err(anyhow!("Plugin path does not exist: {:?}", path)); + } + + // Check for plugin.toml + let manifest_path = path.join("plugin.toml"); + if !manifest_path.exists() { + return Err(anyhow!("Missing plugin.toml in {:?}", path)); + } + + // Parse and validate manifest + let manifest = PluginManifest::from_file(&manifest_path)?; + + // Check that WASM binary exists + let wasm_path = path.join(&manifest.plugin.binary.wasm); + if !wasm_path.exists() { + return Err(anyhow!( + "WASM binary not found: {}", + manifest.plugin.binary.wasm + )); + } + + // Verify the WASM path is within the plugin directory (prevent path + // traversal) + let canonical_wasm = wasm_path.canonicalize()?; + let canonical_path = path.canonicalize()?; + if !canonical_wasm.starts_with(&canonical_path) { + return Err(anyhow!( + "WASM binary path escapes plugin directory: {:?}", + wasm_path + )); + } + + // Validate WASM file + let wasm_bytes = std::fs::read(&wasm_path)?; + if wasm_bytes.len() < 4 || &wasm_bytes[0..4] != b"\0asm" { + return Err(anyhow!("Invalid WASM file: {:?}", wasm_path)); + } + + Ok(()) + } + + /// Get plugin directory path for a given plugin name + pub fn get_plugin_dir(&self, plugin_name: &str) -> Option { + for dir in &self.plugin_dirs { + let plugin_dir = dir.join(plugin_name); + if plugin_dir.exists() { + return Some(plugin_dir); + } + } + None + } } #[cfg(test)] mod tests { - use super::*; - use tempfile::TempDir; + use tempfile::TempDir; - #[tokio::test] - async fn test_discover_plugins_empty() { - let temp_dir = TempDir::new().unwrap(); - let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]); + use super::*; - let manifests = loader.discover_plugins().await.unwrap(); - assert_eq!(manifests.len(), 0); - } + #[tokio::test] + async fn test_discover_plugins_empty() { + let temp_dir = TempDir::new().unwrap(); + let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]); - #[tokio::test] - async fn test_discover_plugins_with_manifest() { - let temp_dir = TempDir::new().unwrap(); - let plugin_dir = temp_dir.path().join("test-plugin"); - std::fs::create_dir(&plugin_dir).unwrap(); + let manifests = loader.discover_plugins().await.unwrap(); + assert_eq!(manifests.len(), 0); + } - // Create a valid manifest - let manifest_content = r#" + #[tokio::test] + async fn test_discover_plugins_with_manifest() { + let temp_dir = TempDir::new().unwrap(); + let plugin_dir = temp_dir.path().join("test-plugin"); + std::fs::create_dir(&plugin_dir).unwrap(); + + // Create a valid manifest + let manifest_content = r#" [plugin] name = "test-plugin" version = "1.0.0" @@ -338,26 +349,27 @@ kind = ["media_type"] [plugin.binary] wasm = "plugin.wasm" "#; - std::fs::write(plugin_dir.join("plugin.toml"), manifest_content).unwrap(); + std::fs::write(plugin_dir.join("plugin.toml"), manifest_content).unwrap(); - // Create dummy WASM file - std::fs::write(plugin_dir.join("plugin.wasm"), b"\0asm\x01\x00\x00\x00").unwrap(); + // Create dummy WASM file + std::fs::write(plugin_dir.join("plugin.wasm"), b"\0asm\x01\x00\x00\x00") + .unwrap(); - let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]); - let manifests = loader.discover_plugins().await.unwrap(); + let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]); + let manifests = loader.discover_plugins().await.unwrap(); - assert_eq!(manifests.len(), 1); - assert_eq!(manifests[0].plugin.name, "test-plugin"); - } + assert_eq!(manifests.len(), 1); + assert_eq!(manifests[0].plugin.name, "test-plugin"); + } - #[test] - fn test_validate_plugin_package() { - let temp_dir = TempDir::new().unwrap(); - let plugin_dir = temp_dir.path().join("test-plugin"); - std::fs::create_dir(&plugin_dir).unwrap(); + #[test] + fn test_validate_plugin_package() { + let temp_dir = TempDir::new().unwrap(); + let plugin_dir = temp_dir.path().join("test-plugin"); + std::fs::create_dir(&plugin_dir).unwrap(); - // Create a valid manifest - let manifest_content = r#" + // Create a valid manifest + let manifest_content = r#" [plugin] name = "test-plugin" version = "1.0.0" @@ -367,27 +379,28 @@ kind = ["media_type"] [plugin.binary] wasm = "plugin.wasm" "#; - std::fs::write(plugin_dir.join("plugin.toml"), manifest_content).unwrap(); + std::fs::write(plugin_dir.join("plugin.toml"), manifest_content).unwrap(); - let loader = PluginLoader::new(vec![]); + let loader = PluginLoader::new(vec![]); - // Should fail without WASM file - assert!(loader.validate_plugin_package(&plugin_dir).is_err()); + // Should fail without WASM file + assert!(loader.validate_plugin_package(&plugin_dir).is_err()); - // Create valid WASM file (magic number only) - std::fs::write(plugin_dir.join("plugin.wasm"), b"\0asm\x01\x00\x00\x00").unwrap(); + // Create valid WASM file (magic number only) + std::fs::write(plugin_dir.join("plugin.wasm"), b"\0asm\x01\x00\x00\x00") + .unwrap(); - // Should succeed now - assert!(loader.validate_plugin_package(&plugin_dir).is_ok()); - } + // Should succeed now + assert!(loader.validate_plugin_package(&plugin_dir).is_ok()); + } - #[test] - fn test_validate_invalid_wasm() { - let temp_dir = TempDir::new().unwrap(); - let plugin_dir = temp_dir.path().join("test-plugin"); - std::fs::create_dir(&plugin_dir).unwrap(); + #[test] + fn test_validate_invalid_wasm() { + let temp_dir = TempDir::new().unwrap(); + let plugin_dir = temp_dir.path().join("test-plugin"); + std::fs::create_dir(&plugin_dir).unwrap(); - let manifest_content = r#" + let manifest_content = r#" [plugin] name = "test-plugin" version = "1.0.0" @@ -397,12 +410,12 @@ kind = ["media_type"] [plugin.binary] wasm = "plugin.wasm" "#; - std::fs::write(plugin_dir.join("plugin.toml"), manifest_content).unwrap(); + std::fs::write(plugin_dir.join("plugin.toml"), manifest_content).unwrap(); - // Create invalid WASM file - std::fs::write(plugin_dir.join("plugin.wasm"), b"not wasm").unwrap(); + // Create invalid WASM file + std::fs::write(plugin_dir.join("plugin.wasm"), b"not wasm").unwrap(); - let loader = PluginLoader::new(vec![]); - assert!(loader.validate_plugin_package(&plugin_dir).is_err()); - } + let loader = PluginLoader::new(vec![]); + assert!(loader.validate_plugin_package(&plugin_dir).is_err()); + } } diff --git a/crates/pinakes-core/src/plugin/mod.rs b/crates/pinakes-core/src/plugin/mod.rs index 2321835..0afd7b7 100644 --- a/crates/pinakes-core/src/plugin/mod.rs +++ b/crates/pinakes-core/src/plugin/mod.rs @@ -1,7 +1,8 @@ //! Plugin system for Pinakes //! -//! This module provides a comprehensive plugin architecture that allows extending -//! Pinakes with custom media types, metadata extractors, search backends, and more. +//! This module provides a comprehensive plugin architecture that allows +//! extending Pinakes with custom media types, metadata extractors, search +//! backends, and more. //! //! # Architecture //! @@ -10,10 +11,10 @@ //! - Hot-reload support for development //! - Automatic plugin discovery from configured directories +use std::{path::PathBuf, sync::Arc}; + use anyhow::Result; use pinakes_plugin_api::{PluginContext, PluginMetadata}; -use std::path::PathBuf; -use std::sync::Arc; use tokio::sync::RwLock; use tracing::{debug, error, info, warn}; @@ -29,391 +30,419 @@ pub use security::CapabilityEnforcer; /// Plugin manager coordinates plugin lifecycle and operations pub struct PluginManager { - /// Plugin registry - registry: Arc>, + /// Plugin registry + registry: Arc>, - /// WASM runtime for executing plugins - runtime: Arc, + /// WASM runtime for executing plugins + runtime: Arc, - /// Plugin loader for discovery and loading - loader: PluginLoader, + /// Plugin loader for discovery and loading + loader: PluginLoader, - /// Capability enforcer for security - enforcer: CapabilityEnforcer, + /// Capability enforcer for security + enforcer: CapabilityEnforcer, - /// Plugin data directory - data_dir: PathBuf, + /// Plugin data directory + data_dir: PathBuf, - /// Plugin cache directory - cache_dir: PathBuf, + /// Plugin cache directory + cache_dir: PathBuf, - /// Configuration - config: PluginManagerConfig, + /// Configuration + config: PluginManagerConfig, } /// Configuration for the plugin manager #[derive(Debug, Clone)] pub struct PluginManagerConfig { - /// Directories to search for plugins - pub plugin_dirs: Vec, + /// Directories to search for plugins + pub plugin_dirs: Vec, - /// Whether to enable hot-reload (for development) - pub enable_hot_reload: bool, + /// Whether to enable hot-reload (for development) + pub enable_hot_reload: bool, - /// Whether to allow unsigned plugins - pub allow_unsigned: bool, + /// Whether to allow unsigned plugins + pub allow_unsigned: bool, - /// Maximum number of concurrent plugin operations - pub max_concurrent_ops: usize, + /// Maximum number of concurrent plugin operations + pub max_concurrent_ops: usize, - /// Plugin timeout in seconds - pub plugin_timeout_secs: u64, + /// Plugin timeout in seconds + pub plugin_timeout_secs: u64, } impl Default for PluginManagerConfig { - fn default() -> Self { - Self { - plugin_dirs: vec![], - enable_hot_reload: false, - allow_unsigned: false, - max_concurrent_ops: 4, - plugin_timeout_secs: 30, - } + fn default() -> Self { + Self { + plugin_dirs: vec![], + enable_hot_reload: false, + allow_unsigned: false, + max_concurrent_ops: 4, + plugin_timeout_secs: 30, } + } } impl From for PluginManagerConfig { - fn from(cfg: crate::config::PluginsConfig) -> Self { - Self { - plugin_dirs: cfg.plugin_dirs, - enable_hot_reload: cfg.enable_hot_reload, - allow_unsigned: cfg.allow_unsigned, - max_concurrent_ops: cfg.max_concurrent_ops, - plugin_timeout_secs: cfg.plugin_timeout_secs, - } + fn from(cfg: crate::config::PluginsConfig) -> Self { + Self { + plugin_dirs: cfg.plugin_dirs, + enable_hot_reload: cfg.enable_hot_reload, + allow_unsigned: cfg.allow_unsigned, + max_concurrent_ops: cfg.max_concurrent_ops, + plugin_timeout_secs: cfg.plugin_timeout_secs, } + } } impl PluginManager { - /// Create a new plugin manager - pub fn new(data_dir: PathBuf, cache_dir: PathBuf, config: PluginManagerConfig) -> Result { - // Ensure directories exist - std::fs::create_dir_all(&data_dir)?; - std::fs::create_dir_all(&cache_dir)?; + /// Create a new plugin manager + pub fn new( + data_dir: PathBuf, + cache_dir: PathBuf, + config: PluginManagerConfig, + ) -> Result { + // Ensure directories exist + std::fs::create_dir_all(&data_dir)?; + std::fs::create_dir_all(&cache_dir)?; - let runtime = Arc::new(WasmRuntime::new()?); - let registry = Arc::new(RwLock::new(PluginRegistry::new())); - let loader = PluginLoader::new(config.plugin_dirs.clone()); - let enforcer = CapabilityEnforcer::new(); + let runtime = Arc::new(WasmRuntime::new()?); + let registry = Arc::new(RwLock::new(PluginRegistry::new())); + let loader = PluginLoader::new(config.plugin_dirs.clone()); + let enforcer = CapabilityEnforcer::new(); - Ok(Self { - registry, - runtime, - loader, - enforcer, - data_dir, - cache_dir, - config, + Ok(Self { + registry, + runtime, + loader, + enforcer, + data_dir, + cache_dir, + config, + }) + } + + /// Discover and load all plugins from configured directories + pub async fn discover_and_load_all(&self) -> Result> { + info!("Discovering plugins from {:?}", self.config.plugin_dirs); + + let manifests = self.loader.discover_plugins().await?; + let mut loaded_plugins = Vec::new(); + + for manifest in manifests { + match self.load_plugin_from_manifest(&manifest).await { + Ok(plugin_id) => { + info!("Loaded plugin: {}", plugin_id); + loaded_plugins.push(plugin_id); + }, + Err(e) => { + warn!("Failed to load plugin {}: {}", manifest.plugin.name, e); + }, + } + } + + Ok(loaded_plugins) + } + + /// Load a plugin from a manifest file + async fn load_plugin_from_manifest( + &self, + manifest: &pinakes_plugin_api::PluginManifest, + ) -> Result { + let plugin_id = manifest.plugin_id(); + + // Validate plugin_id to prevent path traversal + if plugin_id.contains('/') + || plugin_id.contains('\\') + || plugin_id.contains("..") + { + return Err(anyhow::anyhow!("Invalid plugin ID: {}", plugin_id)); + } + + // Check if already loaded + { + let registry = self.registry.read().await; + if registry.is_loaded(&plugin_id) { + return Ok(plugin_id); + } + } + + // Validate capabilities + let capabilities = manifest.to_capabilities(); + self.enforcer.validate_capabilities(&capabilities)?; + + // Create plugin context + let plugin_data_dir = self.data_dir.join(&plugin_id); + let plugin_cache_dir = self.cache_dir.join(&plugin_id); + tokio::fs::create_dir_all(&plugin_data_dir).await?; + tokio::fs::create_dir_all(&plugin_cache_dir).await?; + + let context = PluginContext { + data_dir: plugin_data_dir, + cache_dir: plugin_cache_dir, + config: manifest + .config + .iter() + .map(|(k, v)| { + ( + k.clone(), + serde_json::to_value(v).unwrap_or_else(|e| { + tracing::warn!( + "failed to serialize config value for key {}: {}", + k, + e + ); + serde_json::Value::Null + }), + ) }) + .collect(), + capabilities: capabilities.clone(), + }; + + // Load WASM binary + let wasm_path = self.loader.resolve_wasm_path(manifest)?; + let wasm_plugin = self.runtime.load_plugin(&wasm_path, context).await?; + + // Initialize plugin + let init_succeeded = match wasm_plugin + .call_function("initialize", &[]) + .await + { + Ok(_) => true, + Err(e) => { + tracing::warn!(plugin_id = %plugin_id, "plugin initialization failed: {}", e); + false + }, + }; + + // Register plugin + let metadata = PluginMetadata { + id: plugin_id.clone(), + name: manifest.plugin.name.clone(), + version: manifest.plugin.version.clone(), + author: manifest.plugin.author.clone().unwrap_or_default(), + description: manifest + .plugin + .description + .clone() + .unwrap_or_default(), + api_version: manifest.plugin.api_version.clone(), + capabilities_required: capabilities, + }; + + // Derive manifest_path from the loader's plugin directories + let manifest_path = self + .loader + .get_plugin_dir(&manifest.plugin.name) + .map(|dir| dir.join("plugin.toml")); + + let registered = RegisteredPlugin { + id: plugin_id.clone(), + metadata, + wasm_plugin, + manifest: manifest.clone(), + manifest_path, + enabled: init_succeeded, + }; + + let mut registry = self.registry.write().await; + registry.register(registered)?; + + Ok(plugin_id) + } + + /// Install a plugin from a file or URL + pub async fn install_plugin(&self, source: &str) -> Result { + info!("Installing plugin from: {}", source); + + // Download/copy plugin to plugins directory + let plugin_path = + if source.starts_with("http://") || source.starts_with("https://") { + // Download from URL + self.loader.download_plugin(source).await? + } else { + // Copy from local file + PathBuf::from(source) + }; + + // Load the manifest + let manifest_path = plugin_path.join("plugin.toml"); + let manifest = + pinakes_plugin_api::PluginManifest::from_file(&manifest_path)?; + + // Load the plugin + self.load_plugin_from_manifest(&manifest).await + } + + /// Uninstall a plugin + pub async fn uninstall_plugin(&self, plugin_id: &str) -> Result<()> { + // Validate plugin_id to prevent path traversal + if plugin_id.contains('/') + || plugin_id.contains('\\') + || plugin_id.contains("..") + { + return Err(anyhow::anyhow!("Invalid plugin ID: {}", plugin_id)); } - /// Discover and load all plugins from configured directories - pub async fn discover_and_load_all(&self) -> Result> { - info!("Discovering plugins from {:?}", self.config.plugin_dirs); + info!("Uninstalling plugin: {}", plugin_id); - let manifests = self.loader.discover_plugins().await?; - let mut loaded_plugins = Vec::new(); + // Shutdown plugin first + self.shutdown_plugin(plugin_id).await?; - for manifest in manifests { - match self.load_plugin_from_manifest(&manifest).await { - Ok(plugin_id) => { - info!("Loaded plugin: {}", plugin_id); - loaded_plugins.push(plugin_id); - } - Err(e) => { - warn!("Failed to load plugin {}: {}", manifest.plugin.name, e); - } - } - } + // Remove from registry + let mut registry = self.registry.write().await; + registry.unregister(plugin_id)?; - Ok(loaded_plugins) + // Remove plugin data and cache + let plugin_data_dir = self.data_dir.join(plugin_id); + let plugin_cache_dir = self.cache_dir.join(plugin_id); + + if plugin_data_dir.exists() { + std::fs::remove_dir_all(&plugin_data_dir)?; + } + if plugin_cache_dir.exists() { + std::fs::remove_dir_all(&plugin_cache_dir)?; } - /// Load a plugin from a manifest file - async fn load_plugin_from_manifest( - &self, - manifest: &pinakes_plugin_api::PluginManifest, - ) -> Result { - let plugin_id = manifest.plugin_id(); + Ok(()) + } - // Validate plugin_id to prevent path traversal - if plugin_id.contains('/') || plugin_id.contains('\\') || plugin_id.contains("..") { - return Err(anyhow::anyhow!("Invalid plugin ID: {}", plugin_id)); - } + /// Enable a plugin + pub async fn enable_plugin(&self, plugin_id: &str) -> Result<()> { + let mut registry = self.registry.write().await; + registry.enable(plugin_id) + } - // Check if already loaded - { - let registry = self.registry.read().await; - if registry.is_loaded(&plugin_id) { - return Ok(plugin_id); - } - } + /// Disable a plugin + pub async fn disable_plugin(&self, plugin_id: &str) -> Result<()> { + let mut registry = self.registry.write().await; + registry.disable(plugin_id) + } - // Validate capabilities - let capabilities = manifest.to_capabilities(); - self.enforcer.validate_capabilities(&capabilities)?; + /// Shutdown a specific plugin + pub async fn shutdown_plugin(&self, plugin_id: &str) -> Result<()> { + debug!("Shutting down plugin: {}", plugin_id); - // Create plugin context - let plugin_data_dir = self.data_dir.join(&plugin_id); - let plugin_cache_dir = self.cache_dir.join(&plugin_id); - tokio::fs::create_dir_all(&plugin_data_dir).await?; - tokio::fs::create_dir_all(&plugin_cache_dir).await?; + let registry = self.registry.read().await; + if let Some(plugin) = registry.get(plugin_id) { + plugin.wasm_plugin.call_function("shutdown", &[]).await.ok(); + Ok(()) + } else { + Err(anyhow::anyhow!("Plugin not found: {}", plugin_id)) + } + } - let context = PluginContext { - data_dir: plugin_data_dir, - cache_dir: plugin_cache_dir, - config: manifest - .config - .iter() - .map(|(k, v)| { - ( - k.clone(), - serde_json::to_value(v).unwrap_or_else(|e| { - tracing::warn!("failed to serialize config value for key {}: {}", k, e); - serde_json::Value::Null - }), - ) - }) - .collect(), - capabilities: capabilities.clone(), - }; + /// Shutdown all plugins + pub async fn shutdown_all(&self) -> Result<()> { + info!("Shutting down all plugins"); - // Load WASM binary - let wasm_path = self.loader.resolve_wasm_path(manifest)?; - let wasm_plugin = self.runtime.load_plugin(&wasm_path, context).await?; + let registry = self.registry.read().await; + let plugin_ids: Vec = + registry.list_all().iter().map(|p| p.id.clone()).collect(); - // Initialize plugin - let init_succeeded = match wasm_plugin.call_function("initialize", &[]).await { - Ok(_) => true, - Err(e) => { - tracing::warn!(plugin_id = %plugin_id, "plugin initialization failed: {}", e); - false - } - }; - - // Register plugin - let metadata = PluginMetadata { - id: plugin_id.clone(), - name: manifest.plugin.name.clone(), - version: manifest.plugin.version.clone(), - author: manifest.plugin.author.clone().unwrap_or_default(), - description: manifest.plugin.description.clone().unwrap_or_default(), - api_version: manifest.plugin.api_version.clone(), - capabilities_required: capabilities, - }; - - // Derive manifest_path from the loader's plugin directories - let manifest_path = self - .loader - .get_plugin_dir(&manifest.plugin.name) - .map(|dir| dir.join("plugin.toml")); - - let registered = RegisteredPlugin { - id: plugin_id.clone(), - metadata, - wasm_plugin, - manifest: manifest.clone(), - manifest_path, - enabled: init_succeeded, - }; - - let mut registry = self.registry.write().await; - registry.register(registered)?; - - Ok(plugin_id) + for plugin_id in plugin_ids { + if let Err(e) = self.shutdown_plugin(&plugin_id).await { + error!("Failed to shutdown plugin {}: {}", plugin_id, e); + } } - /// Install a plugin from a file or URL - pub async fn install_plugin(&self, source: &str) -> Result { - info!("Installing plugin from: {}", source); + Ok(()) + } - // Download/copy plugin to plugins directory - let plugin_path = if source.starts_with("http://") || source.starts_with("https://") { - // Download from URL - self.loader.download_plugin(source).await? - } else { - // Copy from local file - PathBuf::from(source) - }; + /// Get list of all registered plugins + pub async fn list_plugins(&self) -> Vec { + let registry = self.registry.read().await; + registry + .list_all() + .iter() + .map(|p| p.metadata.clone()) + .collect() + } - // Load the manifest - let manifest_path = plugin_path.join("plugin.toml"); - let manifest = pinakes_plugin_api::PluginManifest::from_file(&manifest_path)?; + /// Get plugin metadata by ID + pub async fn get_plugin(&self, plugin_id: &str) -> Option { + let registry = self.registry.read().await; + registry.get(plugin_id).map(|p| p.metadata.clone()) + } - // Load the plugin - self.load_plugin_from_manifest(&manifest).await + /// Check if a plugin is loaded and enabled + pub async fn is_plugin_enabled(&self, plugin_id: &str) -> bool { + let registry = self.registry.read().await; + registry.is_enabled(plugin_id).unwrap_or(false) + } + + /// Reload a plugin (for hot-reload during development) + pub async fn reload_plugin(&self, plugin_id: &str) -> Result<()> { + if !self.config.enable_hot_reload { + return Err(anyhow::anyhow!("Hot-reload is disabled")); } - /// Uninstall a plugin - pub async fn uninstall_plugin(&self, plugin_id: &str) -> Result<()> { - // Validate plugin_id to prevent path traversal - if plugin_id.contains('/') || plugin_id.contains('\\') || plugin_id.contains("..") { - return Err(anyhow::anyhow!("Invalid plugin ID: {}", plugin_id)); - } + info!("Reloading plugin: {}", plugin_id); - info!("Uninstalling plugin: {}", plugin_id); + // Re-read the manifest from disk if possible, falling back to cached + // version + let manifest = { + let registry = self.registry.read().await; + let plugin = registry + .get(plugin_id) + .ok_or_else(|| anyhow::anyhow!("Plugin not found"))?; + if let Some(ref manifest_path) = plugin.manifest_path { + pinakes_plugin_api::PluginManifest::from_file(manifest_path) + .unwrap_or_else(|e| { + warn!("Failed to re-read manifest from disk, using cached: {}", e); + plugin.manifest.clone() + }) + } else { + plugin.manifest.clone() + } + }; - // Shutdown plugin first - self.shutdown_plugin(plugin_id).await?; - - // Remove from registry - let mut registry = self.registry.write().await; - registry.unregister(plugin_id)?; - - // Remove plugin data and cache - let plugin_data_dir = self.data_dir.join(plugin_id); - let plugin_cache_dir = self.cache_dir.join(plugin_id); - - if plugin_data_dir.exists() { - std::fs::remove_dir_all(&plugin_data_dir)?; - } - if plugin_cache_dir.exists() { - std::fs::remove_dir_all(&plugin_cache_dir)?; - } - - Ok(()) + // Shutdown and unload current version + self.shutdown_plugin(plugin_id).await?; + { + let mut registry = self.registry.write().await; + registry.unregister(plugin_id)?; } - /// Enable a plugin - pub async fn enable_plugin(&self, plugin_id: &str) -> Result<()> { - let mut registry = self.registry.write().await; - registry.enable(plugin_id) - } + // Reload from manifest + self.load_plugin_from_manifest(&manifest).await?; - /// Disable a plugin - pub async fn disable_plugin(&self, plugin_id: &str) -> Result<()> { - let mut registry = self.registry.write().await; - registry.disable(plugin_id) - } - - /// Shutdown a specific plugin - pub async fn shutdown_plugin(&self, plugin_id: &str) -> Result<()> { - debug!("Shutting down plugin: {}", plugin_id); - - let registry = self.registry.read().await; - if let Some(plugin) = registry.get(plugin_id) { - plugin.wasm_plugin.call_function("shutdown", &[]).await.ok(); - Ok(()) - } else { - Err(anyhow::anyhow!("Plugin not found: {}", plugin_id)) - } - } - - /// Shutdown all plugins - pub async fn shutdown_all(&self) -> Result<()> { - info!("Shutting down all plugins"); - - let registry = self.registry.read().await; - let plugin_ids: Vec = registry.list_all().iter().map(|p| p.id.clone()).collect(); - - for plugin_id in plugin_ids { - if let Err(e) = self.shutdown_plugin(&plugin_id).await { - error!("Failed to shutdown plugin {}: {}", plugin_id, e); - } - } - - Ok(()) - } - - /// Get list of all registered plugins - pub async fn list_plugins(&self) -> Vec { - let registry = self.registry.read().await; - registry - .list_all() - .iter() - .map(|p| p.metadata.clone()) - .collect() - } - - /// Get plugin metadata by ID - pub async fn get_plugin(&self, plugin_id: &str) -> Option { - let registry = self.registry.read().await; - registry.get(plugin_id).map(|p| p.metadata.clone()) - } - - /// Check if a plugin is loaded and enabled - pub async fn is_plugin_enabled(&self, plugin_id: &str) -> bool { - let registry = self.registry.read().await; - registry.is_enabled(plugin_id).unwrap_or(false) - } - - /// Reload a plugin (for hot-reload during development) - pub async fn reload_plugin(&self, plugin_id: &str) -> Result<()> { - if !self.config.enable_hot_reload { - return Err(anyhow::anyhow!("Hot-reload is disabled")); - } - - info!("Reloading plugin: {}", plugin_id); - - // Re-read the manifest from disk if possible, falling back to cached version - let manifest = { - let registry = self.registry.read().await; - let plugin = registry - .get(plugin_id) - .ok_or_else(|| anyhow::anyhow!("Plugin not found"))?; - if let Some(ref manifest_path) = plugin.manifest_path { - pinakes_plugin_api::PluginManifest::from_file(manifest_path).unwrap_or_else(|e| { - warn!("Failed to re-read manifest from disk, using cached: {}", e); - plugin.manifest.clone() - }) - } else { - plugin.manifest.clone() - } - }; - - // Shutdown and unload current version - self.shutdown_plugin(plugin_id).await?; - { - let mut registry = self.registry.write().await; - registry.unregister(plugin_id)?; - } - - // Reload from manifest - self.load_plugin_from_manifest(&manifest).await?; - - Ok(()) - } + Ok(()) + } } #[cfg(test)] mod tests { - use super::*; - use tempfile::TempDir; + use tempfile::TempDir; - #[tokio::test] - async fn test_plugin_manager_creation() { - let temp_dir = TempDir::new().unwrap(); - let data_dir = temp_dir.path().join("data"); - let cache_dir = temp_dir.path().join("cache"); + use super::*; - let config = PluginManagerConfig::default(); - let manager = PluginManager::new(data_dir.clone(), cache_dir.clone(), config); + #[tokio::test] + async fn test_plugin_manager_creation() { + let temp_dir = TempDir::new().unwrap(); + let data_dir = temp_dir.path().join("data"); + let cache_dir = temp_dir.path().join("cache"); - assert!(manager.is_ok()); - assert!(data_dir.exists()); - assert!(cache_dir.exists()); - } + let config = PluginManagerConfig::default(); + let manager = + PluginManager::new(data_dir.clone(), cache_dir.clone(), config); - #[tokio::test] - async fn test_list_plugins_empty() { - let temp_dir = TempDir::new().unwrap(); - let data_dir = temp_dir.path().join("data"); - let cache_dir = temp_dir.path().join("cache"); + assert!(manager.is_ok()); + assert!(data_dir.exists()); + assert!(cache_dir.exists()); + } - let config = PluginManagerConfig::default(); - let manager = PluginManager::new(data_dir, cache_dir, config).unwrap(); + #[tokio::test] + async fn test_list_plugins_empty() { + let temp_dir = TempDir::new().unwrap(); + let data_dir = temp_dir.path().join("data"); + let cache_dir = temp_dir.path().join("cache"); - let plugins = manager.list_plugins().await; - assert_eq!(plugins.len(), 0); - } + let config = PluginManagerConfig::default(); + let manager = PluginManager::new(data_dir, cache_dir, config).unwrap(); + + let plugins = manager.list_plugins().await; + assert_eq!(plugins.len(), 0); + } } diff --git a/crates/pinakes-core/src/plugin/registry.rs b/crates/pinakes-core/src/plugin/registry.rs index 93a5e8b..dd920d7 100644 --- a/crates/pinakes-core/src/plugin/registry.rs +++ b/crates/pinakes-core/src/plugin/registry.rs @@ -1,280 +1,282 @@ //! Plugin registry for managing loaded plugins -use std::path::PathBuf; +use std::{collections::HashMap, path::PathBuf}; use anyhow::{Result, anyhow}; use pinakes_plugin_api::{PluginManifest, PluginMetadata}; -use std::collections::HashMap; use super::runtime::WasmPlugin; /// A registered plugin with its metadata and runtime state #[derive(Clone)] pub struct RegisteredPlugin { - pub id: String, - pub metadata: PluginMetadata, - pub wasm_plugin: WasmPlugin, - pub manifest: PluginManifest, - pub manifest_path: Option, - pub enabled: bool, + pub id: String, + pub metadata: PluginMetadata, + pub wasm_plugin: WasmPlugin, + pub manifest: PluginManifest, + pub manifest_path: Option, + pub enabled: bool, } /// Plugin registry maintains the state of all loaded plugins pub struct PluginRegistry { - /// Map of plugin ID to registered plugin - plugins: HashMap, + /// Map of plugin ID to registered plugin + plugins: HashMap, } impl PluginRegistry { - /// Create a new empty registry - pub fn new() -> Self { - Self { - plugins: HashMap::new(), - } + /// Create a new empty registry + pub fn new() -> Self { + Self { + plugins: HashMap::new(), + } + } + + /// Register a new plugin + pub fn register(&mut self, plugin: RegisteredPlugin) -> Result<()> { + if self.plugins.contains_key(&plugin.id) { + return Err(anyhow!("Plugin already registered: {}", plugin.id)); } - /// Register a new plugin - pub fn register(&mut self, plugin: RegisteredPlugin) -> Result<()> { - if self.plugins.contains_key(&plugin.id) { - return Err(anyhow!("Plugin already registered: {}", plugin.id)); - } + self.plugins.insert(plugin.id.clone(), plugin); + Ok(()) + } - self.plugins.insert(plugin.id.clone(), plugin); - Ok(()) - } + /// Unregister a plugin by ID + pub fn unregister(&mut self, plugin_id: &str) -> Result<()> { + self + .plugins + .remove(plugin_id) + .ok_or_else(|| anyhow!("Plugin not found: {}", plugin_id))?; + Ok(()) + } - /// Unregister a plugin by ID - pub fn unregister(&mut self, plugin_id: &str) -> Result<()> { - self.plugins - .remove(plugin_id) - .ok_or_else(|| anyhow!("Plugin not found: {}", plugin_id))?; - Ok(()) - } + /// Get a plugin by ID + pub fn get(&self, plugin_id: &str) -> Option<&RegisteredPlugin> { + self.plugins.get(plugin_id) + } - /// Get a plugin by ID - pub fn get(&self, plugin_id: &str) -> Option<&RegisteredPlugin> { - self.plugins.get(plugin_id) - } + /// Get a mutable reference to a plugin by ID + pub fn get_mut(&mut self, plugin_id: &str) -> Option<&mut RegisteredPlugin> { + self.plugins.get_mut(plugin_id) + } - /// Get a mutable reference to a plugin by ID - pub fn get_mut(&mut self, plugin_id: &str) -> Option<&mut RegisteredPlugin> { - self.plugins.get_mut(plugin_id) - } + /// Check if a plugin is loaded + pub fn is_loaded(&self, plugin_id: &str) -> bool { + self.plugins.contains_key(plugin_id) + } - /// Check if a plugin is loaded - pub fn is_loaded(&self, plugin_id: &str) -> bool { - self.plugins.contains_key(plugin_id) - } + /// Check if a plugin is enabled. Returns `None` if the plugin is not found. + pub fn is_enabled(&self, plugin_id: &str) -> Option { + self.plugins.get(plugin_id).map(|p| p.enabled) + } - /// Check if a plugin is enabled. Returns `None` if the plugin is not found. - pub fn is_enabled(&self, plugin_id: &str) -> Option { - self.plugins.get(plugin_id).map(|p| p.enabled) - } + /// Enable a plugin + pub fn enable(&mut self, plugin_id: &str) -> Result<()> { + let plugin = self + .plugins + .get_mut(plugin_id) + .ok_or_else(|| anyhow!("Plugin not found: {}", plugin_id))?; - /// Enable a plugin - pub fn enable(&mut self, plugin_id: &str) -> Result<()> { - let plugin = self - .plugins - .get_mut(plugin_id) - .ok_or_else(|| anyhow!("Plugin not found: {}", plugin_id))?; + plugin.enabled = true; + Ok(()) + } - plugin.enabled = true; - Ok(()) - } + /// Disable a plugin + pub fn disable(&mut self, plugin_id: &str) -> Result<()> { + let plugin = self + .plugins + .get_mut(plugin_id) + .ok_or_else(|| anyhow!("Plugin not found: {}", plugin_id))?; - /// Disable a plugin - pub fn disable(&mut self, plugin_id: &str) -> Result<()> { - let plugin = self - .plugins - .get_mut(plugin_id) - .ok_or_else(|| anyhow!("Plugin not found: {}", plugin_id))?; + plugin.enabled = false; + Ok(()) + } - plugin.enabled = false; - Ok(()) - } + /// List all registered plugins + pub fn list_all(&self) -> Vec<&RegisteredPlugin> { + self.plugins.values().collect() + } - /// List all registered plugins - pub fn list_all(&self) -> Vec<&RegisteredPlugin> { - self.plugins.values().collect() - } + /// List all enabled plugins + pub fn list_enabled(&self) -> Vec<&RegisteredPlugin> { + self.plugins.values().filter(|p| p.enabled).collect() + } - /// List all enabled plugins - pub fn list_enabled(&self) -> Vec<&RegisteredPlugin> { - self.plugins.values().filter(|p| p.enabled).collect() - } + /// Get plugins by kind (e.g., "media_type", "metadata_extractor") + pub fn get_by_kind(&self, kind: &str) -> Vec<&RegisteredPlugin> { + self + .plugins + .values() + .filter(|p| p.manifest.plugin.kind.contains(&kind.to_string())) + .collect() + } - /// Get plugins by kind (e.g., "media_type", "metadata_extractor") - pub fn get_by_kind(&self, kind: &str) -> Vec<&RegisteredPlugin> { - self.plugins - .values() - .filter(|p| p.manifest.plugin.kind.contains(&kind.to_string())) - .collect() - } + /// Get count of registered plugins + pub fn count(&self) -> usize { + self.plugins.len() + } - /// Get count of registered plugins - pub fn count(&self) -> usize { - self.plugins.len() - } - - /// Get count of enabled plugins - pub fn count_enabled(&self) -> usize { - self.plugins.values().filter(|p| p.enabled).count() - } + /// Get count of enabled plugins + pub fn count_enabled(&self) -> usize { + self.plugins.values().filter(|p| p.enabled).count() + } } impl Default for PluginRegistry { - fn default() -> Self { - Self::new() - } + fn default() -> Self { + Self::new() + } } #[cfg(test)] mod tests { - use super::*; - use pinakes_plugin_api::Capabilities; - use std::collections::HashMap; + use std::collections::HashMap; - fn create_test_plugin(id: &str, kind: Vec) -> RegisteredPlugin { - let manifest = PluginManifest { - plugin: pinakes_plugin_api::manifest::PluginInfo { - name: id.to_string(), - version: "1.0.0".to_string(), - api_version: "1.0".to_string(), - author: Some("Test".to_string()), - description: Some("Test plugin".to_string()), - homepage: None, - license: None, - kind, - binary: pinakes_plugin_api::manifest::PluginBinary { - wasm: "test.wasm".to_string(), - entrypoint: None, - }, - dependencies: vec![], - }, - capabilities: Default::default(), - config: HashMap::new(), - }; + use pinakes_plugin_api::Capabilities; - RegisteredPlugin { - id: id.to_string(), - metadata: PluginMetadata { - id: id.to_string(), - name: id.to_string(), - version: "1.0.0".to_string(), - author: "Test".to_string(), - description: "Test plugin".to_string(), - api_version: "1.0".to_string(), - capabilities_required: Capabilities::default(), - }, - wasm_plugin: WasmPlugin::default(), - manifest, - manifest_path: None, - enabled: true, - } + use super::*; + + fn create_test_plugin(id: &str, kind: Vec) -> RegisteredPlugin { + let manifest = PluginManifest { + plugin: pinakes_plugin_api::manifest::PluginInfo { + name: id.to_string(), + version: "1.0.0".to_string(), + api_version: "1.0".to_string(), + author: Some("Test".to_string()), + description: Some("Test plugin".to_string()), + homepage: None, + license: None, + kind, + binary: pinakes_plugin_api::manifest::PluginBinary { + wasm: "test.wasm".to_string(), + entrypoint: None, + }, + dependencies: vec![], + }, + capabilities: Default::default(), + config: HashMap::new(), + }; + + RegisteredPlugin { + id: id.to_string(), + metadata: PluginMetadata { + id: id.to_string(), + name: id.to_string(), + version: "1.0.0".to_string(), + author: "Test".to_string(), + description: "Test plugin".to_string(), + api_version: "1.0".to_string(), + capabilities_required: Capabilities::default(), + }, + wasm_plugin: WasmPlugin::default(), + manifest, + manifest_path: None, + enabled: true, } + } - #[test] - fn test_registry_register_and_get() { - let mut registry = PluginRegistry::new(); - let plugin = create_test_plugin("test-plugin", vec!["media_type".to_string()]); + #[test] + fn test_registry_register_and_get() { + let mut registry = PluginRegistry::new(); + let plugin = + create_test_plugin("test-plugin", vec!["media_type".to_string()]); - registry.register(plugin.clone()).unwrap(); + registry.register(plugin.clone()).unwrap(); - assert!(registry.is_loaded("test-plugin")); - assert!(registry.get("test-plugin").is_some()); - } + assert!(registry.is_loaded("test-plugin")); + assert!(registry.get("test-plugin").is_some()); + } - #[test] - fn test_registry_duplicate_register() { - let mut registry = PluginRegistry::new(); - let plugin = create_test_plugin("test-plugin", vec!["media_type".to_string()]); + #[test] + fn test_registry_duplicate_register() { + let mut registry = PluginRegistry::new(); + let plugin = + create_test_plugin("test-plugin", vec!["media_type".to_string()]); - registry.register(plugin.clone()).unwrap(); - let result = registry.register(plugin); + registry.register(plugin.clone()).unwrap(); + let result = registry.register(plugin); - assert!(result.is_err()); - } + assert!(result.is_err()); + } - #[test] - fn test_registry_unregister() { - let mut registry = PluginRegistry::new(); - let plugin = create_test_plugin("test-plugin", vec!["media_type".to_string()]); + #[test] + fn test_registry_unregister() { + let mut registry = PluginRegistry::new(); + let plugin = + create_test_plugin("test-plugin", vec!["media_type".to_string()]); - registry.register(plugin).unwrap(); - registry.unregister("test-plugin").unwrap(); + registry.register(plugin).unwrap(); + registry.unregister("test-plugin").unwrap(); - assert!(!registry.is_loaded("test-plugin")); - } + assert!(!registry.is_loaded("test-plugin")); + } - #[test] - fn test_registry_enable_disable() { - let mut registry = PluginRegistry::new(); - let plugin = create_test_plugin("test-plugin", vec!["media_type".to_string()]); + #[test] + fn test_registry_enable_disable() { + let mut registry = PluginRegistry::new(); + let plugin = + create_test_plugin("test-plugin", vec!["media_type".to_string()]); - registry.register(plugin).unwrap(); - assert_eq!(registry.is_enabled("test-plugin"), Some(true)); + registry.register(plugin).unwrap(); + assert_eq!(registry.is_enabled("test-plugin"), Some(true)); - registry.disable("test-plugin").unwrap(); - assert_eq!(registry.is_enabled("test-plugin"), Some(false)); + registry.disable("test-plugin").unwrap(); + assert_eq!(registry.is_enabled("test-plugin"), Some(false)); - registry.enable("test-plugin").unwrap(); - assert_eq!(registry.is_enabled("test-plugin"), Some(true)); + registry.enable("test-plugin").unwrap(); + assert_eq!(registry.is_enabled("test-plugin"), Some(true)); - assert_eq!(registry.is_enabled("nonexistent"), None); - } + assert_eq!(registry.is_enabled("nonexistent"), None); + } - #[test] - fn test_registry_get_by_kind() { - let mut registry = PluginRegistry::new(); + #[test] + fn test_registry_get_by_kind() { + let mut registry = PluginRegistry::new(); - registry - .register(create_test_plugin( - "plugin1", - vec!["media_type".to_string()], - )) - .unwrap(); - registry - .register(create_test_plugin( - "plugin2", - vec!["metadata_extractor".to_string()], - )) - .unwrap(); - registry - .register(create_test_plugin( - "plugin3", - vec!["media_type".to_string()], - )) - .unwrap(); + registry + .register(create_test_plugin("plugin1", vec![ + "media_type".to_string(), + ])) + .unwrap(); + registry + .register(create_test_plugin("plugin2", vec![ + "metadata_extractor".to_string(), + ])) + .unwrap(); + registry + .register(create_test_plugin("plugin3", vec![ + "media_type".to_string(), + ])) + .unwrap(); - let media_type_plugins = registry.get_by_kind("media_type"); - assert_eq!(media_type_plugins.len(), 2); + let media_type_plugins = registry.get_by_kind("media_type"); + assert_eq!(media_type_plugins.len(), 2); - let extractor_plugins = registry.get_by_kind("metadata_extractor"); - assert_eq!(extractor_plugins.len(), 1); - } + let extractor_plugins = registry.get_by_kind("metadata_extractor"); + assert_eq!(extractor_plugins.len(), 1); + } - #[test] - fn test_registry_counts() { - let mut registry = PluginRegistry::new(); + #[test] + fn test_registry_counts() { + let mut registry = PluginRegistry::new(); - registry - .register(create_test_plugin( - "plugin1", - vec!["media_type".to_string()], - )) - .unwrap(); - registry - .register(create_test_plugin( - "plugin2", - vec!["media_type".to_string()], - )) - .unwrap(); + registry + .register(create_test_plugin("plugin1", vec![ + "media_type".to_string(), + ])) + .unwrap(); + registry + .register(create_test_plugin("plugin2", vec![ + "media_type".to_string(), + ])) + .unwrap(); - assert_eq!(registry.count(), 2); - assert_eq!(registry.count_enabled(), 2); + assert_eq!(registry.count(), 2); + assert_eq!(registry.count_enabled(), 2); - registry.disable("plugin1").unwrap(); - assert_eq!(registry.count(), 2); - assert_eq!(registry.count_enabled(), 1); - } + registry.disable("plugin1").unwrap(); + assert_eq!(registry.count(), 2); + assert_eq!(registry.count_enabled(), 1); + } } diff --git a/crates/pinakes-core/src/plugin/runtime.rs b/crates/pinakes-core/src/plugin/runtime.rs index 41f6382..0b7ac12 100644 --- a/crates/pinakes-core/src/plugin/runtime.rs +++ b/crates/pinakes-core/src/plugin/runtime.rs @@ -1,579 +1,618 @@ //! WASM runtime for executing plugins +use std::{path::Path, sync::Arc}; + use anyhow::{Result, anyhow}; use pinakes_plugin_api::PluginContext; -use std::path::Path; -use std::sync::Arc; use wasmtime::*; /// WASM runtime wrapper for executing plugins pub struct WasmRuntime { - engine: Engine, + engine: Engine, } impl WasmRuntime { - /// Create a new WASM runtime - pub fn new() -> Result { - let mut config = Config::new(); + /// Create a new WASM runtime + pub fn new() -> Result { + let mut config = Config::new(); - // Enable WASM features - config.wasm_component_model(true); - config.async_support(true); + // Enable WASM features + config.wasm_component_model(true); + config.async_support(true); - // Set resource limits - config.max_wasm_stack(1024 * 1024); // 1MB stack - config.consume_fuel(true); // Enable fuel metering for CPU limits + // Set resource limits + config.max_wasm_stack(1024 * 1024); // 1MB stack + config.consume_fuel(true); // Enable fuel metering for CPU limits - let engine = Engine::new(&config)?; + let engine = Engine::new(&config)?; - Ok(Self { engine }) + Ok(Self { engine }) + } + + /// Load a plugin from a WASM file + pub async fn load_plugin( + &self, + wasm_path: &Path, + context: PluginContext, + ) -> Result { + if !wasm_path.exists() { + return Err(anyhow!("WASM file not found: {:?}", wasm_path)); } - /// Load a plugin from a WASM file - pub async fn load_plugin( - &self, - wasm_path: &Path, - context: PluginContext, - ) -> Result { - if !wasm_path.exists() { - return Err(anyhow!("WASM file not found: {:?}", wasm_path)); - } + // Read WASM bytes + let wasm_bytes = std::fs::read(wasm_path)?; - // Read WASM bytes - let wasm_bytes = std::fs::read(wasm_path)?; + // Compile module + let module = Module::new(&self.engine, &wasm_bytes)?; - // Compile module - let module = Module::new(&self.engine, &wasm_bytes)?; - - Ok(WasmPlugin { - module: Arc::new(module), - context, - }) - } + Ok(WasmPlugin { + module: Arc::new(module), + context, + }) + } } /// Store data passed to each WASM invocation pub struct PluginStoreData { - pub context: PluginContext, - pub exchange_buffer: Vec, + pub context: PluginContext, + pub exchange_buffer: Vec, } /// A loaded WASM plugin instance #[derive(Clone)] pub struct WasmPlugin { - module: Arc, - context: PluginContext, + module: Arc, + context: PluginContext, } impl WasmPlugin { - /// Get the plugin context - pub fn context(&self) -> &PluginContext { - &self.context + /// Get the plugin context + pub fn context(&self) -> &PluginContext { + &self.context + } + + /// Execute a plugin function + /// + /// Creates a fresh store and instance per invocation with host functions + /// linked, calls the requested exported function, and returns the result. + pub async fn call_function( + &self, + function_name: &str, + params: &[u8], + ) -> Result> { + let engine = self.module.engine(); + + // Create store with per-invocation data + let store_data = PluginStoreData { + context: self.context.clone(), + exchange_buffer: Vec::new(), + }; + let mut store = Store::new(engine, store_data); + + // Set fuel limit based on capabilities + if let Some(max_cpu_time_ms) = self.context.capabilities.max_cpu_time_ms { + let fuel = max_cpu_time_ms * 100_000; + store.set_fuel(fuel)?; + } else { + store.set_fuel(1_000_000_000)?; } - /// Execute a plugin function - /// - /// Creates a fresh store and instance per invocation with host functions - /// linked, calls the requested exported function, and returns the result. - pub async fn call_function(&self, function_name: &str, params: &[u8]) -> Result> { - let engine = self.module.engine(); + // Set up linker with host functions + let mut linker = Linker::new(engine); + HostFunctions::setup_linker(&mut linker)?; - // Create store with per-invocation data - let store_data = PluginStoreData { - context: self.context.clone(), - exchange_buffer: Vec::new(), - }; - let mut store = Store::new(engine, store_data); + // Instantiate the module + let instance = linker.instantiate_async(&mut store, &self.module).await?; - // Set fuel limit based on capabilities - if let Some(max_cpu_time_ms) = self.context.capabilities.max_cpu_time_ms { - let fuel = max_cpu_time_ms * 100_000; - store.set_fuel(fuel)?; - } else { - store.set_fuel(1_000_000_000)?; + // Get the memory export (if available) + let memory = instance.get_memory(&mut store, "memory"); + + // If there are params and memory is available, write them + let mut alloc_offset: i32 = 0; + if !params.is_empty() + && let Some(mem) = &memory + { + // Call the plugin's alloc function if available, otherwise write at + // offset 0 + let offset = if let Ok(alloc) = + instance.get_typed_func::(&mut store, "alloc") + { + let result = alloc.call_async(&mut store, params.len() as i32).await?; + if result < 0 { + return Err(anyhow!( + "plugin alloc returned negative offset: {}", + result + )); } + result as usize + } else { + 0 + }; - // Set up linker with host functions - let mut linker = Linker::new(engine); - HostFunctions::setup_linker(&mut linker)?; - - // Instantiate the module - let instance = linker.instantiate_async(&mut store, &self.module).await?; - - // Get the memory export (if available) - let memory = instance.get_memory(&mut store, "memory"); - - // If there are params and memory is available, write them - let mut alloc_offset: i32 = 0; - if !params.is_empty() - && let Some(mem) = &memory - { - // Call the plugin's alloc function if available, otherwise write at offset 0 - let offset = if let Ok(alloc) = instance.get_typed_func::(&mut store, "alloc") - { - let result = alloc.call_async(&mut store, params.len() as i32).await?; - if result < 0 { - return Err(anyhow!("plugin alloc returned negative offset: {}", result)); - } - result as usize - } else { - 0 - }; - - alloc_offset = offset as i32; - let mem_data = mem.data_mut(&mut store); - if offset + params.len() <= mem_data.len() { - mem_data[offset..offset + params.len()].copy_from_slice(params); - } - } - - // Look up the exported function and call it - let func = instance - .get_func(&mut store, function_name) - .ok_or_else(|| anyhow!("exported function '{}' not found", function_name))?; - - let func_ty = func.ty(&store); - let param_count = func_ty.params().len(); - let result_count = func_ty.results().len(); - - let mut results = vec![Val::I32(0); result_count]; - - // Call with appropriate params based on function signature - if param_count == 2 && !params.is_empty() { - // Convention: (ptr, len) - func.call_async( - &mut store, - &[Val::I32(alloc_offset), Val::I32(params.len() as i32)], - &mut results, - ) - .await?; - } else if param_count == 0 { - func.call_async(&mut store, &[], &mut results).await?; - } else { - // Generic: fill with zeroes - let params_vals: Vec = (0..param_count).map(|_| Val::I32(0)).collect(); - func.call_async(&mut store, ¶ms_vals, &mut results) - .await?; - } - - // Read result from exchange buffer (host functions may have written data) - let exchange = std::mem::take(&mut store.data_mut().exchange_buffer); - if !exchange.is_empty() { - return Ok(exchange); - } - - // Otherwise serialize the return values - if let Some(Val::I32(ret)) = results.first() { - Ok(ret.to_le_bytes().to_vec()) - } else { - Ok(Vec::new()) - } + alloc_offset = offset as i32; + let mem_data = mem.data_mut(&mut store); + if offset + params.len() <= mem_data.len() { + mem_data[offset..offset + params.len()].copy_from_slice(params); + } } + + // Look up the exported function and call it + let func = + instance + .get_func(&mut store, function_name) + .ok_or_else(|| { + anyhow!("exported function '{}' not found", function_name) + })?; + + let func_ty = func.ty(&store); + let param_count = func_ty.params().len(); + let result_count = func_ty.results().len(); + + let mut results = vec![Val::I32(0); result_count]; + + // Call with appropriate params based on function signature + if param_count == 2 && !params.is_empty() { + // Convention: (ptr, len) + func + .call_async( + &mut store, + &[Val::I32(alloc_offset), Val::I32(params.len() as i32)], + &mut results, + ) + .await?; + } else if param_count == 0 { + func.call_async(&mut store, &[], &mut results).await?; + } else { + // Generic: fill with zeroes + let params_vals: Vec = + (0..param_count).map(|_| Val::I32(0)).collect(); + func + .call_async(&mut store, ¶ms_vals, &mut results) + .await?; + } + + // Read result from exchange buffer (host functions may have written data) + let exchange = std::mem::take(&mut store.data_mut().exchange_buffer); + if !exchange.is_empty() { + return Ok(exchange); + } + + // Otherwise serialize the return values + if let Some(Val::I32(ret)) = results.first() { + Ok(ret.to_le_bytes().to_vec()) + } else { + Ok(Vec::new()) + } + } } #[cfg(test)] impl Default for WasmPlugin { - fn default() -> Self { - let engine = Engine::default(); - let module = Module::new(&engine, br#"(module)"#).unwrap(); + fn default() -> Self { + let engine = Engine::default(); + let module = Module::new(&engine, br#"(module)"#).unwrap(); - Self { - module: Arc::new(module), - context: PluginContext { - data_dir: std::env::temp_dir(), - cache_dir: std::env::temp_dir(), - config: std::collections::HashMap::new(), - capabilities: Default::default(), - }, - } + Self { + module: Arc::new(module), + context: PluginContext { + data_dir: std::env::temp_dir(), + cache_dir: std::env::temp_dir(), + config: std::collections::HashMap::new(), + capabilities: Default::default(), + }, } + } } /// Host functions that plugins can call pub struct HostFunctions; impl HostFunctions { - /// Set up host functions in a linker - pub fn setup_linker(linker: &mut Linker) -> Result<()> { - // host_log: log a message from the plugin - linker.func_wrap( - "env", - "host_log", - |mut caller: Caller<'_, PluginStoreData>, level: i32, ptr: i32, len: i32| { - if ptr < 0 || len < 0 { - return; + /// Set up host functions in a linker + pub fn setup_linker(linker: &mut Linker) -> Result<()> { + // host_log: log a message from the plugin + linker.func_wrap( + "env", + "host_log", + |mut caller: Caller<'_, PluginStoreData>, + level: i32, + ptr: i32, + len: i32| { + if ptr < 0 || len < 0 { + return; + } + let memory = caller.get_export("memory").and_then(|e| e.into_memory()); + if let Some(mem) = memory { + let data = mem.data(&caller); + let start = ptr as usize; + let end = start + len as usize; + if end <= data.len() + && let Ok(msg) = std::str::from_utf8(&data[start..end]) + { + match level { + 0 => tracing::error!(plugin = true, "{}", msg), + 1 => tracing::warn!(plugin = true, "{}", msg), + 2 => tracing::info!(plugin = true, "{}", msg), + _ => tracing::debug!(plugin = true, "{}", msg), + } + } + } + }, + )?; + + // host_read_file: read a file into the exchange buffer + linker.func_wrap( + "env", + "host_read_file", + |mut caller: Caller<'_, PluginStoreData>, + path_ptr: i32, + path_len: i32| + -> i32 { + if path_ptr < 0 || path_len < 0 { + return -1; + } + let memory = caller.get_export("memory").and_then(|e| e.into_memory()); + let Some(mem) = memory else { return -1 }; + + let data = mem.data(&caller); + let start = path_ptr as usize; + let end = start + path_len as usize; + if end > data.len() { + return -1; + } + + let path_str = match std::str::from_utf8(&data[start..end]) { + Ok(s) => s.to_string(), + Err(_) => return -1, + }; + + // Canonicalize path before checking permissions to prevent traversal + let path = match std::path::Path::new(&path_str).canonicalize() { + Ok(p) => p, + Err(_) => return -1, + }; + + // Check read permission against canonicalized path + let can_read = caller + .data() + .context + .capabilities + .filesystem + .read + .iter() + .any(|allowed| { + allowed.canonicalize().is_ok_and(|a| path.starts_with(a)) + }); + + if !can_read { + tracing::warn!(path = %path_str, "plugin read access denied"); + return -2; + } + + match std::fs::read(&path) { + Ok(contents) => { + let len = contents.len() as i32; + caller.data_mut().exchange_buffer = contents; + len + }, + Err(_) => -1, + } + }, + )?; + + // host_write_file: write data to a file + linker.func_wrap( + "env", + "host_write_file", + |mut caller: Caller<'_, PluginStoreData>, + path_ptr: i32, + path_len: i32, + data_ptr: i32, + data_len: i32| + -> i32 { + if path_ptr < 0 || path_len < 0 || data_ptr < 0 || data_len < 0 { + return -1; + } + let memory = caller.get_export("memory").and_then(|e| e.into_memory()); + let Some(mem) = memory else { return -1 }; + + let mem_data = mem.data(&caller); + let path_start = path_ptr as usize; + let path_end = path_start + path_len as usize; + let data_start = data_ptr as usize; + let data_end = data_start + data_len as usize; + + if path_end > mem_data.len() || data_end > mem_data.len() { + return -1; + } + + let path_str = + match std::str::from_utf8(&mem_data[path_start..path_end]) { + Ok(s) => s.to_string(), + Err(_) => return -1, + }; + let file_data = mem_data[data_start..data_end].to_vec(); + + // Canonicalize path for write (file may not exist yet) + let path = std::path::Path::new(&path_str); + let canonical = if path.exists() { + path.canonicalize().ok() + } else { + path + .parent() + .and_then(|p| p.canonicalize().ok()) + .map(|p| p.join(path.file_name().unwrap_or_default())) + }; + let Some(canonical) = canonical else { + return -1; + }; + + // Check write permission against canonicalized path + let can_write = caller + .data() + .context + .capabilities + .filesystem + .write + .iter() + .any(|allowed| { + allowed + .canonicalize() + .is_ok_and(|a| canonical.starts_with(a)) + }); + + if !can_write { + tracing::warn!(path = %path_str, "plugin write access denied"); + return -2; + } + + match std::fs::write(&canonical, &file_data) { + Ok(()) => 0, + Err(_) => -1, + } + }, + )?; + + // host_http_request: make an HTTP request (blocking) + linker.func_wrap( + "env", + "host_http_request", + |mut caller: Caller<'_, PluginStoreData>, + url_ptr: i32, + url_len: i32| + -> i32 { + if url_ptr < 0 || url_len < 0 { + return -1; + } + let memory = caller.get_export("memory").and_then(|e| e.into_memory()); + let Some(mem) = memory else { return -1 }; + + let data = mem.data(&caller); + let start = url_ptr as usize; + let end = start + url_len as usize; + if end > data.len() { + return -1; + } + + let url_str = match std::str::from_utf8(&data[start..end]) { + Ok(s) => s.to_string(), + Err(_) => return -1, + }; + + // Check network permission + if !caller.data().context.capabilities.network.enabled { + tracing::warn!(url = %url_str, "plugin network access denied"); + return -2; + } + + // Use block_in_place to avoid blocking the async runtime's thread pool. + // Falls back to a blocking client with timeout if block_in_place is + // unavailable. + let result = std::panic::catch_unwind(|| { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| e.to_string())?; + let resp = client + .get(&url_str) + .send() + .await + .map_err(|e| e.to_string())?; + let bytes = resp.bytes().await.map_err(|e| e.to_string())?; + Ok::<_, String>(bytes) + }) + }) + }); + + match result { + Ok(Ok(bytes)) => { + let len = bytes.len() as i32; + caller.data_mut().exchange_buffer = bytes.to_vec(); + len + }, + Ok(Err(_)) => -1, + Err(_) => { + // block_in_place panicked (e.g. current-thread runtime); + // fall back to blocking client with timeout + let client = match reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + { + Ok(c) => c, + Err(_) => return -1, + }; + match client.get(&url_str).send() { + Ok(resp) => { + match resp.bytes() { + Ok(bytes) => { + let len = bytes.len() as i32; + caller.data_mut().exchange_buffer = bytes.to_vec(); + len + }, + Err(_) => -1, } - let memory = caller.get_export("memory").and_then(|e| e.into_memory()); - if let Some(mem) = memory { - let data = mem.data(&caller); - let start = ptr as usize; - let end = start + len as usize; - if end <= data.len() - && let Ok(msg) = std::str::from_utf8(&data[start..end]) - { - match level { - 0 => tracing::error!(plugin = true, "{}", msg), - 1 => tracing::warn!(plugin = true, "{}", msg), - 2 => tracing::info!(plugin = true, "{}", msg), - _ => tracing::debug!(plugin = true, "{}", msg), - } - } - } - }, - )?; + }, + Err(_) => -1, + } + }, + } + }, + )?; - // host_read_file: read a file into the exchange buffer - linker.func_wrap( - "env", - "host_read_file", - |mut caller: Caller<'_, PluginStoreData>, path_ptr: i32, path_len: i32| -> i32 { - if path_ptr < 0 || path_len < 0 { - return -1; - } - let memory = caller.get_export("memory").and_then(|e| e.into_memory()); - let Some(mem) = memory else { return -1 }; + // host_get_config: read a config key into the exchange buffer + linker.func_wrap( + "env", + "host_get_config", + |mut caller: Caller<'_, PluginStoreData>, + key_ptr: i32, + key_len: i32| + -> i32 { + if key_ptr < 0 || key_len < 0 { + return -1; + } + let memory = caller.get_export("memory").and_then(|e| e.into_memory()); + let Some(mem) = memory else { return -1 }; - let data = mem.data(&caller); - let start = path_ptr as usize; - let end = start + path_len as usize; - if end > data.len() { - return -1; - } + let data = mem.data(&caller); + let start = key_ptr as usize; + let end = start + key_len as usize; + if end > data.len() { + return -1; + } - let path_str = match std::str::from_utf8(&data[start..end]) { - Ok(s) => s.to_string(), - Err(_) => return -1, - }; + let key_str = match std::str::from_utf8(&data[start..end]) { + Ok(s) => s.to_string(), + Err(_) => return -1, + }; - // Canonicalize path before checking permissions to prevent traversal - let path = match std::path::Path::new(&path_str).canonicalize() { - Ok(p) => p, - Err(_) => return -1, - }; + match caller.data().context.config.get(&key_str) { + Some(value) => { + let json = value.to_string(); + let bytes = json.into_bytes(); + let len = bytes.len() as i32; + caller.data_mut().exchange_buffer = bytes; + len + }, + None => -1, + } + }, + )?; - // Check read permission against canonicalized path - let can_read = caller - .data() - .context - .capabilities - .filesystem - .read - .iter() - .any(|allowed| allowed.canonicalize().is_ok_and(|a| path.starts_with(a))); + // host_get_buffer: copy the exchange buffer to WASM memory + linker.func_wrap( + "env", + "host_get_buffer", + |mut caller: Caller<'_, PluginStoreData>, + dest_ptr: i32, + dest_len: i32| + -> i32 { + if dest_ptr < 0 || dest_len < 0 { + return -1; + } + let buf = caller.data().exchange_buffer.clone(); + let copy_len = buf.len().min(dest_len as usize); - if !can_read { - tracing::warn!(path = %path_str, "plugin read access denied"); - return -2; - } + let memory = caller.get_export("memory").and_then(|e| e.into_memory()); + let Some(mem) = memory else { return -1 }; - match std::fs::read(&path) { - Ok(contents) => { - let len = contents.len() as i32; - caller.data_mut().exchange_buffer = contents; - len - } - Err(_) => -1, - } - }, - )?; + let mem_data = mem.data_mut(&mut caller); + let start = dest_ptr as usize; + if start + copy_len > mem_data.len() { + return -1; + } - // host_write_file: write data to a file - linker.func_wrap( - "env", - "host_write_file", - |mut caller: Caller<'_, PluginStoreData>, - path_ptr: i32, - path_len: i32, - data_ptr: i32, - data_len: i32| - -> i32 { - if path_ptr < 0 || path_len < 0 || data_ptr < 0 || data_len < 0 { - return -1; - } - let memory = caller.get_export("memory").and_then(|e| e.into_memory()); - let Some(mem) = memory else { return -1 }; + mem_data[start..start + copy_len].copy_from_slice(&buf[..copy_len]); + copy_len as i32 + }, + )?; - let mem_data = mem.data(&caller); - let path_start = path_ptr as usize; - let path_end = path_start + path_len as usize; - let data_start = data_ptr as usize; - let data_end = data_start + data_len as usize; - - if path_end > mem_data.len() || data_end > mem_data.len() { - return -1; - } - - let path_str = match std::str::from_utf8(&mem_data[path_start..path_end]) { - Ok(s) => s.to_string(), - Err(_) => return -1, - }; - let file_data = mem_data[data_start..data_end].to_vec(); - - // Canonicalize path for write (file may not exist yet) - let path = std::path::Path::new(&path_str); - let canonical = if path.exists() { - path.canonicalize().ok() - } else { - path.parent() - .and_then(|p| p.canonicalize().ok()) - .map(|p| p.join(path.file_name().unwrap_or_default())) - }; - let Some(canonical) = canonical else { - return -1; - }; - - // Check write permission against canonicalized path - let can_write = caller - .data() - .context - .capabilities - .filesystem - .write - .iter() - .any(|allowed| { - allowed - .canonicalize() - .is_ok_and(|a| canonical.starts_with(a)) - }); - - if !can_write { - tracing::warn!(path = %path_str, "plugin write access denied"); - return -2; - } - - match std::fs::write(&canonical, &file_data) { - Ok(()) => 0, - Err(_) => -1, - } - }, - )?; - - // host_http_request: make an HTTP request (blocking) - linker.func_wrap( - "env", - "host_http_request", - |mut caller: Caller<'_, PluginStoreData>, url_ptr: i32, url_len: i32| -> i32 { - if url_ptr < 0 || url_len < 0 { - return -1; - } - let memory = caller.get_export("memory").and_then(|e| e.into_memory()); - let Some(mem) = memory else { return -1 }; - - let data = mem.data(&caller); - let start = url_ptr as usize; - let end = start + url_len as usize; - if end > data.len() { - return -1; - } - - let url_str = match std::str::from_utf8(&data[start..end]) { - Ok(s) => s.to_string(), - Err(_) => return -1, - }; - - // Check network permission - if !caller.data().context.capabilities.network.enabled { - tracing::warn!(url = %url_str, "plugin network access denied"); - return -2; - } - - // Use block_in_place to avoid blocking the async runtime's thread pool. - // Falls back to a blocking client with timeout if block_in_place is unavailable. - let result = std::panic::catch_unwind(|| { - tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .map_err(|e| e.to_string())?; - let resp = client - .get(&url_str) - .send() - .await - .map_err(|e| e.to_string())?; - let bytes = resp.bytes().await.map_err(|e| e.to_string())?; - Ok::<_, String>(bytes) - }) - }) - }); - - match result { - Ok(Ok(bytes)) => { - let len = bytes.len() as i32; - caller.data_mut().exchange_buffer = bytes.to_vec(); - len - } - Ok(Err(_)) => -1, - Err(_) => { - // block_in_place panicked (e.g. current-thread runtime); - // fall back to blocking client with timeout - let client = match reqwest::blocking::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - { - Ok(c) => c, - Err(_) => return -1, - }; - match client.get(&url_str).send() { - Ok(resp) => match resp.bytes() { - Ok(bytes) => { - let len = bytes.len() as i32; - caller.data_mut().exchange_buffer = bytes.to_vec(); - len - } - Err(_) => -1, - }, - Err(_) => -1, - } - } - } - }, - )?; - - // host_get_config: read a config key into the exchange buffer - linker.func_wrap( - "env", - "host_get_config", - |mut caller: Caller<'_, PluginStoreData>, key_ptr: i32, key_len: i32| -> i32 { - if key_ptr < 0 || key_len < 0 { - return -1; - } - let memory = caller.get_export("memory").and_then(|e| e.into_memory()); - let Some(mem) = memory else { return -1 }; - - let data = mem.data(&caller); - let start = key_ptr as usize; - let end = start + key_len as usize; - if end > data.len() { - return -1; - } - - let key_str = match std::str::from_utf8(&data[start..end]) { - Ok(s) => s.to_string(), - Err(_) => return -1, - }; - - match caller.data().context.config.get(&key_str) { - Some(value) => { - let json = value.to_string(); - let bytes = json.into_bytes(); - let len = bytes.len() as i32; - caller.data_mut().exchange_buffer = bytes; - len - } - None => -1, - } - }, - )?; - - // host_get_buffer: copy the exchange buffer to WASM memory - linker.func_wrap( - "env", - "host_get_buffer", - |mut caller: Caller<'_, PluginStoreData>, dest_ptr: i32, dest_len: i32| -> i32 { - if dest_ptr < 0 || dest_len < 0 { - return -1; - } - let buf = caller.data().exchange_buffer.clone(); - let copy_len = buf.len().min(dest_len as usize); - - let memory = caller.get_export("memory").and_then(|e| e.into_memory()); - let Some(mem) = memory else { return -1 }; - - let mem_data = mem.data_mut(&mut caller); - let start = dest_ptr as usize; - if start + copy_len > mem_data.len() { - return -1; - } - - mem_data[start..start + copy_len].copy_from_slice(&buf[..copy_len]); - copy_len as i32 - }, - )?; - - Ok(()) - } + Ok(()) + } } #[cfg(test)] mod tests { - use super::*; - use pinakes_plugin_api::PluginContext; - use std::collections::HashMap; + use std::collections::HashMap; - #[test] - fn test_wasm_runtime_creation() { - let runtime = WasmRuntime::new(); - assert!(runtime.is_ok()); - } + use pinakes_plugin_api::PluginContext; - #[test] - fn test_host_functions_file_access() { - let mut capabilities = pinakes_plugin_api::Capabilities::default(); - capabilities.filesystem.read.push("/tmp".into()); - capabilities.filesystem.write.push("/tmp/output".into()); + use super::*; - let context = PluginContext { - data_dir: "/tmp/data".into(), - cache_dir: "/tmp/cache".into(), - config: HashMap::new(), - capabilities, - }; + #[test] + fn test_wasm_runtime_creation() { + let runtime = WasmRuntime::new(); + assert!(runtime.is_ok()); + } - // Verify capability checks work via context fields - let can_read = context - .capabilities - .filesystem - .read - .iter() - .any(|p| Path::new("/tmp/test.txt").starts_with(p)); - assert!(can_read); + #[test] + fn test_host_functions_file_access() { + let mut capabilities = pinakes_plugin_api::Capabilities::default(); + capabilities.filesystem.read.push("/tmp".into()); + capabilities.filesystem.write.push("/tmp/output".into()); - let cant_read = context - .capabilities - .filesystem - .read - .iter() - .any(|p| Path::new("/etc/passwd").starts_with(p)); - assert!(!cant_read); + let context = PluginContext { + data_dir: "/tmp/data".into(), + cache_dir: "/tmp/cache".into(), + config: HashMap::new(), + capabilities, + }; - let can_write = context - .capabilities - .filesystem - .write - .iter() - .any(|p| Path::new("/tmp/output/file.txt").starts_with(p)); - assert!(can_write); + // Verify capability checks work via context fields + let can_read = context + .capabilities + .filesystem + .read + .iter() + .any(|p| Path::new("/tmp/test.txt").starts_with(p)); + assert!(can_read); - let cant_write = context - .capabilities - .filesystem - .write - .iter() - .any(|p| Path::new("/tmp/file.txt").starts_with(p)); - assert!(!cant_write); - } + let cant_read = context + .capabilities + .filesystem + .read + .iter() + .any(|p| Path::new("/etc/passwd").starts_with(p)); + assert!(!cant_read); - #[test] - fn test_host_functions_network_access() { - let mut context = PluginContext { - data_dir: "/tmp/data".into(), - cache_dir: "/tmp/cache".into(), - config: HashMap::new(), - capabilities: Default::default(), - }; + let can_write = context + .capabilities + .filesystem + .write + .iter() + .any(|p| Path::new("/tmp/output/file.txt").starts_with(p)); + assert!(can_write); - assert!(!context.capabilities.network.enabled); + let cant_write = context + .capabilities + .filesystem + .write + .iter() + .any(|p| Path::new("/tmp/file.txt").starts_with(p)); + assert!(!cant_write); + } - context.capabilities.network.enabled = true; - assert!(context.capabilities.network.enabled); - } + #[test] + fn test_host_functions_network_access() { + let mut context = PluginContext { + data_dir: "/tmp/data".into(), + cache_dir: "/tmp/cache".into(), + config: HashMap::new(), + capabilities: Default::default(), + }; - #[test] - fn test_linker_setup() { - let engine = Engine::default(); - let mut linker = Linker::::new(&engine); - let result = HostFunctions::setup_linker(&mut linker); - assert!(result.is_ok()); - } + assert!(!context.capabilities.network.enabled); + + context.capabilities.network.enabled = true; + assert!(context.capabilities.network.enabled); + } + + #[test] + fn test_linker_setup() { + let engine = Engine::default(); + let mut linker = Linker::::new(&engine); + let result = HostFunctions::setup_linker(&mut linker); + assert!(result.is_ok()); + } } diff --git a/crates/pinakes-core/src/plugin/security.rs b/crates/pinakes-core/src/plugin/security.rs index 6d4c458..9948695 100644 --- a/crates/pinakes-core/src/plugin/security.rs +++ b/crates/pinakes-core/src/plugin/security.rs @@ -1,341 +1,357 @@ //! Capability-based security for plugins +use std::path::{Path, PathBuf}; + use anyhow::{Result, anyhow}; use pinakes_plugin_api::Capabilities; -use std::path::{Path, PathBuf}; /// Capability enforcer validates and enforces plugin capabilities pub struct CapabilityEnforcer { - /// Maximum allowed memory per plugin (bytes) - max_memory_limit: usize, + /// Maximum allowed memory per plugin (bytes) + max_memory_limit: usize, - /// Maximum allowed CPU time per plugin (milliseconds) - max_cpu_time_limit: u64, + /// Maximum allowed CPU time per plugin (milliseconds) + max_cpu_time_limit: u64, - /// Allowed filesystem read paths (system-wide) - allowed_read_paths: Vec, + /// Allowed filesystem read paths (system-wide) + allowed_read_paths: Vec, - /// Allowed filesystem write paths (system-wide) - allowed_write_paths: Vec, + /// Allowed filesystem write paths (system-wide) + allowed_write_paths: Vec, - /// Whether to allow network access by default - allow_network_default: bool, + /// Whether to allow network access by default + allow_network_default: bool, } impl CapabilityEnforcer { - /// Create a new capability enforcer with default limits - pub fn new() -> Self { - Self { - max_memory_limit: 512 * 1024 * 1024, // 512 MB - max_cpu_time_limit: 60 * 1000, // 60 seconds - allowed_read_paths: vec![], - allowed_write_paths: vec![], - allow_network_default: false, - } + /// Create a new capability enforcer with default limits + pub fn new() -> Self { + Self { + max_memory_limit: 512 * 1024 * 1024, // 512 MB + max_cpu_time_limit: 60 * 1000, // 60 seconds + allowed_read_paths: vec![], + allowed_write_paths: vec![], + allow_network_default: false, + } + } + + /// Set maximum memory limit + pub fn with_max_memory(mut self, bytes: usize) -> Self { + self.max_memory_limit = bytes; + self + } + + /// Set maximum CPU time limit + pub fn with_max_cpu_time(mut self, milliseconds: u64) -> Self { + self.max_cpu_time_limit = milliseconds; + self + } + + /// Add allowed read path + pub fn allow_read_path(mut self, path: PathBuf) -> Self { + self.allowed_read_paths.push(path); + self + } + + /// Add allowed write path + pub fn allow_write_path(mut self, path: PathBuf) -> Self { + self.allowed_write_paths.push(path); + self + } + + /// Set default network access policy + pub fn with_network_default(mut self, allow: bool) -> Self { + self.allow_network_default = allow; + self + } + + /// Validate capabilities requested by a plugin + pub fn validate_capabilities( + &self, + capabilities: &Capabilities, + ) -> Result<()> { + // Validate memory limit + if let Some(memory) = capabilities.max_memory_bytes + && memory > self.max_memory_limit + { + return Err(anyhow!( + "Requested memory ({} bytes) exceeds limit ({} bytes)", + memory, + self.max_memory_limit + )); } - /// Set maximum memory limit - pub fn with_max_memory(mut self, bytes: usize) -> Self { - self.max_memory_limit = bytes; - self + // Validate CPU time limit + if let Some(cpu_time) = capabilities.max_cpu_time_ms + && cpu_time > self.max_cpu_time_limit + { + return Err(anyhow!( + "Requested CPU time ({} ms) exceeds limit ({} ms)", + cpu_time, + self.max_cpu_time_limit + )); } - /// Set maximum CPU time limit - pub fn with_max_cpu_time(mut self, milliseconds: u64) -> Self { - self.max_cpu_time_limit = milliseconds; - self + // Validate filesystem access + self.validate_filesystem_access(capabilities)?; + + // Validate network access + if capabilities.network.enabled && !self.allow_network_default { + return Err(anyhow!( + "Plugin requests network access, but network access is disabled by \ + policy" + )); } - /// Add allowed read path - pub fn allow_read_path(mut self, path: PathBuf) -> Self { - self.allowed_read_paths.push(path); - self + Ok(()) + } + + /// Validate filesystem access capabilities + fn validate_filesystem_access( + &self, + capabilities: &Capabilities, + ) -> Result<()> { + // Check read paths + for path in &capabilities.filesystem.read { + if !self.is_read_allowed(path) { + return Err(anyhow!( + "Plugin requests read access to {:?} which is not in allowed paths", + path + )); + } } - /// Add allowed write path - pub fn allow_write_path(mut self, path: PathBuf) -> Self { - self.allowed_write_paths.push(path); - self + // Check write paths + for path in &capabilities.filesystem.write { + if !self.is_write_allowed(path) { + return Err(anyhow!( + "Plugin requests write access to {:?} which is not in allowed paths", + path + )); + } } - /// Set default network access policy - pub fn with_network_default(mut self, allow: bool) -> Self { - self.allow_network_default = allow; - self + Ok(()) + } + + /// Check if a path is allowed for reading + pub fn is_read_allowed(&self, path: &Path) -> bool { + if self.allowed_read_paths.is_empty() { + return false; // deny-all when unconfigured + } + let Ok(canonical) = path.canonicalize() else { + return false; + }; + self.allowed_read_paths.iter().any(|allowed| { + allowed + .canonicalize() + .is_ok_and(|a| canonical.starts_with(a)) + }) + } + + /// Check if a path is allowed for writing + pub fn is_write_allowed(&self, path: &Path) -> bool { + if self.allowed_write_paths.is_empty() { + return false; // deny-all when unconfigured + } + let canonical = if path.exists() { + path.canonicalize().ok() + } else { + path + .parent() + .and_then(|p| p.canonicalize().ok()) + .map(|p| p.join(path.file_name().unwrap_or_default())) + }; + let Some(canonical) = canonical else { + return false; + }; + self.allowed_write_paths.iter().any(|allowed| { + allowed + .canonicalize() + .is_ok_and(|a| canonical.starts_with(a)) + }) + } + + /// Check if network access is allowed for a plugin + pub fn is_network_allowed(&self, capabilities: &Capabilities) -> bool { + capabilities.network.enabled && self.allow_network_default + } + + /// Check if a specific domain is allowed + pub fn is_domain_allowed( + &self, + capabilities: &Capabilities, + domain: &str, + ) -> bool { + if !capabilities.network.enabled { + return false; } - /// Validate capabilities requested by a plugin - pub fn validate_capabilities(&self, capabilities: &Capabilities) -> Result<()> { - // Validate memory limit - if let Some(memory) = capabilities.max_memory_bytes - && memory > self.max_memory_limit - { - return Err(anyhow!( - "Requested memory ({} bytes) exceeds limit ({} bytes)", - memory, - self.max_memory_limit - )); - } - - // Validate CPU time limit - if let Some(cpu_time) = capabilities.max_cpu_time_ms - && cpu_time > self.max_cpu_time_limit - { - return Err(anyhow!( - "Requested CPU time ({} ms) exceeds limit ({} ms)", - cpu_time, - self.max_cpu_time_limit - )); - } - - // Validate filesystem access - self.validate_filesystem_access(capabilities)?; - - // Validate network access - if capabilities.network.enabled && !self.allow_network_default { - return Err(anyhow!( - "Plugin requests network access, but network access is disabled by policy" - )); - } - - Ok(()) + // If no domain restrictions, allow all domains + if capabilities.network.allowed_domains.is_none() { + return self.allow_network_default; } - /// Validate filesystem access capabilities - fn validate_filesystem_access(&self, capabilities: &Capabilities) -> Result<()> { - // Check read paths - for path in &capabilities.filesystem.read { - if !self.is_read_allowed(path) { - return Err(anyhow!( - "Plugin requests read access to {:?} which is not in allowed paths", - path - )); - } - } + // Check against allowed domains list + capabilities + .network + .allowed_domains + .as_ref() + .map(|domains| domains.iter().any(|d| d.eq_ignore_ascii_case(domain))) + .unwrap_or(false) + } - // Check write paths - for path in &capabilities.filesystem.write { - if !self.is_write_allowed(path) { - return Err(anyhow!( - "Plugin requests write access to {:?} which is not in allowed paths", - path - )); - } - } + /// Get effective memory limit for a plugin + pub fn get_memory_limit(&self, capabilities: &Capabilities) -> usize { + capabilities + .max_memory_bytes + .unwrap_or(self.max_memory_limit) + .min(self.max_memory_limit) + } - Ok(()) - } - - /// Check if a path is allowed for reading - pub fn is_read_allowed(&self, path: &Path) -> bool { - if self.allowed_read_paths.is_empty() { - return false; // deny-all when unconfigured - } - let Ok(canonical) = path.canonicalize() else { - return false; - }; - self.allowed_read_paths.iter().any(|allowed| { - allowed - .canonicalize() - .is_ok_and(|a| canonical.starts_with(a)) - }) - } - - /// Check if a path is allowed for writing - pub fn is_write_allowed(&self, path: &Path) -> bool { - if self.allowed_write_paths.is_empty() { - return false; // deny-all when unconfigured - } - let canonical = if path.exists() { - path.canonicalize().ok() - } else { - path.parent() - .and_then(|p| p.canonicalize().ok()) - .map(|p| p.join(path.file_name().unwrap_or_default())) - }; - let Some(canonical) = canonical else { - return false; - }; - self.allowed_write_paths.iter().any(|allowed| { - allowed - .canonicalize() - .is_ok_and(|a| canonical.starts_with(a)) - }) - } - - /// Check if network access is allowed for a plugin - pub fn is_network_allowed(&self, capabilities: &Capabilities) -> bool { - capabilities.network.enabled && self.allow_network_default - } - - /// Check if a specific domain is allowed - pub fn is_domain_allowed(&self, capabilities: &Capabilities, domain: &str) -> bool { - if !capabilities.network.enabled { - return false; - } - - // If no domain restrictions, allow all domains - if capabilities.network.allowed_domains.is_none() { - return self.allow_network_default; - } - - // Check against allowed domains list - capabilities - .network - .allowed_domains - .as_ref() - .map(|domains| domains.iter().any(|d| d.eq_ignore_ascii_case(domain))) - .unwrap_or(false) - } - - /// Get effective memory limit for a plugin - pub fn get_memory_limit(&self, capabilities: &Capabilities) -> usize { - capabilities - .max_memory_bytes - .unwrap_or(self.max_memory_limit) - .min(self.max_memory_limit) - } - - /// Get effective CPU time limit for a plugin - pub fn get_cpu_time_limit(&self, capabilities: &Capabilities) -> u64 { - capabilities - .max_cpu_time_ms - .unwrap_or(self.max_cpu_time_limit) - .min(self.max_cpu_time_limit) - } + /// Get effective CPU time limit for a plugin + pub fn get_cpu_time_limit(&self, capabilities: &Capabilities) -> u64 { + capabilities + .max_cpu_time_ms + .unwrap_or(self.max_cpu_time_limit) + .min(self.max_cpu_time_limit) + } } impl Default for CapabilityEnforcer { - fn default() -> Self { - Self::new() - } + fn default() -> Self { + Self::new() + } } #[cfg(test)] mod tests { - use super::*; - #[allow(unused_imports)] - use pinakes_plugin_api::{FilesystemCapability, NetworkCapability}; + #[allow(unused_imports)] + use pinakes_plugin_api::{FilesystemCapability, NetworkCapability}; - #[test] - fn test_validate_memory_limit() { - let enforcer = CapabilityEnforcer::new().with_max_memory(100 * 1024 * 1024); // 100 MB + use super::*; - let mut caps = Capabilities::default(); - caps.max_memory_bytes = Some(50 * 1024 * 1024); // 50 MB - OK - assert!(enforcer.validate_capabilities(&caps).is_ok()); + #[test] + fn test_validate_memory_limit() { + let enforcer = CapabilityEnforcer::new().with_max_memory(100 * 1024 * 1024); // 100 MB - caps.max_memory_bytes = Some(200 * 1024 * 1024); // 200 MB - exceeds limit - assert!(enforcer.validate_capabilities(&caps).is_err()); - } + let mut caps = Capabilities::default(); + caps.max_memory_bytes = Some(50 * 1024 * 1024); // 50 MB - OK + assert!(enforcer.validate_capabilities(&caps).is_ok()); - #[test] - fn test_validate_cpu_time_limit() { - let enforcer = CapabilityEnforcer::new().with_max_cpu_time(30_000); // 30 seconds + caps.max_memory_bytes = Some(200 * 1024 * 1024); // 200 MB - exceeds limit + assert!(enforcer.validate_capabilities(&caps).is_err()); + } - let mut caps = Capabilities::default(); - caps.max_cpu_time_ms = Some(10_000); // 10 seconds - OK - assert!(enforcer.validate_capabilities(&caps).is_ok()); + #[test] + fn test_validate_cpu_time_limit() { + let enforcer = CapabilityEnforcer::new().with_max_cpu_time(30_000); // 30 seconds - caps.max_cpu_time_ms = Some(60_000); // 60 seconds - exceeds limit - assert!(enforcer.validate_capabilities(&caps).is_err()); - } + let mut caps = Capabilities::default(); + caps.max_cpu_time_ms = Some(10_000); // 10 seconds - OK + assert!(enforcer.validate_capabilities(&caps).is_ok()); - #[test] - fn test_filesystem_read_allowed() { - // Use real temp directories so canonicalize works - let tmp = tempfile::tempdir().unwrap(); - let allowed_dir = tmp.path().join("allowed"); - std::fs::create_dir_all(&allowed_dir).unwrap(); - let test_file = allowed_dir.join("test.txt"); - std::fs::write(&test_file, "test").unwrap(); + caps.max_cpu_time_ms = Some(60_000); // 60 seconds - exceeds limit + assert!(enforcer.validate_capabilities(&caps).is_err()); + } - let enforcer = CapabilityEnforcer::new().allow_read_path(allowed_dir.clone()); + #[test] + fn test_filesystem_read_allowed() { + // Use real temp directories so canonicalize works + let tmp = tempfile::tempdir().unwrap(); + let allowed_dir = tmp.path().join("allowed"); + std::fs::create_dir_all(&allowed_dir).unwrap(); + let test_file = allowed_dir.join("test.txt"); + std::fs::write(&test_file, "test").unwrap(); - assert!(enforcer.is_read_allowed(&test_file)); - assert!(!enforcer.is_read_allowed(Path::new("/etc/passwd"))); - } + let enforcer = + CapabilityEnforcer::new().allow_read_path(allowed_dir.clone()); - #[test] - fn test_filesystem_read_denied_when_empty() { - let enforcer = CapabilityEnforcer::new(); - assert!(!enforcer.is_read_allowed(Path::new("/tmp/test.txt"))); - } + assert!(enforcer.is_read_allowed(&test_file)); + assert!(!enforcer.is_read_allowed(Path::new("/etc/passwd"))); + } - #[test] - fn test_filesystem_write_allowed() { - let tmp = tempfile::tempdir().unwrap(); - let output_dir = tmp.path().join("output"); - std::fs::create_dir_all(&output_dir).unwrap(); - // Existing file in allowed dir - let existing = output_dir.join("file.txt"); - std::fs::write(&existing, "test").unwrap(); + #[test] + fn test_filesystem_read_denied_when_empty() { + let enforcer = CapabilityEnforcer::new(); + assert!(!enforcer.is_read_allowed(Path::new("/tmp/test.txt"))); + } - let enforcer = CapabilityEnforcer::new().allow_write_path(output_dir.clone()); + #[test] + fn test_filesystem_write_allowed() { + let tmp = tempfile::tempdir().unwrap(); + let output_dir = tmp.path().join("output"); + std::fs::create_dir_all(&output_dir).unwrap(); + // Existing file in allowed dir + let existing = output_dir.join("file.txt"); + std::fs::write(&existing, "test").unwrap(); - assert!(enforcer.is_write_allowed(&existing)); - // New file in allowed dir (parent exists) - assert!(enforcer.is_write_allowed(&output_dir.join("new_file.txt"))); - assert!(!enforcer.is_write_allowed(Path::new("/etc/config"))); - } + let enforcer = + CapabilityEnforcer::new().allow_write_path(output_dir.clone()); - #[test] - fn test_filesystem_write_denied_when_empty() { - let enforcer = CapabilityEnforcer::new(); - assert!(!enforcer.is_write_allowed(Path::new("/tmp/file.txt"))); - } + assert!(enforcer.is_write_allowed(&existing)); + // New file in allowed dir (parent exists) + assert!(enforcer.is_write_allowed(&output_dir.join("new_file.txt"))); + assert!(!enforcer.is_write_allowed(Path::new("/etc/config"))); + } - #[test] - fn test_network_allowed() { - let enforcer = CapabilityEnforcer::new().with_network_default(true); + #[test] + fn test_filesystem_write_denied_when_empty() { + let enforcer = CapabilityEnforcer::new(); + assert!(!enforcer.is_write_allowed(Path::new("/tmp/file.txt"))); + } - let mut caps = Capabilities::default(); - caps.network.enabled = true; + #[test] + fn test_network_allowed() { + let enforcer = CapabilityEnforcer::new().with_network_default(true); - assert!(enforcer.is_network_allowed(&caps)); + let mut caps = Capabilities::default(); + caps.network.enabled = true; - caps.network.enabled = false; - assert!(!enforcer.is_network_allowed(&caps)); - } + assert!(enforcer.is_network_allowed(&caps)); - #[test] - fn test_domain_restrictions() { - let enforcer = CapabilityEnforcer::new().with_network_default(true); + caps.network.enabled = false; + assert!(!enforcer.is_network_allowed(&caps)); + } - let mut caps = Capabilities::default(); - caps.network.enabled = true; - caps.network.allowed_domains = Some(vec![ - "api.example.com".to_string(), - "cdn.example.com".to_string(), - ]); + #[test] + fn test_domain_restrictions() { + let enforcer = CapabilityEnforcer::new().with_network_default(true); - assert!(enforcer.is_domain_allowed(&caps, "api.example.com")); - assert!(enforcer.is_domain_allowed(&caps, "cdn.example.com")); - assert!(!enforcer.is_domain_allowed(&caps, "evil.com")); - } + let mut caps = Capabilities::default(); + caps.network.enabled = true; + caps.network.allowed_domains = Some(vec![ + "api.example.com".to_string(), + "cdn.example.com".to_string(), + ]); - #[test] - fn test_get_effective_limits() { - let enforcer = CapabilityEnforcer::new() - .with_max_memory(100 * 1024 * 1024) - .with_max_cpu_time(30_000); + assert!(enforcer.is_domain_allowed(&caps, "api.example.com")); + assert!(enforcer.is_domain_allowed(&caps, "cdn.example.com")); + assert!(!enforcer.is_domain_allowed(&caps, "evil.com")); + } - let mut caps = Capabilities::default(); + #[test] + fn test_get_effective_limits() { + let enforcer = CapabilityEnforcer::new() + .with_max_memory(100 * 1024 * 1024) + .with_max_cpu_time(30_000); - // No limits specified - use defaults - assert_eq!(enforcer.get_memory_limit(&caps), 100 * 1024 * 1024); - assert_eq!(enforcer.get_cpu_time_limit(&caps), 30_000); + let mut caps = Capabilities::default(); - // Plugin requests lower limits - use plugin's - caps.max_memory_bytes = Some(50 * 1024 * 1024); - caps.max_cpu_time_ms = Some(10_000); - assert_eq!(enforcer.get_memory_limit(&caps), 50 * 1024 * 1024); - assert_eq!(enforcer.get_cpu_time_limit(&caps), 10_000); + // No limits specified - use defaults + assert_eq!(enforcer.get_memory_limit(&caps), 100 * 1024 * 1024); + assert_eq!(enforcer.get_cpu_time_limit(&caps), 30_000); - // Plugin requests higher limits - cap at system max - caps.max_memory_bytes = Some(200 * 1024 * 1024); - caps.max_cpu_time_ms = Some(60_000); - assert_eq!(enforcer.get_memory_limit(&caps), 100 * 1024 * 1024); - assert_eq!(enforcer.get_cpu_time_limit(&caps), 30_000); - } + // Plugin requests lower limits - use plugin's + caps.max_memory_bytes = Some(50 * 1024 * 1024); + caps.max_cpu_time_ms = Some(10_000); + assert_eq!(enforcer.get_memory_limit(&caps), 50 * 1024 * 1024); + assert_eq!(enforcer.get_cpu_time_limit(&caps), 10_000); + + // Plugin requests higher limits - cap at system max + caps.max_memory_bytes = Some(200 * 1024 * 1024); + caps.max_cpu_time_ms = Some(60_000); + assert_eq!(enforcer.get_memory_limit(&caps), 100 * 1024 * 1024); + assert_eq!(enforcer.get_cpu_time_limit(&caps), 30_000); + } } diff --git a/crates/pinakes-core/src/scan.rs b/crates/pinakes-core/src/scan.rs index a3f2f64..be62bfc 100644 --- a/crates/pinakes-core/src/scan.rs +++ b/crates/pinakes-core/src/scan.rs @@ -1,387 +1,422 @@ -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex}; +use std::{ + path::{Path, PathBuf}, + sync::{ + Arc, + Mutex, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, +}; use notify::{PollWatcher, RecursiveMode, Watcher}; use tokio::sync::mpsc; use tracing::{info, warn}; -use crate::error::Result; -use crate::import; -use crate::storage::DynStorageBackend; +use crate::{error::Result, import, storage::DynStorageBackend}; pub struct ScanStatus { - pub scanning: bool, - pub files_found: usize, - pub files_processed: usize, - /// Number of files skipped because they haven't changed (incremental scan) - pub files_skipped: usize, - pub errors: Vec, + pub scanning: bool, + pub files_found: usize, + pub files_processed: usize, + /// Number of files skipped because they haven't changed (incremental scan) + pub files_skipped: usize, + pub errors: Vec, } /// Options for scanning operations #[derive(Debug, Clone, Default)] pub struct ScanOptions { - /// Use incremental scanning (skip unchanged files based on mtime) - pub incremental: bool, - /// Force full rescan even for incremental mode - pub force_full: bool, + /// Use incremental scanning (skip unchanged files based on mtime) + pub incremental: bool, + /// Force full rescan even for incremental mode + pub force_full: bool, } -/// Shared scan progress that can be read by the status endpoint while a scan runs. +/// Shared scan progress that can be read by the status endpoint while a scan +/// runs. #[derive(Clone)] pub struct ScanProgress { - pub is_scanning: Arc, - pub files_found: Arc, - pub files_processed: Arc, - pub error_count: Arc, - pub error_messages: Arc>>, + pub is_scanning: Arc, + pub files_found: Arc, + pub files_processed: Arc, + pub error_count: Arc, + pub error_messages: Arc>>, } const MAX_STORED_ERRORS: usize = 100; impl ScanProgress { - pub fn new() -> Self { - Self { - is_scanning: Arc::new(AtomicBool::new(false)), - files_found: Arc::new(AtomicUsize::new(0)), - files_processed: Arc::new(AtomicUsize::new(0)), - error_count: Arc::new(AtomicUsize::new(0)), - error_messages: Arc::new(Mutex::new(Vec::new())), - } + pub fn new() -> Self { + Self { + is_scanning: Arc::new(AtomicBool::new(false)), + files_found: Arc::new(AtomicUsize::new(0)), + files_processed: Arc::new(AtomicUsize::new(0)), + error_count: Arc::new(AtomicUsize::new(0)), + error_messages: Arc::new(Mutex::new(Vec::new())), } + } - pub fn snapshot(&self) -> ScanStatus { - let errors = self - .error_messages - .lock() - .map(|v| v.clone()) - .unwrap_or_default(); - ScanStatus { - scanning: self.is_scanning.load(Ordering::Acquire), - files_found: self.files_found.load(Ordering::Acquire), - files_processed: self.files_processed.load(Ordering::Acquire), - files_skipped: 0, // Not tracked in real-time progress - errors, - } + pub fn snapshot(&self) -> ScanStatus { + let errors = self + .error_messages + .lock() + .map(|v| v.clone()) + .unwrap_or_default(); + ScanStatus { + scanning: self.is_scanning.load(Ordering::Acquire), + files_found: self.files_found.load(Ordering::Acquire), + files_processed: self.files_processed.load(Ordering::Acquire), + files_skipped: 0, // Not tracked in real-time progress + errors, } + } - fn begin(&self) { - self.is_scanning.store(true, Ordering::Release); - self.files_found.store(0, Ordering::Release); - self.files_processed.store(0, Ordering::Release); - self.error_count.store(0, Ordering::Release); - if let Ok(mut msgs) = self.error_messages.lock() { - msgs.clear(); - } + fn begin(&self) { + self.is_scanning.store(true, Ordering::Release); + self.files_found.store(0, Ordering::Release); + self.files_processed.store(0, Ordering::Release); + self.error_count.store(0, Ordering::Release); + if let Ok(mut msgs) = self.error_messages.lock() { + msgs.clear(); } + } - fn record_error(&self, message: String) { - self.error_count.fetch_add(1, Ordering::Release); - if let Ok(mut msgs) = self.error_messages.lock() - && msgs.len() < MAX_STORED_ERRORS - { - msgs.push(message); - } + fn record_error(&self, message: String) { + self.error_count.fetch_add(1, Ordering::Release); + if let Ok(mut msgs) = self.error_messages.lock() + && msgs.len() < MAX_STORED_ERRORS + { + msgs.push(message); } + } - fn finish(&self) { - self.is_scanning.store(false, Ordering::Release); - } + fn finish(&self) { + self.is_scanning.store(false, Ordering::Release); + } } impl Default for ScanProgress { - fn default() -> Self { - Self::new() - } + fn default() -> Self { + Self::new() + } } pub async fn scan_directory( - storage: &DynStorageBackend, - dir: &Path, - ignore_patterns: &[String], + storage: &DynStorageBackend, + dir: &Path, + ignore_patterns: &[String], ) -> Result { - scan_directory_with_options(storage, dir, ignore_patterns, None, &ScanOptions::default()).await + scan_directory_with_options( + storage, + dir, + ignore_patterns, + None, + &ScanOptions::default(), + ) + .await } /// Scan a directory with incremental scanning support pub async fn scan_directory_incremental( - storage: &DynStorageBackend, - dir: &Path, - ignore_patterns: &[String], + storage: &DynStorageBackend, + dir: &Path, + ignore_patterns: &[String], ) -> Result { - let options = ScanOptions { - incremental: true, - force_full: false, - }; - scan_directory_with_options(storage, dir, ignore_patterns, None, &options).await -} - -pub async fn scan_directory_with_progress( - storage: &DynStorageBackend, - dir: &Path, - ignore_patterns: &[String], - progress: Option<&ScanProgress>, -) -> Result { - scan_directory_with_options( - storage, - dir, - ignore_patterns, - progress, - &ScanOptions::default(), - ) + let options = ScanOptions { + incremental: true, + force_full: false, + }; + scan_directory_with_options(storage, dir, ignore_patterns, None, &options) .await } -/// Scan a directory with full options including progress tracking and incremental mode -pub async fn scan_directory_with_options( - storage: &DynStorageBackend, - dir: &Path, - ignore_patterns: &[String], - progress: Option<&ScanProgress>, - scan_options: &ScanOptions, +pub async fn scan_directory_with_progress( + storage: &DynStorageBackend, + dir: &Path, + ignore_patterns: &[String], + progress: Option<&ScanProgress>, ) -> Result { + scan_directory_with_options( + storage, + dir, + ignore_patterns, + progress, + &ScanOptions::default(), + ) + .await +} + +/// Scan a directory with full options including progress tracking and +/// incremental mode +pub async fn scan_directory_with_options( + storage: &DynStorageBackend, + dir: &Path, + ignore_patterns: &[String], + progress: Option<&ScanProgress>, + scan_options: &ScanOptions, +) -> Result { + info!( + dir = %dir.display(), + incremental = scan_options.incremental, + force = scan_options.force_full, + "starting directory scan" + ); + + if let Some(p) = progress { + p.begin(); + } + + // Convert scan options to import options + let import_options = import::ImportOptions { + incremental: scan_options.incremental && !scan_options.force_full, + force: scan_options.force_full, + photo_config: crate::config::PhotoConfig::default(), + }; + + let results = import::import_directory_with_options( + storage, + dir, + ignore_patterns, + 8, // Default concurrency + &import_options, + ) + .await?; + + let mut errors = Vec::new(); + let mut processed = 0; + let mut skipped = 0; + for result in &results { + match result { + Ok(r) => { + if r.was_skipped { + skipped += 1; + } else { + processed += 1; + } + }, + Err(e) => { + let msg = e.to_string(); + if let Some(p) = progress { + p.record_error(msg.clone()); + } + errors.push(msg); + }, + } + } + + if let Some(p) = progress { + p.files_found.store(results.len(), Ordering::Release); + p.files_processed.store(processed, Ordering::Release); + p.finish(); + } + + let status = ScanStatus { + scanning: false, + files_found: results.len(), + files_processed: processed, + files_skipped: skipped, + errors, + }; + + if scan_options.incremental { info!( dir = %dir.display(), - incremental = scan_options.incremental, - force = scan_options.force_full, - "starting directory scan" + found = status.files_found, + processed = status.files_processed, + skipped = status.files_skipped, + "incremental scan complete" ); + } - if let Some(p) = progress { - p.begin(); - } - - // Convert scan options to import options - let import_options = import::ImportOptions { - incremental: scan_options.incremental && !scan_options.force_full, - force: scan_options.force_full, - photo_config: crate::config::PhotoConfig::default(), - }; - - let results = import::import_directory_with_options( - storage, - dir, - ignore_patterns, - 8, // Default concurrency - &import_options, - ) - .await?; - - let mut errors = Vec::new(); - let mut processed = 0; - let mut skipped = 0; - for result in &results { - match result { - Ok(r) => { - if r.was_skipped { - skipped += 1; - } else { - processed += 1; - } - } - Err(e) => { - let msg = e.to_string(); - if let Some(p) = progress { - p.record_error(msg.clone()); - } - errors.push(msg); - } - } - } - - if let Some(p) = progress { - p.files_found.store(results.len(), Ordering::Release); - p.files_processed.store(processed, Ordering::Release); - p.finish(); - } - - let status = ScanStatus { - scanning: false, - files_found: results.len(), - files_processed: processed, - files_skipped: skipped, - errors, - }; - - if scan_options.incremental { - info!( - dir = %dir.display(), - found = status.files_found, - processed = status.files_processed, - skipped = status.files_skipped, - "incremental scan complete" - ); - } - - Ok(status) + Ok(status) } pub async fn scan_all_roots( - storage: &DynStorageBackend, - ignore_patterns: &[String], + storage: &DynStorageBackend, + ignore_patterns: &[String], ) -> Result> { - scan_all_roots_with_options(storage, ignore_patterns, None, &ScanOptions::default()).await + scan_all_roots_with_options( + storage, + ignore_patterns, + None, + &ScanOptions::default(), + ) + .await } /// Scan all roots incrementally (skip unchanged files) pub async fn scan_all_roots_incremental( - storage: &DynStorageBackend, - ignore_patterns: &[String], + storage: &DynStorageBackend, + ignore_patterns: &[String], ) -> Result> { - let options = ScanOptions { - incremental: true, - force_full: false, - }; - scan_all_roots_with_options(storage, ignore_patterns, None, &options).await + let options = ScanOptions { + incremental: true, + force_full: false, + }; + scan_all_roots_with_options(storage, ignore_patterns, None, &options).await } pub async fn scan_all_roots_with_progress( - storage: &DynStorageBackend, - ignore_patterns: &[String], - progress: Option<&ScanProgress>, + storage: &DynStorageBackend, + ignore_patterns: &[String], + progress: Option<&ScanProgress>, ) -> Result> { - scan_all_roots_with_options(storage, ignore_patterns, progress, &ScanOptions::default()).await + scan_all_roots_with_options( + storage, + ignore_patterns, + progress, + &ScanOptions::default(), + ) + .await } /// Scan all roots with full options including progress and incremental mode pub async fn scan_all_roots_with_options( - storage: &DynStorageBackend, - ignore_patterns: &[String], - progress: Option<&ScanProgress>, - scan_options: &ScanOptions, + storage: &DynStorageBackend, + ignore_patterns: &[String], + progress: Option<&ScanProgress>, + scan_options: &ScanOptions, ) -> Result> { - let roots = storage.list_root_dirs().await?; - let mut statuses = Vec::new(); + let roots = storage.list_root_dirs().await?; + let mut statuses = Vec::new(); - for root in roots { - match scan_directory_with_options(storage, &root, ignore_patterns, progress, scan_options) - .await - { - Ok(status) => statuses.push(status), - Err(e) => { - warn!(root = %root.display(), error = %e, "failed to scan root directory"); - statuses.push(ScanStatus { - scanning: false, - files_found: 0, - files_processed: 0, - files_skipped: 0, - errors: vec![e.to_string()], - }); - } - } + for root in roots { + match scan_directory_with_options( + storage, + &root, + ignore_patterns, + progress, + scan_options, + ) + .await + { + Ok(status) => statuses.push(status), + Err(e) => { + warn!(root = %root.display(), error = %e, "failed to scan root directory"); + statuses.push(ScanStatus { + scanning: false, + files_found: 0, + files_processed: 0, + files_skipped: 0, + errors: vec![e.to_string()], + }); + }, } + } - Ok(statuses) + Ok(statuses) } pub struct FileWatcher { - _watcher: Box, - rx: mpsc::Receiver, + _watcher: Box, + rx: mpsc::Receiver, } impl FileWatcher { - pub fn new(dirs: &[PathBuf]) -> Result { - let (tx, rx) = mpsc::channel(1024); + pub fn new(dirs: &[PathBuf]) -> Result { + let (tx, rx) = mpsc::channel(1024); - // Try the recommended (native) watcher first, fall back to polling - let watcher: Box = match Self::try_native_watcher(dirs, tx.clone()) { - Ok(w) => { - info!("using native filesystem watcher"); - w + // Try the recommended (native) watcher first, fall back to polling + let watcher: Box = match Self::try_native_watcher( + dirs, + tx.clone(), + ) { + Ok(w) => { + info!("using native filesystem watcher"); + w + }, + Err(native_err) => { + warn!(error = %native_err, "native watcher failed, falling back to polling"); + Self::polling_watcher(dirs, tx)? + }, + }; + + Ok(Self { + _watcher: watcher, + rx, + }) + } + + fn try_native_watcher( + dirs: &[PathBuf], + tx: mpsc::Sender, + ) -> std::result::Result, notify::Error> { + let tx_clone = tx.clone(); + let mut watcher = notify::recommended_watcher( + move |res: notify::Result| { + if let Ok(event) = res { + for path in event.paths { + if tx_clone.blocking_send(path).is_err() { + tracing::warn!("filesystem watcher channel closed, stopping"); + break; } - Err(native_err) => { - warn!(error = %native_err, "native watcher failed, falling back to polling"); - Self::polling_watcher(dirs, tx)? + } + } + }, + )?; + + for dir in dirs { + watcher.watch(dir, RecursiveMode::Recursive)?; + } + + Ok(Box::new(watcher)) + } + + fn polling_watcher( + dirs: &[PathBuf], + tx: mpsc::Sender, + ) -> Result> { + let tx_clone = tx.clone(); + let poll_interval = std::time::Duration::from_secs(5); + let config = notify::Config::default().with_poll_interval(poll_interval); + + let mut watcher = PollWatcher::new( + move |res: notify::Result| { + if let Ok(event) = res { + for path in event.paths { + if tx_clone.blocking_send(path).is_err() { + tracing::warn!("filesystem watcher channel closed, stopping"); + break; } - }; - - Ok(Self { - _watcher: watcher, - rx, - }) - } - - fn try_native_watcher( - dirs: &[PathBuf], - tx: mpsc::Sender, - ) -> std::result::Result, notify::Error> { - let tx_clone = tx.clone(); - let mut watcher = - notify::recommended_watcher(move |res: notify::Result| { - if let Ok(event) = res { - for path in event.paths { - if tx_clone.blocking_send(path).is_err() { - tracing::warn!("filesystem watcher channel closed, stopping"); - break; - } - } - } - })?; - - for dir in dirs { - watcher.watch(dir, RecursiveMode::Recursive)?; + } } + }, + config, + ) + .map_err(|e| crate::error::PinakesError::Io(std::io::Error::other(e)))?; - Ok(Box::new(watcher)) + for dir in dirs { + watcher.watch(dir, RecursiveMode::Recursive).map_err(|e| { + crate::error::PinakesError::Io(std::io::Error::other(e)) + })?; } - fn polling_watcher( - dirs: &[PathBuf], - tx: mpsc::Sender, - ) -> Result> { - let tx_clone = tx.clone(); - let poll_interval = std::time::Duration::from_secs(5); - let config = notify::Config::default().with_poll_interval(poll_interval); + Ok(Box::new(watcher)) + } - let mut watcher = PollWatcher::new( - move |res: notify::Result| { - if let Ok(event) = res { - for path in event.paths { - if tx_clone.blocking_send(path).is_err() { - tracing::warn!("filesystem watcher channel closed, stopping"); - break; - } - } - } - }, - config, - ) - .map_err(|e| crate::error::PinakesError::Io(std::io::Error::other(e)))?; - - for dir in dirs { - watcher - .watch(dir, RecursiveMode::Recursive) - .map_err(|e| crate::error::PinakesError::Io(std::io::Error::other(e)))?; - } - - Ok(Box::new(watcher)) - } - - pub async fn next_change(&mut self) -> Option { - self.rx.recv().await - } + pub async fn next_change(&mut self) -> Option { + self.rx.recv().await + } } pub async fn watch_and_import( - storage: DynStorageBackend, - dirs: Vec, - ignore_patterns: Vec, + storage: DynStorageBackend, + dirs: Vec, + ignore_patterns: Vec, ) -> Result<()> { - let mut watcher = FileWatcher::new(&dirs)?; - info!("filesystem watcher started"); + let mut watcher = FileWatcher::new(&dirs)?; + info!("filesystem watcher started"); - while let Some(path) = watcher.next_change().await { - if path.is_file() - && crate::media_type::MediaType::from_path(&path).is_some() - && !crate::import::should_ignore(&path, &ignore_patterns) - { - info!(path = %path.display(), "detected file change, importing"); - if let Err(e) = import::import_file(&storage, &path).await { - warn!(path = %path.display(), error = %e, "failed to import changed file"); - } - } + while let Some(path) = watcher.next_change().await { + if path.is_file() + && crate::media_type::MediaType::from_path(&path).is_some() + && !crate::import::should_ignore(&path, &ignore_patterns) + { + info!(path = %path.display(), "detected file change, importing"); + if let Err(e) = import::import_file(&storage, &path).await { + warn!(path = %path.display(), error = %e, "failed to import changed file"); + } } + } - Ok(()) + Ok(()) } diff --git a/crates/pinakes-core/src/scheduler.rs b/crates/pinakes-core/src/scheduler.rs index 88784c4..55bd627 100644 --- a/crates/pinakes-core/src/scheduler.rs +++ b/crates/pinakes-core/src/scheduler.rs @@ -1,5 +1,4 @@ -use std::path::PathBuf; -use std::sync::Arc; +use std::{path::PathBuf, sync::Arc}; use chrono::{DateTime, Datelike, Utc}; use serde::{Deserialize, Serialize}; @@ -7,511 +6,543 @@ use tokio::sync::RwLock; use tokio_util::sync::CancellationToken; use uuid::Uuid; -use crate::config::Config; -use crate::jobs::{JobKind, JobQueue}; +use crate::{ + config::Config, + jobs::{JobKind, JobQueue}, +}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case", tag = "type")] pub enum Schedule { - Interval { secs: u64 }, - Daily { hour: u32, minute: u32 }, - Weekly { day: u32, hour: u32, minute: u32 }, + Interval { + secs: u64, + }, + Daily { + hour: u32, + minute: u32, + }, + Weekly { + day: u32, + hour: u32, + minute: u32, + }, } impl Schedule { - pub fn next_run(&self, from: DateTime) -> DateTime { - match self { - Schedule::Interval { secs } => from + chrono::Duration::seconds(*secs as i64), - Schedule::Daily { hour, minute } => { - let today = from - .date_naive() - .and_hms_opt(*hour, *minute, 0) - .unwrap_or_default(); - let today_utc = today.and_utc(); - if today_utc > from { - today_utc - } else { - today_utc + chrono::Duration::days(1) - } - } - Schedule::Weekly { day, hour, minute } => { - let current_day = from.weekday().num_days_from_monday(); - let target_day = *day; - let days_ahead = if target_day > current_day { - target_day - current_day - } else if target_day < current_day { - 7 - (current_day - target_day) - } else { - let today = from - .date_naive() - .and_hms_opt(*hour, *minute, 0) - .unwrap_or_default() - .and_utc(); - if today > from { - return today; - } - 7 - }; - let target_date = from.date_naive() + chrono::Duration::days(days_ahead as i64); - target_date - .and_hms_opt(*hour, *minute, 0) - .unwrap_or_default() - .and_utc() - } + pub fn next_run(&self, from: DateTime) -> DateTime { + match self { + Schedule::Interval { secs } => { + from + chrono::Duration::seconds(*secs as i64) + }, + Schedule::Daily { hour, minute } => { + let today = from + .date_naive() + .and_hms_opt(*hour, *minute, 0) + .unwrap_or_default(); + let today_utc = today.and_utc(); + if today_utc > from { + today_utc + } else { + today_utc + chrono::Duration::days(1) } + }, + Schedule::Weekly { day, hour, minute } => { + let current_day = from.weekday().num_days_from_monday(); + let target_day = *day; + let days_ahead = if target_day > current_day { + target_day - current_day + } else if target_day < current_day { + 7 - (current_day - target_day) + } else { + let today = from + .date_naive() + .and_hms_opt(*hour, *minute, 0) + .unwrap_or_default() + .and_utc(); + if today > from { + return today; + } + 7 + }; + let target_date = + from.date_naive() + chrono::Duration::days(days_ahead as i64); + target_date + .and_hms_opt(*hour, *minute, 0) + .unwrap_or_default() + .and_utc() + }, } + } - pub fn display_string(&self) -> String { - match self { - Schedule::Interval { secs } => { - if *secs >= 3600 { - format!("Every {}h", secs / 3600) - } else if *secs >= 60 { - format!("Every {}m", secs / 60) - } else { - format!("Every {}s", secs) - } - } - Schedule::Daily { hour, minute } => format!("Daily {hour:02}:{minute:02}"), - Schedule::Weekly { day, hour, minute } => { - let day_name = match day { - 0 => "Mon", - 1 => "Tue", - 2 => "Wed", - 3 => "Thu", - 4 => "Fri", - 5 => "Sat", - _ => "Sun", - }; - format!("{day_name} {hour:02}:{minute:02}") - } + pub fn display_string(&self) -> String { + match self { + Schedule::Interval { secs } => { + if *secs >= 3600 { + format!("Every {}h", secs / 3600) + } else if *secs >= 60 { + format!("Every {}m", secs / 60) + } else { + format!("Every {}s", secs) } + }, + Schedule::Daily { hour, minute } => { + format!("Daily {hour:02}:{minute:02}") + }, + Schedule::Weekly { day, hour, minute } => { + let day_name = match day { + 0 => "Mon", + 1 => "Tue", + 2 => "Wed", + 3 => "Thu", + 4 => "Fri", + 5 => "Sat", + _ => "Sun", + }; + format!("{day_name} {hour:02}:{minute:02}") + }, } + } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ScheduledTask { - pub id: String, - pub name: String, - pub kind: JobKind, - pub schedule: Schedule, - pub enabled: bool, - pub last_run: Option>, - pub next_run: Option>, - pub last_status: Option, - /// Whether a job for this task is currently running. Skipped during serialization. - #[serde(default, skip_serializing)] - pub running: bool, - /// The job ID of the last submitted job. Skipped during serialization/deserialization. - #[serde(skip)] - pub last_job_id: Option, + pub id: String, + pub name: String, + pub kind: JobKind, + pub schedule: Schedule, + pub enabled: bool, + pub last_run: Option>, + pub next_run: Option>, + pub last_status: Option, + /// Whether a job for this task is currently running. Skipped during + /// serialization. + #[serde(default, skip_serializing)] + pub running: bool, + /// The job ID of the last submitted job. Skipped during + /// serialization/deserialization. + #[serde(skip)] + pub last_job_id: Option, } pub struct TaskScheduler { - tasks: Arc>>, + tasks: Arc>>, + job_queue: Arc, + cancel: CancellationToken, + config: Arc>, + config_path: Option, +} + +impl TaskScheduler { + pub fn new( job_queue: Arc, cancel: CancellationToken, config: Arc>, config_path: Option, -} + ) -> Self { + let now = Utc::now(); + let default_tasks = vec![ + ScheduledTask { + id: "periodic_scan".to_string(), + name: "Periodic Scan".to_string(), + kind: JobKind::Scan { path: None }, + schedule: Schedule::Interval { secs: 3600 }, + enabled: true, + last_run: None, + next_run: Some(now + chrono::Duration::seconds(3600)), + last_status: None, + running: false, + last_job_id: None, + }, + ScheduledTask { + id: "integrity_check".to_string(), + name: "Integrity Check".to_string(), + kind: JobKind::VerifyIntegrity { media_ids: vec![] }, + schedule: Schedule::Weekly { + day: 0, + hour: 3, + minute: 0, + }, + enabled: false, + last_run: None, + next_run: None, + last_status: None, + running: false, + last_job_id: None, + }, + ScheduledTask { + id: "orphan_detection".to_string(), + name: "Orphan Detection".to_string(), + kind: JobKind::OrphanDetection, + schedule: Schedule::Daily { + hour: 2, + minute: 0, + }, + enabled: false, + last_run: None, + next_run: None, + last_status: None, + running: false, + last_job_id: None, + }, + ScheduledTask { + id: "thumbnail_cleanup".to_string(), + name: "Thumbnail Cleanup".to_string(), + kind: JobKind::CleanupThumbnails, + schedule: Schedule::Weekly { + day: 6, + hour: 4, + minute: 0, + }, + enabled: false, + last_run: None, + next_run: None, + last_status: None, + running: false, + last_job_id: None, + }, + ]; -impl TaskScheduler { - pub fn new( - job_queue: Arc, - cancel: CancellationToken, - config: Arc>, - config_path: Option, - ) -> Self { - let now = Utc::now(); - let default_tasks = vec![ - ScheduledTask { - id: "periodic_scan".to_string(), - name: "Periodic Scan".to_string(), - kind: JobKind::Scan { path: None }, - schedule: Schedule::Interval { secs: 3600 }, - enabled: true, - last_run: None, - next_run: Some(now + chrono::Duration::seconds(3600)), - last_status: None, - running: false, - last_job_id: None, - }, - ScheduledTask { - id: "integrity_check".to_string(), - name: "Integrity Check".to_string(), - kind: JobKind::VerifyIntegrity { media_ids: vec![] }, - schedule: Schedule::Weekly { - day: 0, - hour: 3, - minute: 0, - }, - enabled: false, - last_run: None, - next_run: None, - last_status: None, - running: false, - last_job_id: None, - }, - ScheduledTask { - id: "orphan_detection".to_string(), - name: "Orphan Detection".to_string(), - kind: JobKind::OrphanDetection, - schedule: Schedule::Daily { hour: 2, minute: 0 }, - enabled: false, - last_run: None, - next_run: None, - last_status: None, - running: false, - last_job_id: None, - }, - ScheduledTask { - id: "thumbnail_cleanup".to_string(), - name: "Thumbnail Cleanup".to_string(), - kind: JobKind::CleanupThumbnails, - schedule: Schedule::Weekly { - day: 6, - hour: 4, - minute: 0, - }, - enabled: false, - last_run: None, - next_run: None, - last_status: None, - running: false, - last_job_id: None, - }, - ]; - - Self { - tasks: Arc::new(RwLock::new(default_tasks)), - job_queue, - cancel, - config, - config_path, - } + Self { + tasks: Arc::new(RwLock::new(default_tasks)), + job_queue, + cancel, + config, + config_path, } + } - /// Restore saved task state from config. Should be called once after construction. - pub async fn restore_state(&self) { - let saved = self.config.read().await.scheduled_tasks.clone(); - if saved.is_empty() { - return; - } - let mut tasks = self.tasks.write().await; - for saved_task in &saved { - if let Some(task) = tasks.iter_mut().find(|t| t.id == saved_task.id) { - task.enabled = saved_task.enabled; - task.schedule = saved_task.schedule.clone(); - if let Some(Ok(dt)) = saved_task - .last_run - .as_ref() - .map(|s| DateTime::parse_from_rfc3339(s)) - { - task.last_run = Some(dt.with_timezone(&Utc)); - } - if task.enabled { - let from = task.last_run.unwrap_or_else(Utc::now); - task.next_run = Some(task.schedule.next_run(from)); - } else { - task.next_run = None; - } - } - } + /// Restore saved task state from config. Should be called once after + /// construction. + pub async fn restore_state(&self) { + let saved = self.config.read().await.scheduled_tasks.clone(); + if saved.is_empty() { + return; } - - /// Persist current task state to config file. - async fn persist_task_state(&self) { - let tasks = self.tasks.read().await; - let task_configs: Vec = tasks - .iter() - .map(|t| crate::config::ScheduledTaskConfig { - id: t.id.clone(), - enabled: t.enabled, - schedule: t.schedule.clone(), - last_run: t.last_run.map(|dt| dt.to_rfc3339()), - }) - .collect(); - drop(tasks); - + let mut tasks = self.tasks.write().await; + for saved_task in &saved { + if let Some(task) = tasks.iter_mut().find(|t| t.id == saved_task.id) { + task.enabled = saved_task.enabled; + task.schedule = saved_task.schedule.clone(); + if let Some(Ok(dt)) = saved_task + .last_run + .as_ref() + .map(|s| DateTime::parse_from_rfc3339(s)) { - let mut config = self.config.write().await; - config.scheduled_tasks = task_configs; + task.last_run = Some(dt.with_timezone(&Utc)); } + if task.enabled { + let from = task.last_run.unwrap_or_else(Utc::now); + task.next_run = Some(task.schedule.next_run(from)); + } else { + task.next_run = None; + } + } + } + } - if let Some(ref path) = self.config_path { - let config = self.config.read().await; - if let Err(e) = config.save_to_file(path) { - tracing::warn!(error = %e, "failed to persist scheduler state to config file"); - } + /// Persist current task state to config file. + async fn persist_task_state(&self) { + let tasks = self.tasks.read().await; + let task_configs: Vec = tasks + .iter() + .map(|t| { + crate::config::ScheduledTaskConfig { + id: t.id.clone(), + enabled: t.enabled, + schedule: t.schedule.clone(), + last_run: t.last_run.map(|dt| dt.to_rfc3339()), } + }) + .collect(); + drop(tasks); + + { + let mut config = self.config.write().await; + config.scheduled_tasks = task_configs; } - pub async fn list_tasks(&self) -> Vec { - self.tasks.read().await.clone() + if let Some(ref path) = self.config_path { + let config = self.config.read().await; + if let Err(e) = config.save_to_file(path) { + tracing::warn!(error = %e, "failed to persist scheduler state to config file"); + } } + } - pub async fn toggle_task(&self, id: &str) -> Option { - let result = { - let mut tasks = self.tasks.write().await; - if let Some(task) = tasks.iter_mut().find(|t| t.id == id) { - task.enabled = !task.enabled; - if task.enabled { - task.next_run = Some(task.schedule.next_run(Utc::now())); - } else { - task.next_run = None; - } - Some(task.enabled) - } else { - None - } - }; - if result.is_some() { - self.persist_task_state().await; + pub async fn list_tasks(&self) -> Vec { + self.tasks.read().await.clone() + } + + pub async fn toggle_task(&self, id: &str) -> Option { + let result = { + let mut tasks = self.tasks.write().await; + if let Some(task) = tasks.iter_mut().find(|t| t.id == id) { + task.enabled = !task.enabled; + if task.enabled { + task.next_run = Some(task.schedule.next_run(Utc::now())); + } else { + task.next_run = None; } - result + Some(task.enabled) + } else { + None + } + }; + if result.is_some() { + self.persist_task_state().await; } + result + } - /// Run a task immediately. Uses a single write lock to avoid TOCTOU races. - pub async fn run_now(&self, id: &str) -> Option { - let result = { - let mut tasks = self.tasks.write().await; - let task = tasks.iter_mut().find(|t| t.id == id)?; + /// Run a task immediately. Uses a single write lock to avoid TOCTOU races. + pub async fn run_now(&self, id: &str) -> Option { + let result = { + let mut tasks = self.tasks.write().await; + let task = tasks.iter_mut().find(|t| t.id == id)?; - // Submit the job (cheap: sends to mpsc channel) - let job_id = self.job_queue.submit(task.kind.clone()).await; + // Submit the job (cheap: sends to mpsc channel) + let job_id = self.job_queue.submit(task.kind.clone()).await; - task.last_run = Some(Utc::now()); + task.last_run = Some(Utc::now()); + task.last_status = Some("running".to_string()); + task.running = true; + task.last_job_id = Some(job_id); + if task.enabled { + task.next_run = Some(task.schedule.next_run(Utc::now())); + } + + Some(job_id.to_string()) + }; + if result.is_some() { + self.persist_task_state().await; + } + result + } + + /// Main scheduler loop. Uses a two-phase approach per tick to avoid + /// holding the write lock across await points. Returns when the + /// cancellation token is triggered. + pub async fn run(&self) { + let mut interval = + tokio::time::interval(std::time::Duration::from_secs(30)); + loop { + tokio::select! { + _ = interval.tick() => {} + _ = self.cancel.cancelled() => { + tracing::info!("scheduler shutting down"); + return; + } + } + + // Phase 1: Check completed jobs and update running status + { + use crate::jobs::JobStatus; + let mut tasks = self.tasks.write().await; + for task in tasks.iter_mut() { + if !task.running { + continue; + } + let Some(job_id) = task.last_job_id else { + continue; + }; + let Some(job) = self.job_queue.status(job_id).await else { + continue; + }; + match &job.status { + JobStatus::Completed { .. } => { + task.running = false; + task.last_status = Some("completed".to_string()); + }, + JobStatus::Failed { error } => { + task.running = false; + task.last_status = Some(format!("failed: {error}")); + }, + JobStatus::Cancelled => { + task.running = false; + task.last_status = Some("cancelled".to_string()); + }, + _ => {}, // still pending or running + } + } + } + + // Phase 2: Collect due tasks and submit jobs + let now = Utc::now(); + let mut to_submit: Vec<(usize, JobKind)> = Vec::new(); + + { + let mut tasks = self.tasks.write().await; + for (i, task) in tasks.iter_mut().enumerate() { + if !task.enabled || task.running { + continue; + } + let due = task.next_run.is_some_and(|next| now >= next); + if due { + to_submit.push((i, task.kind.clone())); + task.last_run = Some(now); task.last_status = Some("running".to_string()); task.running = true; - task.last_job_id = Some(job_id); - if task.enabled { - task.next_run = Some(task.schedule.next_run(Utc::now())); - } - - Some(job_id.to_string()) - }; - if result.is_some() { - self.persist_task_state().await; + task.next_run = Some(task.schedule.next_run(now)); + } } - result - } + } - /// Main scheduler loop. Uses a two-phase approach per tick to avoid - /// holding the write lock across await points. Returns when the - /// cancellation token is triggered. - pub async fn run(&self) { - let mut interval = tokio::time::interval(std::time::Duration::from_secs(30)); - loop { - tokio::select! { - _ = interval.tick() => {} - _ = self.cancel.cancelled() => { - tracing::info!("scheduler shutting down"); - return; - } - } - - // Phase 1: Check completed jobs and update running status - { - use crate::jobs::JobStatus; - let mut tasks = self.tasks.write().await; - for task in tasks.iter_mut() { - if !task.running { - continue; - } - let Some(job_id) = task.last_job_id else { - continue; - }; - let Some(job) = self.job_queue.status(job_id).await else { - continue; - }; - match &job.status { - JobStatus::Completed { .. } => { - task.running = false; - task.last_status = Some("completed".to_string()); - } - JobStatus::Failed { error } => { - task.running = false; - task.last_status = Some(format!("failed: {error}")); - } - JobStatus::Cancelled => { - task.running = false; - task.last_status = Some("cancelled".to_string()); - } - _ => {} // still pending or running - } - } - } - - // Phase 2: Collect due tasks and submit jobs - let now = Utc::now(); - let mut to_submit: Vec<(usize, JobKind)> = Vec::new(); - - { - let mut tasks = self.tasks.write().await; - for (i, task) in tasks.iter_mut().enumerate() { - if !task.enabled || task.running { - continue; - } - let due = task.next_run.is_some_and(|next| now >= next); - if due { - to_submit.push((i, task.kind.clone())); - task.last_run = Some(now); - task.last_status = Some("running".to_string()); - task.running = true; - task.next_run = Some(task.schedule.next_run(now)); - } - } - } - - // Submit jobs without holding the lock - for (idx, kind) in to_submit { - let job_id = self.job_queue.submit(kind).await; - let mut tasks = self.tasks.write().await; - if let Some(task) = tasks.get_mut(idx) { - task.last_job_id = Some(job_id); - } - } + // Submit jobs without holding the lock + for (idx, kind) in to_submit { + let job_id = self.job_queue.submit(kind).await; + let mut tasks = self.tasks.write().await; + if let Some(task) = tasks.get_mut(idx) { + task.last_job_id = Some(job_id); } + } } + } } #[cfg(test)] mod tests { - use super::*; - use chrono::TimeZone; + use chrono::TimeZone; - #[test] - fn test_interval_next_run() { - let from = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap(); - let schedule = Schedule::Interval { secs: 3600 }; - let next = schedule.next_run(from); - assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 15, 13, 0, 0).unwrap()); - } + use super::*; - #[test] - fn test_daily_next_run_future_today() { - // 10:00 UTC, schedule is 14:00 => same day - let from = Utc.with_ymd_and_hms(2025, 6, 15, 10, 0, 0).unwrap(); - let schedule = Schedule::Daily { - hour: 14, - minute: 0, - }; - let next = schedule.next_run(from); - assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 15, 14, 0, 0).unwrap()); - } + #[test] + fn test_interval_next_run() { + let from = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap(); + let schedule = Schedule::Interval { secs: 3600 }; + let next = schedule.next_run(from); + assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 15, 13, 0, 0).unwrap()); + } - #[test] - fn test_daily_next_run_past_today() { - // 16:00 UTC, schedule is 14:00 => next day - let from = Utc.with_ymd_and_hms(2025, 6, 15, 16, 0, 0).unwrap(); - let schedule = Schedule::Daily { - hour: 14, - minute: 0, - }; - let next = schedule.next_run(from); - assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 16, 14, 0, 0).unwrap()); - } + #[test] + fn test_daily_next_run_future_today() { + // 10:00 UTC, schedule is 14:00 => same day + let from = Utc.with_ymd_and_hms(2025, 6, 15, 10, 0, 0).unwrap(); + let schedule = Schedule::Daily { + hour: 14, + minute: 0, + }; + let next = schedule.next_run(from); + assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 15, 14, 0, 0).unwrap()); + } - #[test] - fn test_weekly_next_run() { - // 2025-06-15 is a Sunday (day 6). Target is Monday (day 0) at 03:00. - let from = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap(); - let schedule = Schedule::Weekly { - day: 0, - hour: 3, - minute: 0, - }; - let next = schedule.next_run(from); - assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 16, 3, 0, 0).unwrap()); - } + #[test] + fn test_daily_next_run_past_today() { + // 16:00 UTC, schedule is 14:00 => next day + let from = Utc.with_ymd_and_hms(2025, 6, 15, 16, 0, 0).unwrap(); + let schedule = Schedule::Daily { + hour: 14, + minute: 0, + }; + let next = schedule.next_run(from); + assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 16, 14, 0, 0).unwrap()); + } - #[test] - fn test_weekly_same_day_future() { - // 2025-06-15 is Sunday (day 6). Schedule is Sunday 14:00, current is 10:00 => today. - let from = Utc.with_ymd_and_hms(2025, 6, 15, 10, 0, 0).unwrap(); - let schedule = Schedule::Weekly { - day: 6, - hour: 14, - minute: 0, - }; - let next = schedule.next_run(from); - assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 15, 14, 0, 0).unwrap()); - } + #[test] + fn test_weekly_next_run() { + // 2025-06-15 is a Sunday (day 6). Target is Monday (day 0) at 03:00. + let from = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap(); + let schedule = Schedule::Weekly { + day: 0, + hour: 3, + minute: 0, + }; + let next = schedule.next_run(from); + assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 16, 3, 0, 0).unwrap()); + } - #[test] - fn test_weekly_same_day_past() { - // 2025-06-15 is Sunday (day 6). Schedule is Sunday 08:00, current is 10:00 => next week. - let from = Utc.with_ymd_and_hms(2025, 6, 15, 10, 0, 0).unwrap(); - let schedule = Schedule::Weekly { - day: 6, - hour: 8, - minute: 0, - }; - let next = schedule.next_run(from); - assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 22, 8, 0, 0).unwrap()); - } + #[test] + fn test_weekly_same_day_future() { + // 2025-06-15 is Sunday (day 6). Schedule is Sunday 14:00, current is 10:00 + // => today. + let from = Utc.with_ymd_and_hms(2025, 6, 15, 10, 0, 0).unwrap(); + let schedule = Schedule::Weekly { + day: 6, + hour: 14, + minute: 0, + }; + let next = schedule.next_run(from); + assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 15, 14, 0, 0).unwrap()); + } - #[test] - fn test_serde_roundtrip() { - let task = ScheduledTask { - id: "test".to_string(), - name: "Test Task".to_string(), - kind: JobKind::Scan { path: None }, - schedule: Schedule::Interval { secs: 3600 }, - enabled: true, - last_run: Some(Utc::now()), - next_run: Some(Utc::now()), - last_status: Some("completed".to_string()), - running: true, - last_job_id: Some(Uuid::now_v7()), - }; + #[test] + fn test_weekly_same_day_past() { + // 2025-06-15 is Sunday (day 6). Schedule is Sunday 08:00, current is 10:00 + // => next week. + let from = Utc.with_ymd_and_hms(2025, 6, 15, 10, 0, 0).unwrap(); + let schedule = Schedule::Weekly { + day: 6, + hour: 8, + minute: 0, + }; + let next = schedule.next_run(from); + assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 22, 8, 0, 0).unwrap()); + } - let json = serde_json::to_string(&task).unwrap(); - let deserialized: ScheduledTask = serde_json::from_str(&json).unwrap(); + #[test] + fn test_serde_roundtrip() { + let task = ScheduledTask { + id: "test".to_string(), + name: "Test Task".to_string(), + kind: JobKind::Scan { path: None }, + schedule: Schedule::Interval { secs: 3600 }, + enabled: true, + last_run: Some(Utc::now()), + next_run: Some(Utc::now()), + last_status: Some("completed".to_string()), + running: true, + last_job_id: Some(Uuid::now_v7()), + }; - assert_eq!(deserialized.id, "test"); - assert_eq!(deserialized.enabled, true); - // running defaults to false on deserialization (skip_serializing) - assert!(!deserialized.running); - // last_job_id is skipped entirely - assert!(deserialized.last_job_id.is_none()); - } + let json = serde_json::to_string(&task).unwrap(); + let deserialized: ScheduledTask = serde_json::from_str(&json).unwrap(); - #[test] - fn test_display_string() { - assert_eq!( - Schedule::Interval { secs: 3600 }.display_string(), - "Every 1h" - ); - assert_eq!( - Schedule::Interval { secs: 300 }.display_string(), - "Every 5m" - ); - assert_eq!( - Schedule::Interval { secs: 30 }.display_string(), - "Every 30s" - ); - assert_eq!( - Schedule::Daily { hour: 3, minute: 0 }.display_string(), - "Daily 03:00" - ); - assert_eq!( - Schedule::Weekly { - day: 0, - hour: 3, - minute: 0 - } - .display_string(), - "Mon 03:00" - ); - assert_eq!( - Schedule::Weekly { - day: 6, - hour: 14, - minute: 30 - } - .display_string(), - "Sun 14:30" - ); - } + assert_eq!(deserialized.id, "test"); + assert_eq!(deserialized.enabled, true); + // running defaults to false on deserialization (skip_serializing) + assert!(!deserialized.running); + // last_job_id is skipped entirely + assert!(deserialized.last_job_id.is_none()); + } + + #[test] + fn test_display_string() { + assert_eq!( + Schedule::Interval { secs: 3600 }.display_string(), + "Every 1h" + ); + assert_eq!( + Schedule::Interval { secs: 300 }.display_string(), + "Every 5m" + ); + assert_eq!( + Schedule::Interval { secs: 30 }.display_string(), + "Every 30s" + ); + assert_eq!( + Schedule::Daily { + hour: 3, + minute: 0, + } + .display_string(), + "Daily 03:00" + ); + assert_eq!( + Schedule::Weekly { + day: 0, + hour: 3, + minute: 0, + } + .display_string(), + "Mon 03:00" + ); + assert_eq!( + Schedule::Weekly { + day: 6, + hour: 14, + minute: 30, + } + .display_string(), + "Sun 14:30" + ); + } } diff --git a/crates/pinakes-core/src/search.rs b/crates/pinakes-core/src/search.rs index 33e9001..adc4cf9 100644 --- a/crates/pinakes-core/src/search.rs +++ b/crates/pinakes-core/src/search.rs @@ -1,553 +1,524 @@ use serde::{Deserialize, Serialize}; -use winnow::combinator::{alt, delimited, preceded, repeat}; -use winnow::token::{take_till, take_while}; -use winnow::{ModalResult, Parser}; +use winnow::{ + ModalResult, + Parser, + combinator::{alt, delimited, preceded, repeat}, + token::{take_till, take_while}, +}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum SearchQuery { - FullText(String), - FieldMatch { - field: String, - value: String, - }, - And(Vec), - Or(Vec), - Not(Box), - Prefix(String), - Fuzzy(String), - TypeFilter(String), - TagFilter(String), - /// Range query: field:start..end (inclusive) - RangeQuery { - field: String, - start: Option, - end: Option, - }, - /// Comparison query: field:>value, field:=value, field:<=value - CompareQuery { - field: String, - op: CompareOp, - value: i64, - }, - /// Date query: created:today, modified:last-week, etc. - DateQuery { - field: String, - value: DateValue, - }, + FullText(String), + FieldMatch { + field: String, + value: String, + }, + And(Vec), + Or(Vec), + Not(Box), + Prefix(String), + Fuzzy(String), + TypeFilter(String), + TagFilter(String), + /// Range query: field:start..end (inclusive) + RangeQuery { + field: String, + start: Option, + end: Option, + }, + /// Comparison query: field:>value, field:=value, field:<=value + CompareQuery { + field: String, + op: CompareOp, + value: i64, + }, + /// Date query: created:today, modified:last-week, etc. + DateQuery { + field: String, + value: DateValue, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum CompareOp { - GreaterThan, - GreaterOrEqual, - LessThan, - LessOrEqual, + GreaterThan, + GreaterOrEqual, + LessThan, + LessOrEqual, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum DateValue { - Today, - Yesterday, - ThisWeek, - LastWeek, - ThisMonth, - LastMonth, - ThisYear, - LastYear, - /// Days ago: last-7d, last-30d - DaysAgo(u32), + Today, + Yesterday, + ThisWeek, + LastWeek, + ThisMonth, + LastMonth, + ThisYear, + LastYear, + /// Days ago: last-7d, last-30d + DaysAgo(u32), } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SearchRequest { - pub query: SearchQuery, - pub sort: SortOrder, - pub pagination: crate::model::Pagination, + pub query: SearchQuery, + pub sort: SortOrder, + pub pagination: crate::model::Pagination, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SearchResults { - pub items: Vec, - pub total_count: u64, + pub items: Vec, + pub total_count: u64, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] #[derive(Default)] pub enum SortOrder { - #[default] - Relevance, - DateAsc, - DateDesc, - NameAsc, - NameDesc, - SizeAsc, - SizeDesc, + #[default] + Relevance, + DateAsc, + DateDesc, + NameAsc, + NameDesc, + SizeAsc, + SizeDesc, } fn ws<'i>(input: &mut &'i str) -> ModalResult<&'i str> { - take_while(0.., ' ').parse_next(input) + take_while(0.., ' ').parse_next(input) } fn quoted_string(input: &mut &str) -> ModalResult { - delimited('"', take_till(0.., '"'), '"') - .map(|s: &str| s.to_string()) - .parse_next(input) + delimited('"', take_till(0.., '"'), '"') + .map(|s: &str| s.to_string()) + .parse_next(input) } fn bare_word(input: &mut &str) -> ModalResult { - take_while(1.., |c: char| !c.is_whitespace() && c != ')' && c != '(') - .map(|s: &str| s.to_string()) - .parse_next(input) + take_while(1.., |c: char| !c.is_whitespace() && c != ')' && c != '(') + .map(|s: &str| s.to_string()) + .parse_next(input) } fn word_or_quoted(input: &mut &str) -> ModalResult { - alt((quoted_string, bare_word)).parse_next(input) + alt((quoted_string, bare_word)).parse_next(input) } fn not_expr(input: &mut &str) -> ModalResult { - preceded(('-', ws), atom) - .map(|q| SearchQuery::Not(Box::new(q))) - .parse_next(input) + preceded(('-', ws), atom) + .map(|q| SearchQuery::Not(Box::new(q))) + .parse_next(input) } /// Parse a date value like "today", "yesterday", "last-week", "last-30d" fn parse_date_value(s: &str) -> Option { - match s.to_lowercase().as_str() { - "today" => Some(DateValue::Today), - "yesterday" => Some(DateValue::Yesterday), - "this-week" | "thisweek" => Some(DateValue::ThisWeek), - "last-week" | "lastweek" => Some(DateValue::LastWeek), - "this-month" | "thismonth" => Some(DateValue::ThisMonth), - "last-month" | "lastmonth" => Some(DateValue::LastMonth), - "this-year" | "thisyear" => Some(DateValue::ThisYear), - "last-year" | "lastyear" => Some(DateValue::LastYear), - other => { - // Try to parse "last-Nd" format (e.g., "last-7d", "last-30d") - if let Some(rest) = other.strip_prefix("last-") - && let Some(days_str) = rest.strip_suffix('d') - && let Ok(days) = days_str.parse::() - { - return Some(DateValue::DaysAgo(days)); - } - None - } - } + match s.to_lowercase().as_str() { + "today" => Some(DateValue::Today), + "yesterday" => Some(DateValue::Yesterday), + "this-week" | "thisweek" => Some(DateValue::ThisWeek), + "last-week" | "lastweek" => Some(DateValue::LastWeek), + "this-month" | "thismonth" => Some(DateValue::ThisMonth), + "last-month" | "lastmonth" => Some(DateValue::LastMonth), + "this-year" | "thisyear" => Some(DateValue::ThisYear), + "last-year" | "lastyear" => Some(DateValue::LastYear), + other => { + // Try to parse "last-Nd" format (e.g., "last-7d", "last-30d") + if let Some(rest) = other.strip_prefix("last-") + && let Some(days_str) = rest.strip_suffix('d') + && let Ok(days) = days_str.parse::() + { + return Some(DateValue::DaysAgo(days)); + } + None + }, + } } /// Parse size strings like "10MB", "1GB", "500KB" to bytes fn parse_size_value(s: &str) -> Option { - let s = s.to_uppercase(); - if let Some(num) = s.strip_suffix("GB") { - num.parse::().ok().map(|n| n * 1024 * 1024 * 1024) - } else if let Some(num) = s.strip_suffix("MB") { - num.parse::().ok().map(|n| n * 1024 * 1024) - } else if let Some(num) = s.strip_suffix("KB") { - num.parse::().ok().map(|n| n * 1024) - } else if let Some(num) = s.strip_suffix('B') { - num.parse::().ok() - } else { - s.parse::().ok() - } + let s = s.to_uppercase(); + if let Some(num) = s.strip_suffix("GB") { + num.parse::().ok().map(|n| n * 1024 * 1024 * 1024) + } else if let Some(num) = s.strip_suffix("MB") { + num.parse::().ok().map(|n| n * 1024 * 1024) + } else if let Some(num) = s.strip_suffix("KB") { + num.parse::().ok().map(|n| n * 1024) + } else if let Some(num) = s.strip_suffix('B') { + num.parse::().ok() + } else { + s.parse::().ok() + } } fn field_match(input: &mut &str) -> ModalResult { - let field_name = - take_while(1.., |c: char| c.is_alphanumeric() || c == '_').map(|s: &str| s.to_string()); - (field_name, ':', word_or_quoted) - .map(|(field, _, value)| { - // Handle special field types - match field.as_str() { - "type" => return SearchQuery::TypeFilter(value), - "tag" => return SearchQuery::TagFilter(value), - _ => {} - } - - // Check for range queries: field:start..end - if value.contains("..") { - let parts: Vec<&str> = value.split("..").collect(); - if parts.len() == 2 { - let start = if parts[0].is_empty() { - None - } else if field == "size" { - parse_size_value(parts[0]) - } else { - parts[0].parse().ok() - }; - let end = if parts[1].is_empty() { - None - } else if field == "size" { - parse_size_value(parts[1]) - } else { - parts[1].parse().ok() - }; - return SearchQuery::RangeQuery { field, start, end }; - } - } - - // Check for comparison queries: >=, <=, >, < - if let Some(rest) = value.strip_prefix(">=") { - let val = if field == "size" { - parse_size_value(rest).unwrap_or(0) - } else { - rest.parse().unwrap_or(0) - }; - return SearchQuery::CompareQuery { - field, - op: CompareOp::GreaterOrEqual, - value: val, - }; - } - if let Some(rest) = value.strip_prefix("<=") { - let val = if field == "size" { - parse_size_value(rest).unwrap_or(0) - } else { - rest.parse().unwrap_or(0) - }; - return SearchQuery::CompareQuery { - field, - op: CompareOp::LessOrEqual, - value: val, - }; - } - if let Some(rest) = value.strip_prefix('>') { - let val = if field == "size" { - parse_size_value(rest).unwrap_or(0) - } else { - rest.parse().unwrap_or(0) - }; - return SearchQuery::CompareQuery { - field, - op: CompareOp::GreaterThan, - value: val, - }; - } - if let Some(rest) = value.strip_prefix('<') { - let val = if field == "size" { - parse_size_value(rest).unwrap_or(0) - } else { - rest.parse().unwrap_or(0) - }; - return SearchQuery::CompareQuery { - field, - op: CompareOp::LessThan, - value: val, - }; - } - - // Check for date queries on created/modified fields - if (field == "created" || field == "modified") - && let Some(date_val) = parse_date_value(&value) - { - return SearchQuery::DateQuery { - field, - value: date_val, - }; - } - - // Default: simple field match - SearchQuery::FieldMatch { field, value } - }) - .parse_next(input) -} - -fn prefix_expr(input: &mut &str) -> ModalResult { - let word = take_while(1.., |c: char| { - !c.is_whitespace() && c != ')' && c != '(' && c != '*' - }) + let field_name = take_while(1.., |c: char| c.is_alphanumeric() || c == '_') .map(|s: &str| s.to_string()); - (word, '*') - .map(|(w, _)| SearchQuery::Prefix(w)) - .parse_next(input) -} + (field_name, ':', word_or_quoted) + .map(|(field, _, value)| { + // Handle special field types + match field.as_str() { + "type" => return SearchQuery::TypeFilter(value), + "tag" => return SearchQuery::TagFilter(value), + _ => {}, + } -fn fuzzy_expr(input: &mut &str) -> ModalResult { - let word = take_while(1.., |c: char| { - !c.is_whitespace() && c != ')' && c != '(' && c != '~' + // Check for range queries: field:start..end + if value.contains("..") { + let parts: Vec<&str> = value.split("..").collect(); + if parts.len() == 2 { + let start = if parts[0].is_empty() { + None + } else if field == "size" { + parse_size_value(parts[0]) + } else { + parts[0].parse().ok() + }; + let end = if parts[1].is_empty() { + None + } else if field == "size" { + parse_size_value(parts[1]) + } else { + parts[1].parse().ok() + }; + return SearchQuery::RangeQuery { field, start, end }; + } + } + + // Check for comparison queries: >=, <=, >, < + if let Some(rest) = value.strip_prefix(">=") { + let val = if field == "size" { + parse_size_value(rest).unwrap_or(0) + } else { + rest.parse().unwrap_or(0) + }; + return SearchQuery::CompareQuery { + field, + op: CompareOp::GreaterOrEqual, + value: val, + }; + } + if let Some(rest) = value.strip_prefix("<=") { + let val = if field == "size" { + parse_size_value(rest).unwrap_or(0) + } else { + rest.parse().unwrap_or(0) + }; + return SearchQuery::CompareQuery { + field, + op: CompareOp::LessOrEqual, + value: val, + }; + } + if let Some(rest) = value.strip_prefix('>') { + let val = if field == "size" { + parse_size_value(rest).unwrap_or(0) + } else { + rest.parse().unwrap_or(0) + }; + return SearchQuery::CompareQuery { + field, + op: CompareOp::GreaterThan, + value: val, + }; + } + if let Some(rest) = value.strip_prefix('<') { + let val = if field == "size" { + parse_size_value(rest).unwrap_or(0) + } else { + rest.parse().unwrap_or(0) + }; + return SearchQuery::CompareQuery { + field, + op: CompareOp::LessThan, + value: val, + }; + } + + // Check for date queries on created/modified fields + if (field == "created" || field == "modified") + && let Some(date_val) = parse_date_value(&value) + { + return SearchQuery::DateQuery { + field, + value: date_val, + }; + } + + // Default: simple field match + SearchQuery::FieldMatch { field, value } }) - .map(|s: &str| s.to_string()); - (word, '~') - .map(|(w, _)| SearchQuery::Fuzzy(w)) - .parse_next(input) -} - -fn paren_expr(input: &mut &str) -> ModalResult { - delimited(('(', ws), or_expr, (ws, ')')).parse_next(input) -} - -fn not_or_keyword(input: &mut &str) -> ModalResult<()> { - if let Some(rest) = input.strip_prefix("OR") - && (rest.is_empty() || rest.starts_with(' ') || rest.starts_with(')')) - { - return Err(winnow::error::ErrMode::Backtrack( - winnow::error::ContextError::new(), - )); - } - Ok(()) -} - -fn full_text(input: &mut &str) -> ModalResult { - not_or_keyword.parse_next(input)?; - word_or_quoted.map(SearchQuery::FullText).parse_next(input) -} - -fn atom(input: &mut &str) -> ModalResult { - alt(( - paren_expr, - not_expr, - field_match, - prefix_expr, - fuzzy_expr, - full_text, - )) .parse_next(input) } +fn prefix_expr(input: &mut &str) -> ModalResult { + let word = take_while(1.., |c: char| { + !c.is_whitespace() && c != ')' && c != '(' && c != '*' + }) + .map(|s: &str| s.to_string()); + (word, '*') + .map(|(w, _)| SearchQuery::Prefix(w)) + .parse_next(input) +} + +fn fuzzy_expr(input: &mut &str) -> ModalResult { + let word = take_while(1.., |c: char| { + !c.is_whitespace() && c != ')' && c != '(' && c != '~' + }) + .map(|s: &str| s.to_string()); + (word, '~') + .map(|(w, _)| SearchQuery::Fuzzy(w)) + .parse_next(input) +} + +fn paren_expr(input: &mut &str) -> ModalResult { + delimited(('(', ws), or_expr, (ws, ')')).parse_next(input) +} + +fn not_or_keyword(input: &mut &str) -> ModalResult<()> { + if let Some(rest) = input.strip_prefix("OR") + && (rest.is_empty() || rest.starts_with(' ') || rest.starts_with(')')) + { + return Err(winnow::error::ErrMode::Backtrack( + winnow::error::ContextError::new(), + )); + } + Ok(()) +} + +fn full_text(input: &mut &str) -> ModalResult { + not_or_keyword.parse_next(input)?; + word_or_quoted.map(SearchQuery::FullText).parse_next(input) +} + +fn atom(input: &mut &str) -> ModalResult { + alt(( + paren_expr, + not_expr, + field_match, + prefix_expr, + fuzzy_expr, + full_text, + )) + .parse_next(input) +} + fn and_expr(input: &mut &str) -> ModalResult { - let first = atom.parse_next(input)?; - let rest: Vec = repeat(0.., preceded(ws, atom)).parse_next(input)?; - if rest.is_empty() { - Ok(first) - } else { - let mut terms = vec![first]; - terms.extend(rest); - Ok(SearchQuery::And(terms)) - } + let first = atom.parse_next(input)?; + let rest: Vec = + repeat(0.., preceded(ws, atom)).parse_next(input)?; + if rest.is_empty() { + Ok(first) + } else { + let mut terms = vec![first]; + terms.extend(rest); + Ok(SearchQuery::And(terms)) + } } fn or_expr(input: &mut &str) -> ModalResult { - let first = and_expr.parse_next(input)?; - let rest: Vec = - repeat(0.., preceded((ws, "OR", ws), and_expr)).parse_next(input)?; - if rest.is_empty() { - Ok(first) - } else { - let mut terms = vec![first]; - terms.extend(rest); - Ok(SearchQuery::Or(terms)) - } + let first = and_expr.parse_next(input)?; + let rest: Vec = + repeat(0.., preceded((ws, "OR", ws), and_expr)).parse_next(input)?; + if rest.is_empty() { + Ok(first) + } else { + let mut terms = vec![first]; + terms.extend(rest); + Ok(SearchQuery::Or(terms)) + } } pub fn parse_search_query(input: &str) -> crate::error::Result { - let trimmed = input.trim(); - if trimmed.is_empty() { - return Ok(SearchQuery::FullText(String::new())); - } - let mut input = trimmed; - or_expr - .parse_next(&mut input) - .map_err(|e| crate::error::PinakesError::SearchParse(format!("{e}"))) + let trimmed = input.trim(); + if trimmed.is_empty() { + return Ok(SearchQuery::FullText(String::new())); + } + let mut input = trimmed; + or_expr + .parse_next(&mut input) + .map_err(|e| crate::error::PinakesError::SearchParse(format!("{e}"))) } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn test_simple_text() { - let q = parse_search_query("hello").unwrap(); - assert_eq!(q, SearchQuery::FullText("hello".into())); - } + #[test] + fn test_simple_text() { + let q = parse_search_query("hello").unwrap(); + assert_eq!(q, SearchQuery::FullText("hello".into())); + } - #[test] - fn test_field_match() { - let q = parse_search_query("artist:Beatles").unwrap(); - assert_eq!( - q, - SearchQuery::FieldMatch { - field: "artist".into(), - value: "Beatles".into() - } - ); - } + #[test] + fn test_field_match() { + let q = parse_search_query("artist:Beatles").unwrap(); + assert_eq!(q, SearchQuery::FieldMatch { + field: "artist".into(), + value: "Beatles".into(), + }); + } - #[test] - fn test_type_filter() { - let q = parse_search_query("type:pdf").unwrap(); - assert_eq!(q, SearchQuery::TypeFilter("pdf".into())); - } + #[test] + fn test_type_filter() { + let q = parse_search_query("type:pdf").unwrap(); + assert_eq!(q, SearchQuery::TypeFilter("pdf".into())); + } - #[test] - fn test_tag_filter() { - let q = parse_search_query("tag:music").unwrap(); - assert_eq!(q, SearchQuery::TagFilter("music".into())); - } + #[test] + fn test_tag_filter() { + let q = parse_search_query("tag:music").unwrap(); + assert_eq!(q, SearchQuery::TagFilter("music".into())); + } - #[test] - fn test_and_implicit() { - let q = parse_search_query("hello world").unwrap(); - assert_eq!( - q, - SearchQuery::And(vec![ - SearchQuery::FullText("hello".into()), - SearchQuery::FullText("world".into()), - ]) - ); - } + #[test] + fn test_and_implicit() { + let q = parse_search_query("hello world").unwrap(); + assert_eq!( + q, + SearchQuery::And(vec![ + SearchQuery::FullText("hello".into()), + SearchQuery::FullText("world".into()), + ]) + ); + } - #[test] - fn test_or() { - let q = parse_search_query("hello OR world").unwrap(); - assert_eq!( - q, - SearchQuery::Or(vec![ - SearchQuery::FullText("hello".into()), - SearchQuery::FullText("world".into()), - ]) - ); - } + #[test] + fn test_or() { + let q = parse_search_query("hello OR world").unwrap(); + assert_eq!( + q, + SearchQuery::Or(vec![ + SearchQuery::FullText("hello".into()), + SearchQuery::FullText("world".into()), + ]) + ); + } - #[test] - fn test_not() { - let q = parse_search_query("-excluded").unwrap(); - assert_eq!( - q, - SearchQuery::Not(Box::new(SearchQuery::FullText("excluded".into()))) - ); - } + #[test] + fn test_not() { + let q = parse_search_query("-excluded").unwrap(); + assert_eq!( + q, + SearchQuery::Not(Box::new(SearchQuery::FullText("excluded".into()))) + ); + } - #[test] - fn test_prefix() { - let q = parse_search_query("hel*").unwrap(); - assert_eq!(q, SearchQuery::Prefix("hel".into())); - } + #[test] + fn test_prefix() { + let q = parse_search_query("hel*").unwrap(); + assert_eq!(q, SearchQuery::Prefix("hel".into())); + } - #[test] - fn test_fuzzy() { - let q = parse_search_query("hello~").unwrap(); - assert_eq!(q, SearchQuery::Fuzzy("hello".into())); - } + #[test] + fn test_fuzzy() { + let q = parse_search_query("hello~").unwrap(); + assert_eq!(q, SearchQuery::Fuzzy("hello".into())); + } - #[test] - fn test_quoted() { - let q = parse_search_query("\"hello world\"").unwrap(); - assert_eq!(q, SearchQuery::FullText("hello world".into())); - } + #[test] + fn test_quoted() { + let q = parse_search_query("\"hello world\"").unwrap(); + assert_eq!(q, SearchQuery::FullText("hello world".into())); + } - #[test] - fn test_range_query_year() { - let q = parse_search_query("year:2020..2023").unwrap(); - assert_eq!( - q, - SearchQuery::RangeQuery { - field: "year".into(), - start: Some(2020), - end: Some(2023) - } - ); - } + #[test] + fn test_range_query_year() { + let q = parse_search_query("year:2020..2023").unwrap(); + assert_eq!(q, SearchQuery::RangeQuery { + field: "year".into(), + start: Some(2020), + end: Some(2023), + }); + } - #[test] - fn test_range_query_open_start() { - let q = parse_search_query("year:..2023").unwrap(); - assert_eq!( - q, - SearchQuery::RangeQuery { - field: "year".into(), - start: None, - end: Some(2023) - } - ); - } + #[test] + fn test_range_query_open_start() { + let q = parse_search_query("year:..2023").unwrap(); + assert_eq!(q, SearchQuery::RangeQuery { + field: "year".into(), + start: None, + end: Some(2023), + }); + } - #[test] - fn test_range_query_open_end() { - let q = parse_search_query("year:2020..").unwrap(); - assert_eq!( - q, - SearchQuery::RangeQuery { - field: "year".into(), - start: Some(2020), - end: None - } - ); - } + #[test] + fn test_range_query_open_end() { + let q = parse_search_query("year:2020..").unwrap(); + assert_eq!(q, SearchQuery::RangeQuery { + field: "year".into(), + start: Some(2020), + end: None, + }); + } - #[test] - fn test_compare_greater_than() { - let q = parse_search_query("year:>2020").unwrap(); - assert_eq!( - q, - SearchQuery::CompareQuery { - field: "year".into(), - op: CompareOp::GreaterThan, - value: 2020 - } - ); - } + #[test] + fn test_compare_greater_than() { + let q = parse_search_query("year:>2020").unwrap(); + assert_eq!(q, SearchQuery::CompareQuery { + field: "year".into(), + op: CompareOp::GreaterThan, + value: 2020, + }); + } - #[test] - fn test_compare_less_or_equal() { - let q = parse_search_query("year:<=2023").unwrap(); - assert_eq!( - q, - SearchQuery::CompareQuery { - field: "year".into(), - op: CompareOp::LessOrEqual, - value: 2023 - } - ); - } + #[test] + fn test_compare_less_or_equal() { + let q = parse_search_query("year:<=2023").unwrap(); + assert_eq!(q, SearchQuery::CompareQuery { + field: "year".into(), + op: CompareOp::LessOrEqual, + value: 2023, + }); + } - #[test] - fn test_size_compare_mb() { - let q = parse_search_query("size:>10MB").unwrap(); - assert_eq!( - q, - SearchQuery::CompareQuery { - field: "size".into(), - op: CompareOp::GreaterThan, - value: 10 * 1024 * 1024 - } - ); - } + #[test] + fn test_size_compare_mb() { + let q = parse_search_query("size:>10MB").unwrap(); + assert_eq!(q, SearchQuery::CompareQuery { + field: "size".into(), + op: CompareOp::GreaterThan, + value: 10 * 1024 * 1024, + }); + } - #[test] - fn test_size_range_gb() { - let q = parse_search_query("size:1GB..2GB").unwrap(); - assert_eq!( - q, - SearchQuery::RangeQuery { - field: "size".into(), - start: Some(1024 * 1024 * 1024), - end: Some(2 * 1024 * 1024 * 1024) - } - ); - } + #[test] + fn test_size_range_gb() { + let q = parse_search_query("size:1GB..2GB").unwrap(); + assert_eq!(q, SearchQuery::RangeQuery { + field: "size".into(), + start: Some(1024 * 1024 * 1024), + end: Some(2 * 1024 * 1024 * 1024), + }); + } - #[test] - fn test_date_query_today() { - let q = parse_search_query("created:today").unwrap(); - assert_eq!( - q, - SearchQuery::DateQuery { - field: "created".into(), - value: DateValue::Today - } - ); - } + #[test] + fn test_date_query_today() { + let q = parse_search_query("created:today").unwrap(); + assert_eq!(q, SearchQuery::DateQuery { + field: "created".into(), + value: DateValue::Today, + }); + } - #[test] - fn test_date_query_last_week() { - let q = parse_search_query("modified:last-week").unwrap(); - assert_eq!( - q, - SearchQuery::DateQuery { - field: "modified".into(), - value: DateValue::LastWeek - } - ); - } + #[test] + fn test_date_query_last_week() { + let q = parse_search_query("modified:last-week").unwrap(); + assert_eq!(q, SearchQuery::DateQuery { + field: "modified".into(), + value: DateValue::LastWeek, + }); + } - #[test] - fn test_date_query_days_ago() { - let q = parse_search_query("created:last-30d").unwrap(); - assert_eq!( - q, - SearchQuery::DateQuery { - field: "created".into(), - value: DateValue::DaysAgo(30) - } - ); - } + #[test] + fn test_date_query_days_ago() { + let q = parse_search_query("created:last-30d").unwrap(); + assert_eq!(q, SearchQuery::DateQuery { + field: "created".into(), + value: DateValue::DaysAgo(30), + }); + } } diff --git a/crates/pinakes-core/src/sharing.rs b/crates/pinakes-core/src/sharing.rs index cdb67b7..9d75593 100644 --- a/crates/pinakes-core/src/sharing.rs +++ b/crates/pinakes-core/src/sharing.rs @@ -12,423 +12,424 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::model::MediaId; -use crate::users::UserId; +use crate::{model::MediaId, users::UserId}; /// Unique identifier for a share. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct ShareId(pub Uuid); impl ShareId { - pub fn new() -> Self { - Self(Uuid::now_v7()) - } + pub fn new() -> Self { + Self(Uuid::now_v7()) + } } impl Default for ShareId { - fn default() -> Self { - Self::new() - } + fn default() -> Self { + Self::new() + } } impl fmt::Display for ShareId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } } /// What is being shared. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ShareTarget { - Media { media_id: MediaId }, - Collection { collection_id: Uuid }, - Tag { tag_id: Uuid }, - SavedSearch { search_id: Uuid }, + Media { media_id: MediaId }, + Collection { collection_id: Uuid }, + Tag { tag_id: Uuid }, + SavedSearch { search_id: Uuid }, } impl ShareTarget { - pub fn target_type(&self) -> &'static str { - match self { - Self::Media { .. } => "media", - Self::Collection { .. } => "collection", - Self::Tag { .. } => "tag", - Self::SavedSearch { .. } => "saved_search", - } + pub fn target_type(&self) -> &'static str { + match self { + Self::Media { .. } => "media", + Self::Collection { .. } => "collection", + Self::Tag { .. } => "tag", + Self::SavedSearch { .. } => "saved_search", } + } - pub fn target_id(&self) -> Uuid { - match self { - Self::Media { media_id } => media_id.0, - Self::Collection { collection_id } => *collection_id, - Self::Tag { tag_id } => *tag_id, - Self::SavedSearch { search_id } => *search_id, - } + pub fn target_id(&self) -> Uuid { + match self { + Self::Media { media_id } => media_id.0, + Self::Collection { collection_id } => *collection_id, + Self::Tag { tag_id } => *tag_id, + Self::SavedSearch { search_id } => *search_id, } + } } /// Who the share is with. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ShareRecipient { - /// Public link accessible to anyone with the token - PublicLink { - token: String, - password_hash: Option, - }, - /// Shared with a specific user - User { user_id: UserId }, - /// Shared with a group - Group { group_id: Uuid }, - /// Shared with a federated user on another server - Federated { - user_handle: String, - server_url: String, - }, + /// Public link accessible to anyone with the token + PublicLink { + token: String, + password_hash: Option, + }, + /// Shared with a specific user + User { user_id: UserId }, + /// Shared with a group + Group { group_id: Uuid }, + /// Shared with a federated user on another server + Federated { + user_handle: String, + server_url: String, + }, } impl ShareRecipient { - pub fn recipient_type(&self) -> &'static str { - match self { - Self::PublicLink { .. } => "public_link", - Self::User { .. } => "user", - Self::Group { .. } => "group", - Self::Federated { .. } => "federated", - } + pub fn recipient_type(&self) -> &'static str { + match self { + Self::PublicLink { .. } => "public_link", + Self::User { .. } => "user", + Self::Group { .. } => "group", + Self::Federated { .. } => "federated", } + } } /// Permissions granted by a share. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, +)] pub struct SharePermissions { - /// Can view the content - pub can_view: bool, - /// Can download the content - pub can_download: bool, - /// Can edit the content/metadata - pub can_edit: bool, - /// Can delete the content - pub can_delete: bool, - /// Can reshare with others - pub can_reshare: bool, - /// Can add new items (for collections) - pub can_add: bool, + /// Can view the content + pub can_view: bool, + /// Can download the content + pub can_download: bool, + /// Can edit the content/metadata + pub can_edit: bool, + /// Can delete the content + pub can_delete: bool, + /// Can reshare with others + pub can_reshare: bool, + /// Can add new items (for collections) + pub can_add: bool, } impl SharePermissions { - /// View-only permissions - pub fn view_only() -> Self { - Self { - can_view: true, - ..Default::default() - } + /// View-only permissions + pub fn view_only() -> Self { + Self { + can_view: true, + ..Default::default() } + } - /// Download permissions (includes view) - pub fn download() -> Self { - Self { - can_view: true, - can_download: true, - ..Default::default() - } + /// Download permissions (includes view) + pub fn download() -> Self { + Self { + can_view: true, + can_download: true, + ..Default::default() } + } - /// Edit permissions (includes view and download) - pub fn edit() -> Self { - Self { - can_view: true, - can_download: true, - can_edit: true, - can_add: true, - ..Default::default() - } + /// Edit permissions (includes view and download) + pub fn edit() -> Self { + Self { + can_view: true, + can_download: true, + can_edit: true, + can_add: true, + ..Default::default() } + } - /// Full permissions - pub fn full() -> Self { - Self { - can_view: true, - can_download: true, - can_edit: true, - can_delete: true, - can_reshare: true, - can_add: true, - } + /// Full permissions + pub fn full() -> Self { + Self { + can_view: true, + can_download: true, + can_edit: true, + can_delete: true, + can_reshare: true, + can_add: true, } + } - /// Merge permissions (takes the most permissive of each) - pub fn merge(&self, other: &Self) -> Self { - Self { - can_view: self.can_view || other.can_view, - can_download: self.can_download || other.can_download, - can_edit: self.can_edit || other.can_edit, - can_delete: self.can_delete || other.can_delete, - can_reshare: self.can_reshare || other.can_reshare, - can_add: self.can_add || other.can_add, - } + /// Merge permissions (takes the most permissive of each) + pub fn merge(&self, other: &Self) -> Self { + Self { + can_view: self.can_view || other.can_view, + can_download: self.can_download || other.can_download, + can_edit: self.can_edit || other.can_edit, + can_delete: self.can_delete || other.can_delete, + can_reshare: self.can_reshare || other.can_reshare, + can_add: self.can_add || other.can_add, } + } } /// A share record. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Share { - pub id: ShareId, - pub target: ShareTarget, - pub owner_id: UserId, - pub recipient: ShareRecipient, - pub permissions: SharePermissions, - pub note: Option, - pub expires_at: Option>, - pub access_count: u64, - pub last_accessed: Option>, - /// Whether children (media in collection, etc.) inherit this share - pub inherit_to_children: bool, - /// Parent share if this was created via reshare - pub parent_share_id: Option, - pub created_at: DateTime, - pub updated_at: DateTime, + pub id: ShareId, + pub target: ShareTarget, + pub owner_id: UserId, + pub recipient: ShareRecipient, + pub permissions: SharePermissions, + pub note: Option, + pub expires_at: Option>, + pub access_count: u64, + pub last_accessed: Option>, + /// Whether children (media in collection, etc.) inherit this share + pub inherit_to_children: bool, + /// Parent share if this was created via reshare + pub parent_share_id: Option, + pub created_at: DateTime, + pub updated_at: DateTime, } impl Share { - /// Create a new public link share. - pub fn new_public_link( - owner_id: UserId, - target: ShareTarget, - token: String, - permissions: SharePermissions, - ) -> Self { - let now = Utc::now(); - Self { - id: ShareId::new(), - target, - owner_id, - recipient: ShareRecipient::PublicLink { - token, - password_hash: None, - }, - permissions, - note: None, - expires_at: None, - access_count: 0, - last_accessed: None, - inherit_to_children: true, - parent_share_id: None, - created_at: now, - updated_at: now, - } + /// Create a new public link share. + pub fn new_public_link( + owner_id: UserId, + target: ShareTarget, + token: String, + permissions: SharePermissions, + ) -> Self { + let now = Utc::now(); + Self { + id: ShareId::new(), + target, + owner_id, + recipient: ShareRecipient::PublicLink { + token, + password_hash: None, + }, + permissions, + note: None, + expires_at: None, + access_count: 0, + last_accessed: None, + inherit_to_children: true, + parent_share_id: None, + created_at: now, + updated_at: now, } + } - /// Create a new user share. - pub fn new_user_share( - owner_id: UserId, - target: ShareTarget, - recipient_user_id: UserId, - permissions: SharePermissions, - ) -> Self { - let now = Utc::now(); - Self { - id: ShareId::new(), - target, - owner_id, - recipient: ShareRecipient::User { - user_id: recipient_user_id, - }, - permissions, - note: None, - expires_at: None, - access_count: 0, - last_accessed: None, - inherit_to_children: true, - parent_share_id: None, - created_at: now, - updated_at: now, - } + /// Create a new user share. + pub fn new_user_share( + owner_id: UserId, + target: ShareTarget, + recipient_user_id: UserId, + permissions: SharePermissions, + ) -> Self { + let now = Utc::now(); + Self { + id: ShareId::new(), + target, + owner_id, + recipient: ShareRecipient::User { + user_id: recipient_user_id, + }, + permissions, + note: None, + expires_at: None, + access_count: 0, + last_accessed: None, + inherit_to_children: true, + parent_share_id: None, + created_at: now, + updated_at: now, } + } - /// Check if the share has expired. - pub fn is_expired(&self) -> bool { - self.expires_at.map(|exp| exp < Utc::now()).unwrap_or(false) - } + /// Check if the share has expired. + pub fn is_expired(&self) -> bool { + self.expires_at.map(|exp| exp < Utc::now()).unwrap_or(false) + } - /// Check if this is a public link share. - pub fn is_public(&self) -> bool { - matches!(self.recipient, ShareRecipient::PublicLink { .. }) - } + /// Check if this is a public link share. + pub fn is_public(&self) -> bool { + matches!(self.recipient, ShareRecipient::PublicLink { .. }) + } - /// Get the public token if this is a public link share. - pub fn public_token(&self) -> Option<&str> { - match &self.recipient { - ShareRecipient::PublicLink { token, .. } => Some(token), - _ => None, - } + /// Get the public token if this is a public link share. + pub fn public_token(&self) -> Option<&str> { + match &self.recipient { + ShareRecipient::PublicLink { token, .. } => Some(token), + _ => None, } + } } /// Types of share activity actions. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ShareActivityAction { - Created, - Updated, - Accessed, - Downloaded, - Revoked, - Expired, - PasswordFailed, + Created, + Updated, + Accessed, + Downloaded, + Revoked, + Expired, + PasswordFailed, } impl fmt::Display for ShareActivityAction { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Created => write!(f, "created"), - Self::Updated => write!(f, "updated"), - Self::Accessed => write!(f, "accessed"), - Self::Downloaded => write!(f, "downloaded"), - Self::Revoked => write!(f, "revoked"), - Self::Expired => write!(f, "expired"), - Self::PasswordFailed => write!(f, "password_failed"), - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Created => write!(f, "created"), + Self::Updated => write!(f, "updated"), + Self::Accessed => write!(f, "accessed"), + Self::Downloaded => write!(f, "downloaded"), + Self::Revoked => write!(f, "revoked"), + Self::Expired => write!(f, "expired"), + Self::PasswordFailed => write!(f, "password_failed"), } + } } impl std::str::FromStr for ShareActivityAction { - type Err = String; + type Err = String; - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "created" => Ok(Self::Created), - "updated" => Ok(Self::Updated), - "accessed" => Ok(Self::Accessed), - "downloaded" => Ok(Self::Downloaded), - "revoked" => Ok(Self::Revoked), - "expired" => Ok(Self::Expired), - "password_failed" => Ok(Self::PasswordFailed), - _ => Err(format!("unknown share activity action: {}", s)), - } + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "created" => Ok(Self::Created), + "updated" => Ok(Self::Updated), + "accessed" => Ok(Self::Accessed), + "downloaded" => Ok(Self::Downloaded), + "revoked" => Ok(Self::Revoked), + "expired" => Ok(Self::Expired), + "password_failed" => Ok(Self::PasswordFailed), + _ => Err(format!("unknown share activity action: {}", s)), } + } } /// Activity log entry for a share. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ShareActivity { - pub id: Uuid, - pub share_id: ShareId, - pub actor_id: Option, - pub actor_ip: Option, - pub action: ShareActivityAction, - pub details: Option, - pub timestamp: DateTime, + pub id: Uuid, + pub share_id: ShareId, + pub actor_id: Option, + pub actor_ip: Option, + pub action: ShareActivityAction, + pub details: Option, + pub timestamp: DateTime, } impl ShareActivity { - pub fn new(share_id: ShareId, action: ShareActivityAction) -> Self { - Self { - id: Uuid::now_v7(), - share_id, - actor_id: None, - actor_ip: None, - action, - details: None, - timestamp: Utc::now(), - } + pub fn new(share_id: ShareId, action: ShareActivityAction) -> Self { + Self { + id: Uuid::now_v7(), + share_id, + actor_id: None, + actor_ip: None, + action, + details: None, + timestamp: Utc::now(), } + } - pub fn with_actor(mut self, actor_id: UserId) -> Self { - self.actor_id = Some(actor_id); - self - } + pub fn with_actor(mut self, actor_id: UserId) -> Self { + self.actor_id = Some(actor_id); + self + } - pub fn with_ip(mut self, ip: &str) -> Self { - self.actor_ip = Some(ip.to_string()); - self - } + pub fn with_ip(mut self, ip: &str) -> Self { + self.actor_ip = Some(ip.to_string()); + self + } - pub fn with_details(mut self, details: &str) -> Self { - self.details = Some(details.to_string()); - self - } + pub fn with_details(mut self, details: &str) -> Self { + self.details = Some(details.to_string()); + self + } } /// Types of share notifications. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ShareNotificationType { - NewShare, - ShareUpdated, - ShareRevoked, - ShareExpiring, - ShareAccessed, + NewShare, + ShareUpdated, + ShareRevoked, + ShareExpiring, + ShareAccessed, } impl fmt::Display for ShareNotificationType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::NewShare => write!(f, "new_share"), - Self::ShareUpdated => write!(f, "share_updated"), - Self::ShareRevoked => write!(f, "share_revoked"), - Self::ShareExpiring => write!(f, "share_expiring"), - Self::ShareAccessed => write!(f, "share_accessed"), - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NewShare => write!(f, "new_share"), + Self::ShareUpdated => write!(f, "share_updated"), + Self::ShareRevoked => write!(f, "share_revoked"), + Self::ShareExpiring => write!(f, "share_expiring"), + Self::ShareAccessed => write!(f, "share_accessed"), } + } } impl std::str::FromStr for ShareNotificationType { - type Err = String; + type Err = String; - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "new_share" => Ok(Self::NewShare), - "share_updated" => Ok(Self::ShareUpdated), - "share_revoked" => Ok(Self::ShareRevoked), - "share_expiring" => Ok(Self::ShareExpiring), - "share_accessed" => Ok(Self::ShareAccessed), - _ => Err(format!("unknown share notification type: {}", s)), - } + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "new_share" => Ok(Self::NewShare), + "share_updated" => Ok(Self::ShareUpdated), + "share_revoked" => Ok(Self::ShareRevoked), + "share_expiring" => Ok(Self::ShareExpiring), + "share_accessed" => Ok(Self::ShareAccessed), + _ => Err(format!("unknown share notification type: {}", s)), } + } } /// A notification about a share. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ShareNotification { - pub id: Uuid, - pub user_id: UserId, - pub share_id: ShareId, - pub notification_type: ShareNotificationType, - pub is_read: bool, - pub created_at: DateTime, + pub id: Uuid, + pub user_id: UserId, + pub share_id: ShareId, + pub notification_type: ShareNotificationType, + pub is_read: bool, + pub created_at: DateTime, } impl ShareNotification { - pub fn new( - user_id: UserId, - share_id: ShareId, - notification_type: ShareNotificationType, - ) -> Self { - Self { - id: Uuid::now_v7(), - user_id, - share_id, - notification_type, - is_read: false, - created_at: Utc::now(), - } + pub fn new( + user_id: UserId, + share_id: ShareId, + notification_type: ShareNotificationType, + ) -> Self { + Self { + id: Uuid::now_v7(), + user_id, + share_id, + notification_type, + is_read: false, + created_at: Utc::now(), } + } } /// Generate a random share token using UUID. pub fn generate_share_token() -> String { - // Use UUIDv4 for random tokens - simple string representation - Uuid::new_v4().simple().to_string() + // Use UUIDv4 for random tokens - simple string representation + Uuid::new_v4().simple().to_string() } /// Hash a share password. pub fn hash_share_password(password: &str) -> String { - // Use BLAKE3 for password hashing (in production, use Argon2) - blake3::hash(password.as_bytes()).to_hex().to_string() + // Use BLAKE3 for password hashing (in production, use Argon2) + blake3::hash(password.as_bytes()).to_hex().to_string() } /// Verify a share password. pub fn verify_share_password(password: &str, hash: &str) -> bool { - let computed = hash_share_password(password); - computed == hash + let computed = hash_share_password(password); + computed == hash } diff --git a/crates/pinakes-core/src/social.rs b/crates/pinakes-core/src/social.rs index 63bea0d..c02d897 100644 --- a/crates/pinakes-core/src/social.rs +++ b/crates/pinakes-core/src/social.rs @@ -4,49 +4,48 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::model::MediaId; -use crate::users::UserId; +use crate::{model::MediaId, users::UserId}; /// A user's rating for a media item. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Rating { - pub id: Uuid, - pub user_id: UserId, - pub media_id: MediaId, - pub stars: u8, - pub review_text: Option, - pub created_at: DateTime, + pub id: Uuid, + pub user_id: UserId, + pub media_id: MediaId, + pub stars: u8, + pub review_text: Option, + pub created_at: DateTime, } /// A comment on a media item, supporting threaded replies. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Comment { - pub id: Uuid, - pub user_id: UserId, - pub media_id: MediaId, - pub parent_comment_id: Option, - pub text: String, - pub created_at: DateTime, + pub id: Uuid, + pub user_id: UserId, + pub media_id: MediaId, + pub parent_comment_id: Option, + pub text: String, + pub created_at: DateTime, } /// A user's favorite bookmark for a media item. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Favorite { - pub user_id: UserId, - pub media_id: MediaId, - pub created_at: DateTime, + pub user_id: UserId, + pub media_id: MediaId, + pub created_at: DateTime, } /// A shareable link to a media item with optional password and expiration. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ShareLink { - pub id: Uuid, - pub media_id: MediaId, - pub created_by: UserId, - pub token: String, - #[serde(skip_serializing)] - pub password_hash: Option, - pub expires_at: Option>, - pub view_count: u64, - pub created_at: DateTime, + pub id: Uuid, + pub media_id: MediaId, + pub created_by: UserId, + pub token: String, + #[serde(skip_serializing)] + pub password_hash: Option, + pub expires_at: Option>, + pub view_count: u64, + pub created_at: DateTime, } diff --git a/crates/pinakes-core/src/storage/migrations.rs b/crates/pinakes-core/src/storage/migrations.rs index bc0cbec..cedd987 100644 --- a/crates/pinakes-core/src/storage/migrations.rs +++ b/crates/pinakes-core/src/storage/migrations.rs @@ -1,26 +1,28 @@ use crate::error::{PinakesError, Result}; mod sqlite_migrations { - use refinery::embed_migrations; - embed_migrations!("../../migrations/sqlite"); + use refinery::embed_migrations; + embed_migrations!("../../migrations/sqlite"); } mod postgres_migrations { - use refinery::embed_migrations; - embed_migrations!("../../migrations/postgres"); + use refinery::embed_migrations; + embed_migrations!("../../migrations/postgres"); } pub fn run_sqlite_migrations(conn: &mut rusqlite::Connection) -> Result<()> { - sqlite_migrations::migrations::runner() - .run(conn) - .map_err(|e| PinakesError::Migration(e.to_string()))?; - Ok(()) + sqlite_migrations::migrations::runner() + .run(conn) + .map_err(|e| PinakesError::Migration(e.to_string()))?; + Ok(()) } -pub async fn run_postgres_migrations(client: &mut tokio_postgres::Client) -> Result<()> { - postgres_migrations::migrations::runner() - .run_async(client) - .await - .map_err(|e| PinakesError::Migration(e.to_string()))?; - Ok(()) +pub async fn run_postgres_migrations( + client: &mut tokio_postgres::Client, +) -> Result<()> { + postgres_migrations::migrations::runner() + .run_async(client) + .await + .map_err(|e| PinakesError::Migration(e.to_string()))?; + Ok(()) } diff --git a/crates/pinakes-core/src/storage/mod.rs b/crates/pinakes-core/src/storage/mod.rs index 29e93ce..04b3c29 100644 --- a/crates/pinakes-core/src/storage/mod.rs +++ b/crates/pinakes-core/src/storage/mod.rs @@ -2,852 +2,1026 @@ pub mod migrations; pub mod postgres; pub mod sqlite; -use std::path::PathBuf; -use std::sync::Arc; - -use uuid::Uuid; +use std::{path::PathBuf, sync::Arc}; use chrono::{DateTime, Utc}; +use uuid::Uuid; -use crate::analytics::UsageEvent; -use crate::enrichment::ExternalMetadata; -use crate::error::Result; -use crate::model::*; -use crate::playlists::Playlist; -use crate::search::{SearchRequest, SearchResults}; -use crate::social::{Comment, Rating, ShareLink}; -use crate::subtitles::Subtitle; -use crate::transcode::{TranscodeSession, TranscodeStatus}; -use crate::users::UserId; +use crate::{ + analytics::UsageEvent, + enrichment::ExternalMetadata, + error::Result, + model::*, + playlists::Playlist, + search::{SearchRequest, SearchResults}, + social::{Comment, Rating, ShareLink}, + subtitles::Subtitle, + transcode::{TranscodeSession, TranscodeStatus}, + users::UserId, +}; /// Statistics about the database. #[derive(Debug, Clone, Default)] pub struct DatabaseStats { - pub media_count: u64, - pub tag_count: u64, - pub collection_count: u64, - pub audit_count: u64, - pub database_size_bytes: u64, - pub backend_name: String, + pub media_count: u64, + pub tag_count: u64, + pub collection_count: u64, + pub audit_count: u64, + pub database_size_bytes: u64, + pub backend_name: String, } /// Session data for database-backed session storage. #[derive(Debug, Clone)] pub struct SessionData { - pub session_token: String, - pub user_id: Option, - pub username: String, - pub role: String, - pub created_at: DateTime, - pub expires_at: DateTime, - pub last_accessed: DateTime, + pub session_token: String, + pub user_id: Option, + pub username: String, + pub role: String, + pub created_at: DateTime, + pub expires_at: DateTime, + pub last_accessed: DateTime, } #[async_trait::async_trait] pub trait StorageBackend: Send + Sync + 'static { - // Migrations - async fn run_migrations(&self) -> Result<()>; + // Migrations + async fn run_migrations(&self) -> Result<()>; - // Root directories - async fn add_root_dir(&self, path: PathBuf) -> Result<()>; - async fn list_root_dirs(&self) -> Result>; - async fn remove_root_dir(&self, path: &std::path::Path) -> Result<()>; + // Root directories + async fn add_root_dir(&self, path: PathBuf) -> Result<()>; + async fn list_root_dirs(&self) -> Result>; + async fn remove_root_dir(&self, path: &std::path::Path) -> Result<()>; - // Media CRUD - async fn insert_media(&self, item: &MediaItem) -> Result<()>; - async fn get_media(&self, id: MediaId) -> Result; - async fn count_media(&self) -> Result; - async fn get_media_by_hash(&self, hash: &ContentHash) -> Result>; - /// Get a media item by its file path (used for incremental scanning) - async fn get_media_by_path(&self, path: &std::path::Path) -> Result>; - async fn list_media(&self, pagination: &Pagination) -> Result>; - async fn update_media(&self, item: &MediaItem) -> Result<()>; - async fn delete_media(&self, id: MediaId) -> Result<()>; - async fn delete_all_media(&self) -> Result; + // Media CRUD + async fn insert_media(&self, item: &MediaItem) -> Result<()>; + async fn get_media(&self, id: MediaId) -> Result; + async fn count_media(&self) -> Result; + async fn get_media_by_hash( + &self, + hash: &ContentHash, + ) -> Result>; + /// Get a media item by its file path (used for incremental scanning) + async fn get_media_by_path( + &self, + path: &std::path::Path, + ) -> Result>; + async fn list_media(&self, pagination: &Pagination) + -> Result>; + async fn update_media(&self, item: &MediaItem) -> Result<()>; + async fn delete_media(&self, id: MediaId) -> Result<()>; + async fn delete_all_media(&self) -> Result; - // Tags - async fn create_tag(&self, name: &str, parent_id: Option) -> Result; - async fn get_tag(&self, id: Uuid) -> Result; - async fn list_tags(&self) -> Result>; - async fn delete_tag(&self, id: Uuid) -> Result<()>; - async fn tag_media(&self, media_id: MediaId, tag_id: Uuid) -> Result<()>; - async fn untag_media(&self, media_id: MediaId, tag_id: Uuid) -> Result<()>; - async fn get_media_tags(&self, media_id: MediaId) -> Result>; - async fn get_tag_descendants(&self, tag_id: Uuid) -> Result>; + // Tags + async fn create_tag( + &self, + name: &str, + parent_id: Option, + ) -> Result; + async fn get_tag(&self, id: Uuid) -> Result; + async fn list_tags(&self) -> Result>; + async fn delete_tag(&self, id: Uuid) -> Result<()>; + async fn tag_media(&self, media_id: MediaId, tag_id: Uuid) -> Result<()>; + async fn untag_media(&self, media_id: MediaId, tag_id: Uuid) -> Result<()>; + async fn get_media_tags(&self, media_id: MediaId) -> Result>; + async fn get_tag_descendants(&self, tag_id: Uuid) -> Result>; - // Collections - async fn create_collection( - &self, - name: &str, - kind: CollectionKind, - description: Option<&str>, - filter_query: Option<&str>, - ) -> Result; - async fn get_collection(&self, id: Uuid) -> Result; - async fn list_collections(&self) -> Result>; - async fn delete_collection(&self, id: Uuid) -> Result<()>; - async fn add_to_collection( - &self, - collection_id: Uuid, - media_id: MediaId, - position: i32, - ) -> Result<()>; - async fn remove_from_collection(&self, collection_id: Uuid, media_id: MediaId) -> Result<()>; - async fn get_collection_members(&self, collection_id: Uuid) -> Result>; + // Collections + async fn create_collection( + &self, + name: &str, + kind: CollectionKind, + description: Option<&str>, + filter_query: Option<&str>, + ) -> Result; + async fn get_collection(&self, id: Uuid) -> Result; + async fn list_collections(&self) -> Result>; + async fn delete_collection(&self, id: Uuid) -> Result<()>; + async fn add_to_collection( + &self, + collection_id: Uuid, + media_id: MediaId, + position: i32, + ) -> Result<()>; + async fn remove_from_collection( + &self, + collection_id: Uuid, + media_id: MediaId, + ) -> Result<()>; + async fn get_collection_members( + &self, + collection_id: Uuid, + ) -> Result>; - // Search - async fn search(&self, request: &SearchRequest) -> Result; + // Search + async fn search(&self, request: &SearchRequest) -> Result; - // Audit - async fn record_audit(&self, entry: &AuditEntry) -> Result<()>; - async fn list_audit_entries( - &self, - media_id: Option, - pagination: &Pagination, - ) -> Result>; + // Audit + async fn record_audit(&self, entry: &AuditEntry) -> Result<()>; + async fn list_audit_entries( + &self, + media_id: Option, + pagination: &Pagination, + ) -> Result>; - // Custom fields - async fn set_custom_field( - &self, - media_id: MediaId, - name: &str, - field: &CustomField, - ) -> Result<()>; - async fn get_custom_fields( - &self, - media_id: MediaId, - ) -> Result>; - async fn delete_custom_field(&self, media_id: MediaId, name: &str) -> Result<()>; + // Custom fields + async fn set_custom_field( + &self, + media_id: MediaId, + name: &str, + field: &CustomField, + ) -> Result<()>; + async fn get_custom_fields( + &self, + media_id: MediaId, + ) -> Result>; + async fn delete_custom_field( + &self, + media_id: MediaId, + name: &str, + ) -> Result<()>; - // Batch operations (transactional where supported) - async fn batch_delete_media(&self, ids: &[MediaId]) -> Result { - let mut count = 0u64; - for id in ids { - self.delete_media(*id).await?; - count += 1; - } - Ok(count) + // Batch operations (transactional where supported) + async fn batch_delete_media(&self, ids: &[MediaId]) -> Result { + let mut count = 0u64; + for id in ids { + self.delete_media(*id).await?; + count += 1; + } + Ok(count) + } + + async fn batch_tag_media( + &self, + media_ids: &[MediaId], + tag_ids: &[Uuid], + ) -> Result { + let mut count = 0u64; + for media_id in media_ids { + for tag_id in tag_ids { + self.tag_media(*media_id, *tag_id).await?; + count += 1; + } + } + Ok(count) + } + + // Integrity + async fn list_media_paths( + &self, + ) -> Result>; + + // Batch metadata update + #[allow(clippy::too_many_arguments)] + async fn batch_update_media( + &self, + ids: &[MediaId], + title: Option<&str>, + artist: Option<&str>, + album: Option<&str>, + genre: Option<&str>, + year: Option, + description: Option<&str>, + ) -> Result { + let mut count = 0u64; + for id in ids { + let mut item = self.get_media(*id).await?; + if let Some(v) = title { + item.title = Some(v.to_string()); + } + if let Some(v) = artist { + item.artist = Some(v.to_string()); + } + if let Some(v) = album { + item.album = Some(v.to_string()); + } + if let Some(v) = genre { + item.genre = Some(v.to_string()); + } + if let Some(v) = &year { + item.year = Some(*v); + } + if let Some(v) = description { + item.description = Some(v.to_string()); + } + item.updated_at = chrono::Utc::now(); + self.update_media(&item).await?; + count += 1; + } + Ok(count) + } + + // Saved searches + async fn save_search( + &self, + id: uuid::Uuid, + name: &str, + query: &str, + sort_order: Option<&str>, + ) -> Result<()>; + async fn list_saved_searches(&self) + -> Result>; + async fn delete_saved_search(&self, id: uuid::Uuid) -> Result<()>; + + // Duplicates + async fn find_duplicates(&self) -> Result>>; + async fn find_perceptual_duplicates( + &self, + threshold: u32, + ) -> Result>>; + + // Database management + async fn database_stats(&self) -> Result; + async fn vacuum(&self) -> Result<()>; + async fn clear_all_data(&self) -> Result<()>; + + // Thumbnail helpers + /// List all media IDs, optionally filtering to those missing thumbnails. + async fn list_media_ids_for_thumbnails( + &self, + only_missing: bool, + ) -> Result>; + + // Library statistics + async fn library_statistics(&self) -> Result; + + // User Management + async fn list_users(&self) -> Result>; + async fn get_user( + &self, + id: crate::users::UserId, + ) -> Result; + async fn get_user_by_username( + &self, + username: &str, + ) -> Result; + async fn create_user( + &self, + username: &str, + password_hash: &str, + role: crate::config::UserRole, + profile: Option, + ) -> Result; + async fn update_user( + &self, + id: crate::users::UserId, + password_hash: Option<&str>, + role: Option, + profile: Option, + ) -> Result; + async fn delete_user(&self, id: crate::users::UserId) -> Result<()>; + async fn get_user_libraries( + &self, + user_id: crate::users::UserId, + ) -> Result>; + async fn grant_library_access( + &self, + user_id: crate::users::UserId, + root_path: &str, + permission: crate::users::LibraryPermission, + ) -> Result<()>; + async fn revoke_library_access( + &self, + user_id: crate::users::UserId, + root_path: &str, + ) -> Result<()>; + + /// Check if a user has access to a specific media item based on library + /// permissions. Returns the permission level if access is granted, or an + /// error if denied. Admin users (role=admin) bypass library checks and have + /// full access. + async fn check_library_access( + &self, + user_id: crate::users::UserId, + media_id: crate::model::MediaId, + ) -> Result { + // Default implementation: get the media item's path and check against + // user's library access + let media = self.get_media(media_id).await?; + let path_str = media.path.to_string_lossy().to_string(); + + // Get user's library permissions + let libraries = self.get_user_libraries(user_id).await?; + + // If user has no library restrictions, they have no access (unless they're + // admin) This default impl requires at least one matching library + // permission + for lib in &libraries { + if path_str.starts_with(&lib.root_path) { + return Ok(lib.permission); + } } - async fn batch_tag_media(&self, media_ids: &[MediaId], tag_ids: &[Uuid]) -> Result { - let mut count = 0u64; - for media_id in media_ids { - for tag_id in tag_ids { - self.tag_media(*media_id, *tag_id).await?; - count += 1; - } - } - Ok(count) + Err(crate::error::PinakesError::Authorization(format!( + "user {} has no access to media {}", + user_id, media_id + ))) + } + + /// Check if a user has at least read access to a media item + async fn has_media_read_access( + &self, + user_id: crate::users::UserId, + media_id: crate::model::MediaId, + ) -> Result { + match self.check_library_access(user_id, media_id).await { + Ok(perm) => Ok(perm.can_read()), + Err(_) => Ok(false), } + } - // Integrity - async fn list_media_paths(&self) -> Result>; - - // Batch metadata update - #[allow(clippy::too_many_arguments)] - async fn batch_update_media( - &self, - ids: &[MediaId], - title: Option<&str>, - artist: Option<&str>, - album: Option<&str>, - genre: Option<&str>, - year: Option, - description: Option<&str>, - ) -> Result { - let mut count = 0u64; - for id in ids { - let mut item = self.get_media(*id).await?; - if let Some(v) = title { - item.title = Some(v.to_string()); - } - if let Some(v) = artist { - item.artist = Some(v.to_string()); - } - if let Some(v) = album { - item.album = Some(v.to_string()); - } - if let Some(v) = genre { - item.genre = Some(v.to_string()); - } - if let Some(v) = &year { - item.year = Some(*v); - } - if let Some(v) = description { - item.description = Some(v.to_string()); - } - item.updated_at = chrono::Utc::now(); - self.update_media(&item).await?; - count += 1; - } - Ok(count) + /// Check if a user has write access to a media item + async fn has_media_write_access( + &self, + user_id: crate::users::UserId, + media_id: crate::model::MediaId, + ) -> Result { + match self.check_library_access(user_id, media_id).await { + Ok(perm) => Ok(perm.can_write()), + Err(_) => Ok(false), } + } - // Saved searches - async fn save_search( - &self, - id: uuid::Uuid, - name: &str, - query: &str, - sort_order: Option<&str>, - ) -> Result<()>; - async fn list_saved_searches(&self) -> Result>; - async fn delete_saved_search(&self, id: uuid::Uuid) -> Result<()>; + // ===== Ratings ===== + async fn rate_media( + &self, + user_id: UserId, + media_id: MediaId, + stars: u8, + review: Option<&str>, + ) -> Result; + async fn get_media_ratings(&self, media_id: MediaId) -> Result>; + async fn get_user_rating( + &self, + user_id: UserId, + media_id: MediaId, + ) -> Result>; + async fn delete_rating(&self, id: Uuid) -> Result<()>; - // Duplicates - async fn find_duplicates(&self) -> Result>>; - async fn find_perceptual_duplicates(&self, threshold: u32) -> Result>>; + // ===== Comments ===== + async fn add_comment( + &self, + user_id: UserId, + media_id: MediaId, + text: &str, + parent_id: Option, + ) -> Result; + async fn get_media_comments(&self, media_id: MediaId) + -> Result>; + async fn delete_comment(&self, id: Uuid) -> Result<()>; - // Database management - async fn database_stats(&self) -> Result; - async fn vacuum(&self) -> Result<()>; - async fn clear_all_data(&self) -> Result<()>; + // ===== Favorites ===== + async fn add_favorite( + &self, + user_id: UserId, + media_id: MediaId, + ) -> Result<()>; + async fn remove_favorite( + &self, + user_id: UserId, + media_id: MediaId, + ) -> Result<()>; + async fn get_user_favorites( + &self, + user_id: UserId, + pagination: &Pagination, + ) -> Result>; + async fn is_favorite( + &self, + user_id: UserId, + media_id: MediaId, + ) -> Result; - // Thumbnail helpers - /// List all media IDs, optionally filtering to those missing thumbnails. - async fn list_media_ids_for_thumbnails( - &self, - only_missing: bool, - ) -> Result>; + // ===== Share Links ===== + async fn create_share_link( + &self, + media_id: MediaId, + created_by: UserId, + token: &str, + password_hash: Option<&str>, + expires_at: Option>, + ) -> Result; + async fn get_share_link(&self, token: &str) -> Result; + async fn increment_share_views(&self, token: &str) -> Result<()>; + async fn delete_share_link(&self, id: Uuid) -> Result<()>; - // Library statistics - async fn library_statistics(&self) -> Result; + // ===== Playlists ===== + async fn create_playlist( + &self, + owner_id: UserId, + name: &str, + description: Option<&str>, + is_public: bool, + is_smart: bool, + filter_query: Option<&str>, + ) -> Result; + async fn get_playlist(&self, id: Uuid) -> Result; + async fn list_playlists( + &self, + owner_id: Option, + ) -> Result>; + async fn update_playlist( + &self, + id: Uuid, + name: Option<&str>, + description: Option<&str>, + is_public: Option, + ) -> Result; + async fn delete_playlist(&self, id: Uuid) -> Result<()>; + async fn add_to_playlist( + &self, + playlist_id: Uuid, + media_id: MediaId, + position: i32, + ) -> Result<()>; + async fn remove_from_playlist( + &self, + playlist_id: Uuid, + media_id: MediaId, + ) -> Result<()>; + async fn get_playlist_items( + &self, + playlist_id: Uuid, + ) -> Result>; + async fn reorder_playlist( + &self, + playlist_id: Uuid, + media_id: MediaId, + new_position: i32, + ) -> Result<()>; - // User Management - async fn list_users(&self) -> Result>; - async fn get_user(&self, id: crate::users::UserId) -> Result; - async fn get_user_by_username(&self, username: &str) -> Result; - async fn create_user( - &self, - username: &str, - password_hash: &str, - role: crate::config::UserRole, - profile: Option, - ) -> Result; - async fn update_user( - &self, - id: crate::users::UserId, - password_hash: Option<&str>, - role: Option, - profile: Option, - ) -> Result; - async fn delete_user(&self, id: crate::users::UserId) -> Result<()>; - async fn get_user_libraries( - &self, - user_id: crate::users::UserId, - ) -> Result>; - async fn grant_library_access( - &self, - user_id: crate::users::UserId, - root_path: &str, - permission: crate::users::LibraryPermission, - ) -> Result<()>; - async fn revoke_library_access( - &self, - user_id: crate::users::UserId, - root_path: &str, - ) -> Result<()>; + // ===== Analytics ===== + async fn record_usage_event(&self, event: &UsageEvent) -> Result<()>; + async fn get_usage_events( + &self, + media_id: Option, + user_id: Option, + limit: u64, + ) -> Result>; + async fn get_most_viewed(&self, limit: u64) -> Result>; + async fn get_recently_viewed( + &self, + user_id: UserId, + limit: u64, + ) -> Result>; + async fn update_watch_progress( + &self, + user_id: UserId, + media_id: MediaId, + progress_secs: f64, + ) -> Result<()>; + async fn get_watch_progress( + &self, + user_id: UserId, + media_id: MediaId, + ) -> Result>; + async fn cleanup_old_events(&self, before: DateTime) -> Result; - /// Check if a user has access to a specific media item based on library permissions. - /// Returns the permission level if access is granted, or an error if denied. - /// Admin users (role=admin) bypass library checks and have full access. - async fn check_library_access( - &self, - user_id: crate::users::UserId, - media_id: crate::model::MediaId, - ) -> Result { - // Default implementation: get the media item's path and check against user's library access - let media = self.get_media(media_id).await?; - let path_str = media.path.to_string_lossy().to_string(); + // ===== Subtitles ===== + async fn add_subtitle(&self, subtitle: &Subtitle) -> Result<()>; + async fn get_media_subtitles( + &self, + media_id: MediaId, + ) -> Result>; + async fn delete_subtitle(&self, id: Uuid) -> Result<()>; + async fn update_subtitle_offset( + &self, + id: Uuid, + offset_ms: i64, + ) -> Result<()>; - // Get user's library permissions - let libraries = self.get_user_libraries(user_id).await?; + // ===== External Metadata (Enrichment) ===== + async fn store_external_metadata( + &self, + meta: &ExternalMetadata, + ) -> Result<()>; + async fn get_external_metadata( + &self, + media_id: MediaId, + ) -> Result>; + async fn delete_external_metadata(&self, id: Uuid) -> Result<()>; - // If user has no library restrictions, they have no access (unless they're admin) - // This default impl requires at least one matching library permission - for lib in &libraries { - if path_str.starts_with(&lib.root_path) { - return Ok(lib.permission); - } - } + // ===== Transcode Sessions ===== + async fn create_transcode_session( + &self, + session: &TranscodeSession, + ) -> Result<()>; + async fn get_transcode_session(&self, id: Uuid) -> Result; + async fn list_transcode_sessions( + &self, + media_id: Option, + ) -> Result>; + async fn update_transcode_status( + &self, + id: Uuid, + status: TranscodeStatus, + progress: f32, + ) -> Result<()>; + async fn cleanup_expired_transcodes( + &self, + before: DateTime, + ) -> Result; - Err(crate::error::PinakesError::Authorization(format!( - "user {} has no access to media {}", - user_id, media_id - ))) + // ===== Session Management ===== + /// Create a new session in the database + async fn create_session(&self, session: &SessionData) -> Result<()>; + + /// Get a session by its token, returns None if not found or expired + async fn get_session( + &self, + session_token: &str, + ) -> Result>; + + /// Update the last_accessed timestamp for a session + async fn touch_session(&self, session_token: &str) -> Result<()>; + + /// Delete a specific session + async fn delete_session(&self, session_token: &str) -> Result<()>; + + /// Delete all sessions for a specific user + async fn delete_user_sessions(&self, username: &str) -> Result; + + /// Delete all expired sessions (where expires_at < now) + async fn delete_expired_sessions(&self) -> Result; + + /// List all active sessions (optionally filtered by username) + async fn list_active_sessions( + &self, + username: Option<&str>, + ) -> Result>; + + // Book Management Methods + + /// Upsert book metadata for a media item + async fn upsert_book_metadata( + &self, + metadata: &crate::model::BookMetadata, + ) -> Result<()>; + + /// Get book metadata for a media item + async fn get_book_metadata( + &self, + media_id: MediaId, + ) -> Result>; + + /// Add an author to a book + async fn add_book_author( + &self, + media_id: MediaId, + author: &crate::model::AuthorInfo, + ) -> Result<()>; + + /// Get all authors for a book + async fn get_book_authors( + &self, + media_id: MediaId, + ) -> Result>; + + /// List all distinct authors with book counts + async fn list_all_authors( + &self, + pagination: &Pagination, + ) -> Result>; + + /// List all series with book counts + async fn list_series(&self) -> Result>; + + /// Get all books in a series, ordered by series_index + async fn get_series_books(&self, series_name: &str) + -> Result>; + + /// Update reading progress for a user and book + async fn update_reading_progress( + &self, + user_id: uuid::Uuid, + media_id: MediaId, + current_page: i32, + ) -> Result<()>; + + /// Get reading progress for a user and book + async fn get_reading_progress( + &self, + user_id: uuid::Uuid, + media_id: MediaId, + ) -> Result>; + + /// Get reading list for a user filtered by status + async fn get_reading_list( + &self, + user_id: uuid::Uuid, + status: Option, + ) -> Result>; + + /// Search books with book-specific criteria + async fn search_books( + &self, + isbn: Option<&str>, + author: Option<&str>, + series: Option<&str>, + publisher: Option<&str>, + language: Option<&str>, + pagination: &Pagination, + ) -> Result>; + + // ===== Managed Storage ===== + + /// Insert a media item that uses managed storage + async fn insert_managed_media(&self, item: &MediaItem) -> Result<()>; + + /// Get or create a managed blob record (for deduplication tracking) + async fn get_or_create_blob( + &self, + hash: &ContentHash, + size: u64, + mime_type: &str, + ) -> Result; + + /// Get a managed blob by its content hash + async fn get_blob(&self, hash: &ContentHash) -> Result>; + + /// Increment the reference count for a blob + async fn increment_blob_ref(&self, hash: &ContentHash) -> Result<()>; + + /// Decrement the reference count for a blob. Returns true if blob should be + /// deleted. + async fn decrement_blob_ref(&self, hash: &ContentHash) -> Result; + + /// Update the last_verified timestamp for a blob + async fn update_blob_verified(&self, hash: &ContentHash) -> Result<()>; + + /// List orphaned blobs (reference_count = 0) + async fn list_orphaned_blobs(&self) -> Result>; + + /// Delete a blob record + async fn delete_blob(&self, hash: &ContentHash) -> Result<()>; + + /// Get managed storage statistics + async fn managed_storage_stats(&self) -> Result; + + // ===== Sync Devices ===== + + /// Register a new sync device + async fn register_device( + &self, + device: &crate::sync::SyncDevice, + token_hash: &str, + ) -> Result; + + /// Get a sync device by ID + async fn get_device( + &self, + id: crate::sync::DeviceId, + ) -> Result; + + /// Get a sync device by its token hash + async fn get_device_by_token( + &self, + token_hash: &str, + ) -> Result>; + + /// List all devices for a user + async fn list_user_devices( + &self, + user_id: UserId, + ) -> Result>; + + /// Update a sync device + async fn update_device(&self, device: &crate::sync::SyncDevice) + -> Result<()>; + + /// Delete a sync device + async fn delete_device(&self, id: crate::sync::DeviceId) -> Result<()>; + + /// Update the last_seen_at timestamp for a device + async fn touch_device(&self, id: crate::sync::DeviceId) -> Result<()>; + + // ===== Sync Log ===== + + /// Record a change in the sync log + async fn record_sync_change( + &self, + change: &crate::sync::SyncLogEntry, + ) -> Result<()>; + + /// Get changes since a cursor position + async fn get_changes_since( + &self, + cursor: i64, + limit: u64, + ) -> Result>; + + /// Get the current sync cursor (highest sequence number) + async fn get_current_sync_cursor(&self) -> Result; + + /// Clean up old sync log entries + async fn cleanup_old_sync_log(&self, before: DateTime) -> Result; + + // ===== Device Sync State ===== + + /// Get sync state for a device and path + async fn get_device_sync_state( + &self, + device_id: crate::sync::DeviceId, + path: &str, + ) -> Result>; + + /// Insert or update device sync state + async fn upsert_device_sync_state( + &self, + state: &crate::sync::DeviceSyncState, + ) -> Result<()>; + + /// List all pending sync items for a device + async fn list_pending_sync( + &self, + device_id: crate::sync::DeviceId, + ) -> Result>; + + // ===== Upload Sessions (Chunked Uploads) ===== + + /// Create a new upload session + async fn create_upload_session( + &self, + session: &crate::sync::UploadSession, + ) -> Result<()>; + + /// Get an upload session by ID + async fn get_upload_session( + &self, + id: Uuid, + ) -> Result; + + /// Update an upload session + async fn update_upload_session( + &self, + session: &crate::sync::UploadSession, + ) -> Result<()>; + + /// Record a received chunk + async fn record_chunk( + &self, + upload_id: Uuid, + chunk: &crate::sync::ChunkInfo, + ) -> Result<()>; + + /// Get all chunks for an upload + async fn get_upload_chunks( + &self, + upload_id: Uuid, + ) -> Result>; + + /// Clean up expired upload sessions + async fn cleanup_expired_uploads(&self) -> Result; + + // ===== Sync Conflicts ===== + + /// Record a sync conflict + async fn record_conflict( + &self, + conflict: &crate::sync::SyncConflict, + ) -> Result<()>; + + /// Get unresolved conflicts for a device + async fn get_unresolved_conflicts( + &self, + device_id: crate::sync::DeviceId, + ) -> Result>; + + /// Resolve a conflict + async fn resolve_conflict( + &self, + id: Uuid, + resolution: crate::config::ConflictResolution, + ) -> Result<()>; + + // ===== Enhanced Sharing ===== + + /// Create a new share + async fn create_share( + &self, + share: &crate::sharing::Share, + ) -> Result; + + /// Get a share by ID + async fn get_share( + &self, + id: crate::sharing::ShareId, + ) -> Result; + + /// Get a share by its public token + async fn get_share_by_token( + &self, + token: &str, + ) -> Result; + + /// List shares created by a user + async fn list_shares_by_owner( + &self, + owner_id: UserId, + pagination: &Pagination, + ) -> Result>; + + /// List shares received by a user + async fn list_shares_for_user( + &self, + user_id: UserId, + pagination: &Pagination, + ) -> Result>; + + /// List all shares for a specific target + async fn list_shares_for_target( + &self, + target: &crate::sharing::ShareTarget, + ) -> Result>; + + /// Update a share + async fn update_share( + &self, + share: &crate::sharing::Share, + ) -> Result; + + /// Delete a share + async fn delete_share(&self, id: crate::sharing::ShareId) -> Result<()>; + + /// Record that a share was accessed + async fn record_share_access( + &self, + id: crate::sharing::ShareId, + ) -> Result<()>; + + /// Check share access for a user and target + async fn check_share_access( + &self, + user_id: Option, + target: &crate::sharing::ShareTarget, + ) -> Result>; + + /// Get effective permissions for a media item (considering inheritance) + async fn get_effective_share_permissions( + &self, + user_id: Option, + media_id: MediaId, + ) -> Result>; + + /// Batch delete shares + async fn batch_delete_shares( + &self, + ids: &[crate::sharing::ShareId], + ) -> Result; + + /// Clean up expired shares + async fn cleanup_expired_shares(&self) -> Result; + + // ===== Share Activity ===== + + /// Record share activity + async fn record_share_activity( + &self, + activity: &crate::sharing::ShareActivity, + ) -> Result<()>; + + /// Get activity for a share + async fn get_share_activity( + &self, + share_id: crate::sharing::ShareId, + pagination: &Pagination, + ) -> Result>; + + // ===== Share Notifications ===== + + /// Create a share notification + async fn create_share_notification( + &self, + notification: &crate::sharing::ShareNotification, + ) -> Result<()>; + + /// Get unread notifications for a user + async fn get_unread_notifications( + &self, + user_id: UserId, + ) -> Result>; + + /// Mark a notification as read + async fn mark_notification_read(&self, id: Uuid) -> Result<()>; + + /// Mark all notifications as read for a user + async fn mark_all_notifications_read(&self, user_id: UserId) -> Result<()>; + + // ===== File Management ===== + + /// Rename a media item (changes file_name and updates path accordingly). + /// For external storage, this actually renames the file on disk. + /// For managed storage, this only updates the metadata. + /// Returns the old path for sync log recording. + async fn rename_media(&self, id: MediaId, new_name: &str) -> Result; + + /// Move a media item to a new directory. + /// For external storage, this actually moves the file on disk. + /// For managed storage, this only updates the path in metadata. + /// Returns the old path for sync log recording. + async fn move_media( + &self, + id: MediaId, + new_directory: &std::path::Path, + ) -> Result; + + /// Batch move multiple media items to a new directory. + async fn batch_move_media( + &self, + ids: &[MediaId], + new_directory: &std::path::Path, + ) -> Result> { + let mut results = Vec::new(); + for id in ids { + let old_path = self.move_media(*id, new_directory).await?; + results.push((*id, old_path)); } - - /// Check if a user has at least read access to a media item - async fn has_media_read_access( - &self, - user_id: crate::users::UserId, - media_id: crate::model::MediaId, - ) -> Result { - match self.check_library_access(user_id, media_id).await { - Ok(perm) => Ok(perm.can_read()), - Err(_) => Ok(false), - } - } - - /// Check if a user has write access to a media item - async fn has_media_write_access( - &self, - user_id: crate::users::UserId, - media_id: crate::model::MediaId, - ) -> Result { - match self.check_library_access(user_id, media_id).await { - Ok(perm) => Ok(perm.can_write()), - Err(_) => Ok(false), - } - } - - // ===== Ratings ===== - async fn rate_media( - &self, - user_id: UserId, - media_id: MediaId, - stars: u8, - review: Option<&str>, - ) -> Result; - async fn get_media_ratings(&self, media_id: MediaId) -> Result>; - async fn get_user_rating(&self, user_id: UserId, media_id: MediaId) -> Result>; - async fn delete_rating(&self, id: Uuid) -> Result<()>; - - // ===== Comments ===== - async fn add_comment( - &self, - user_id: UserId, - media_id: MediaId, - text: &str, - parent_id: Option, - ) -> Result; - async fn get_media_comments(&self, media_id: MediaId) -> Result>; - async fn delete_comment(&self, id: Uuid) -> Result<()>; - - // ===== Favorites ===== - async fn add_favorite(&self, user_id: UserId, media_id: MediaId) -> Result<()>; - async fn remove_favorite(&self, user_id: UserId, media_id: MediaId) -> Result<()>; - async fn get_user_favorites( - &self, - user_id: UserId, - pagination: &Pagination, - ) -> Result>; - async fn is_favorite(&self, user_id: UserId, media_id: MediaId) -> Result; - - // ===== Share Links ===== - async fn create_share_link( - &self, - media_id: MediaId, - created_by: UserId, - token: &str, - password_hash: Option<&str>, - expires_at: Option>, - ) -> Result; - async fn get_share_link(&self, token: &str) -> Result; - async fn increment_share_views(&self, token: &str) -> Result<()>; - async fn delete_share_link(&self, id: Uuid) -> Result<()>; - - // ===== Playlists ===== - async fn create_playlist( - &self, - owner_id: UserId, - name: &str, - description: Option<&str>, - is_public: bool, - is_smart: bool, - filter_query: Option<&str>, - ) -> Result; - async fn get_playlist(&self, id: Uuid) -> Result; - async fn list_playlists(&self, owner_id: Option) -> Result>; - async fn update_playlist( - &self, - id: Uuid, - name: Option<&str>, - description: Option<&str>, - is_public: Option, - ) -> Result; - async fn delete_playlist(&self, id: Uuid) -> Result<()>; - async fn add_to_playlist( - &self, - playlist_id: Uuid, - media_id: MediaId, - position: i32, - ) -> Result<()>; - async fn remove_from_playlist(&self, playlist_id: Uuid, media_id: MediaId) -> Result<()>; - async fn get_playlist_items(&self, playlist_id: Uuid) -> Result>; - async fn reorder_playlist( - &self, - playlist_id: Uuid, - media_id: MediaId, - new_position: i32, - ) -> Result<()>; - - // ===== Analytics ===== - async fn record_usage_event(&self, event: &UsageEvent) -> Result<()>; - async fn get_usage_events( - &self, - media_id: Option, - user_id: Option, - limit: u64, - ) -> Result>; - async fn get_most_viewed(&self, limit: u64) -> Result>; - async fn get_recently_viewed(&self, user_id: UserId, limit: u64) -> Result>; - async fn update_watch_progress( - &self, - user_id: UserId, - media_id: MediaId, - progress_secs: f64, - ) -> Result<()>; - async fn get_watch_progress(&self, user_id: UserId, media_id: MediaId) -> Result>; - async fn cleanup_old_events(&self, before: DateTime) -> Result; - - // ===== Subtitles ===== - async fn add_subtitle(&self, subtitle: &Subtitle) -> Result<()>; - async fn get_media_subtitles(&self, media_id: MediaId) -> Result>; - async fn delete_subtitle(&self, id: Uuid) -> Result<()>; - async fn update_subtitle_offset(&self, id: Uuid, offset_ms: i64) -> Result<()>; - - // ===== External Metadata (Enrichment) ===== - async fn store_external_metadata(&self, meta: &ExternalMetadata) -> Result<()>; - async fn get_external_metadata(&self, media_id: MediaId) -> Result>; - async fn delete_external_metadata(&self, id: Uuid) -> Result<()>; - - // ===== Transcode Sessions ===== - async fn create_transcode_session(&self, session: &TranscodeSession) -> Result<()>; - async fn get_transcode_session(&self, id: Uuid) -> Result; - async fn list_transcode_sessions( - &self, - media_id: Option, - ) -> Result>; - async fn update_transcode_status( - &self, - id: Uuid, - status: TranscodeStatus, - progress: f32, - ) -> Result<()>; - async fn cleanup_expired_transcodes(&self, before: DateTime) -> Result; - - // ===== Session Management ===== - /// Create a new session in the database - async fn create_session(&self, session: &SessionData) -> Result<()>; - - /// Get a session by its token, returns None if not found or expired - async fn get_session(&self, session_token: &str) -> Result>; - - /// Update the last_accessed timestamp for a session - async fn touch_session(&self, session_token: &str) -> Result<()>; - - /// Delete a specific session - async fn delete_session(&self, session_token: &str) -> Result<()>; - - /// Delete all sessions for a specific user - async fn delete_user_sessions(&self, username: &str) -> Result; - - /// Delete all expired sessions (where expires_at < now) - async fn delete_expired_sessions(&self) -> Result; - - /// List all active sessions (optionally filtered by username) - async fn list_active_sessions(&self, username: Option<&str>) -> Result>; - - // Book Management Methods - - /// Upsert book metadata for a media item - async fn upsert_book_metadata(&self, metadata: &crate::model::BookMetadata) -> Result<()>; - - /// Get book metadata for a media item - async fn get_book_metadata( - &self, - media_id: MediaId, - ) -> Result>; - - /// Add an author to a book - async fn add_book_author( - &self, - media_id: MediaId, - author: &crate::model::AuthorInfo, - ) -> Result<()>; - - /// Get all authors for a book - async fn get_book_authors(&self, media_id: MediaId) -> Result>; - - /// List all distinct authors with book counts - async fn list_all_authors(&self, pagination: &Pagination) -> Result>; - - /// List all series with book counts - async fn list_series(&self) -> Result>; - - /// Get all books in a series, ordered by series_index - async fn get_series_books(&self, series_name: &str) -> Result>; - - /// Update reading progress for a user and book - async fn update_reading_progress( - &self, - user_id: uuid::Uuid, - media_id: MediaId, - current_page: i32, - ) -> Result<()>; - - /// Get reading progress for a user and book - async fn get_reading_progress( - &self, - user_id: uuid::Uuid, - media_id: MediaId, - ) -> Result>; - - /// Get reading list for a user filtered by status - async fn get_reading_list( - &self, - user_id: uuid::Uuid, - status: Option, - ) -> Result>; - - /// Search books with book-specific criteria - async fn search_books( - &self, - isbn: Option<&str>, - author: Option<&str>, - series: Option<&str>, - publisher: Option<&str>, - language: Option<&str>, - pagination: &Pagination, - ) -> Result>; - - // ===== Managed Storage ===== - - /// Insert a media item that uses managed storage - async fn insert_managed_media(&self, item: &MediaItem) -> Result<()>; - - /// Get or create a managed blob record (for deduplication tracking) - async fn get_or_create_blob( - &self, - hash: &ContentHash, - size: u64, - mime_type: &str, - ) -> Result; - - /// Get a managed blob by its content hash - async fn get_blob(&self, hash: &ContentHash) -> Result>; - - /// Increment the reference count for a blob - async fn increment_blob_ref(&self, hash: &ContentHash) -> Result<()>; - - /// Decrement the reference count for a blob. Returns true if blob should be deleted. - async fn decrement_blob_ref(&self, hash: &ContentHash) -> Result; - - /// Update the last_verified timestamp for a blob - async fn update_blob_verified(&self, hash: &ContentHash) -> Result<()>; - - /// List orphaned blobs (reference_count = 0) - async fn list_orphaned_blobs(&self) -> Result>; - - /// Delete a blob record - async fn delete_blob(&self, hash: &ContentHash) -> Result<()>; - - /// Get managed storage statistics - async fn managed_storage_stats(&self) -> Result; - - // ===== Sync Devices ===== - - /// Register a new sync device - async fn register_device( - &self, - device: &crate::sync::SyncDevice, - token_hash: &str, - ) -> Result; - - /// Get a sync device by ID - async fn get_device(&self, id: crate::sync::DeviceId) -> Result; - - /// Get a sync device by its token hash - async fn get_device_by_token( - &self, - token_hash: &str, - ) -> Result>; - - /// List all devices for a user - async fn list_user_devices(&self, user_id: UserId) -> Result>; - - /// Update a sync device - async fn update_device(&self, device: &crate::sync::SyncDevice) -> Result<()>; - - /// Delete a sync device - async fn delete_device(&self, id: crate::sync::DeviceId) -> Result<()>; - - /// Update the last_seen_at timestamp for a device - async fn touch_device(&self, id: crate::sync::DeviceId) -> Result<()>; - - // ===== Sync Log ===== - - /// Record a change in the sync log - async fn record_sync_change(&self, change: &crate::sync::SyncLogEntry) -> Result<()>; - - /// Get changes since a cursor position - async fn get_changes_since( - &self, - cursor: i64, - limit: u64, - ) -> Result>; - - /// Get the current sync cursor (highest sequence number) - async fn get_current_sync_cursor(&self) -> Result; - - /// Clean up old sync log entries - async fn cleanup_old_sync_log(&self, before: DateTime) -> Result; - - // ===== Device Sync State ===== - - /// Get sync state for a device and path - async fn get_device_sync_state( - &self, - device_id: crate::sync::DeviceId, - path: &str, - ) -> Result>; - - /// Insert or update device sync state - async fn upsert_device_sync_state(&self, state: &crate::sync::DeviceSyncState) -> Result<()>; - - /// List all pending sync items for a device - async fn list_pending_sync( - &self, - device_id: crate::sync::DeviceId, - ) -> Result>; - - // ===== Upload Sessions (Chunked Uploads) ===== - - /// Create a new upload session - async fn create_upload_session(&self, session: &crate::sync::UploadSession) -> Result<()>; - - /// Get an upload session by ID - async fn get_upload_session(&self, id: Uuid) -> Result; - - /// Update an upload session - async fn update_upload_session(&self, session: &crate::sync::UploadSession) -> Result<()>; - - /// Record a received chunk - async fn record_chunk(&self, upload_id: Uuid, chunk: &crate::sync::ChunkInfo) -> Result<()>; - - /// Get all chunks for an upload - async fn get_upload_chunks(&self, upload_id: Uuid) -> Result>; - - /// Clean up expired upload sessions - async fn cleanup_expired_uploads(&self) -> Result; - - // ===== Sync Conflicts ===== - - /// Record a sync conflict - async fn record_conflict(&self, conflict: &crate::sync::SyncConflict) -> Result<()>; - - /// Get unresolved conflicts for a device - async fn get_unresolved_conflicts( - &self, - device_id: crate::sync::DeviceId, - ) -> Result>; - - /// Resolve a conflict - async fn resolve_conflict( - &self, - id: Uuid, - resolution: crate::config::ConflictResolution, - ) -> Result<()>; - - // ===== Enhanced Sharing ===== - - /// Create a new share - async fn create_share(&self, share: &crate::sharing::Share) -> Result; - - /// Get a share by ID - async fn get_share(&self, id: crate::sharing::ShareId) -> Result; - - /// Get a share by its public token - async fn get_share_by_token(&self, token: &str) -> Result; - - /// List shares created by a user - async fn list_shares_by_owner( - &self, - owner_id: UserId, - pagination: &Pagination, - ) -> Result>; - - /// List shares received by a user - async fn list_shares_for_user( - &self, - user_id: UserId, - pagination: &Pagination, - ) -> Result>; - - /// List all shares for a specific target - async fn list_shares_for_target( - &self, - target: &crate::sharing::ShareTarget, - ) -> Result>; - - /// Update a share - async fn update_share(&self, share: &crate::sharing::Share) -> Result; - - /// Delete a share - async fn delete_share(&self, id: crate::sharing::ShareId) -> Result<()>; - - /// Record that a share was accessed - async fn record_share_access(&self, id: crate::sharing::ShareId) -> Result<()>; - - /// Check share access for a user and target - async fn check_share_access( - &self, - user_id: Option, - target: &crate::sharing::ShareTarget, - ) -> Result>; - - /// Get effective permissions for a media item (considering inheritance) - async fn get_effective_share_permissions( - &self, - user_id: Option, - media_id: MediaId, - ) -> Result>; - - /// Batch delete shares - async fn batch_delete_shares(&self, ids: &[crate::sharing::ShareId]) -> Result; - - /// Clean up expired shares - async fn cleanup_expired_shares(&self) -> Result; - - // ===== Share Activity ===== - - /// Record share activity - async fn record_share_activity(&self, activity: &crate::sharing::ShareActivity) -> Result<()>; - - /// Get activity for a share - async fn get_share_activity( - &self, - share_id: crate::sharing::ShareId, - pagination: &Pagination, - ) -> Result>; - - // ===== Share Notifications ===== - - /// Create a share notification - async fn create_share_notification( - &self, - notification: &crate::sharing::ShareNotification, - ) -> Result<()>; - - /// Get unread notifications for a user - async fn get_unread_notifications( - &self, - user_id: UserId, - ) -> Result>; - - /// Mark a notification as read - async fn mark_notification_read(&self, id: Uuid) -> Result<()>; - - /// Mark all notifications as read for a user - async fn mark_all_notifications_read(&self, user_id: UserId) -> Result<()>; - - // ===== File Management ===== - - /// Rename a media item (changes file_name and updates path accordingly). - /// For external storage, this actually renames the file on disk. - /// For managed storage, this only updates the metadata. - /// Returns the old path for sync log recording. - async fn rename_media(&self, id: MediaId, new_name: &str) -> Result; - - /// Move a media item to a new directory. - /// For external storage, this actually moves the file on disk. - /// For managed storage, this only updates the path in metadata. - /// Returns the old path for sync log recording. - async fn move_media(&self, id: MediaId, new_directory: &std::path::Path) -> Result; - - /// Batch move multiple media items to a new directory. - async fn batch_move_media( - &self, - ids: &[MediaId], - new_directory: &std::path::Path, - ) -> Result> { - let mut results = Vec::new(); - for id in ids { - let old_path = self.move_media(*id, new_directory).await?; - results.push((*id, old_path)); - } - Ok(results) - } - - // ===== Trash / Soft Delete ===== - - /// Soft delete a media item (set deleted_at timestamp). - async fn soft_delete_media(&self, id: MediaId) -> Result<()>; - - /// Restore a soft-deleted media item. - async fn restore_media(&self, id: MediaId) -> Result<()>; - - /// List all soft-deleted media items. - async fn list_trash(&self, pagination: &Pagination) -> Result>; - - /// Permanently delete all items in trash. - async fn empty_trash(&self) -> Result; - - /// Permanently delete items in trash older than the specified date. - async fn purge_old_trash(&self, before: DateTime) -> Result; - - /// Count items in trash. - async fn count_trash(&self) -> Result; - - // ===== Markdown Links (Obsidian-style) ===== - - /// Save extracted markdown links for a media item. - /// This replaces any existing links for the source media. - async fn save_markdown_links( - &self, - media_id: MediaId, - links: &[crate::model::MarkdownLink], - ) -> Result<()>; - - /// Get outgoing links from a media item. - async fn get_outgoing_links( - &self, - media_id: MediaId, - ) -> Result>; - - /// Get backlinks (incoming links) to a media item. - async fn get_backlinks(&self, media_id: MediaId) -> Result>; - - /// Clear all links for a media item. - async fn clear_links_for_media(&self, media_id: MediaId) -> Result<()>; - - /// Get graph data for visualization. - /// - /// If `center_id` is provided, returns nodes within `depth` hops of that node. - /// If `center_id` is None, returns the entire graph (limited by internal max). - async fn get_graph_data( - &self, - center_id: Option, - depth: u32, - ) -> Result; - - /// Resolve unresolved links by matching target_path against media item paths. - /// Returns the number of links that were resolved. - async fn resolve_links(&self) -> Result; - - /// Update the links_extracted_at timestamp for a media item. - async fn mark_links_extracted(&self, media_id: MediaId) -> Result<()>; - - /// Get count of unresolved links (links where target_media_id is NULL). - async fn count_unresolved_links(&self) -> Result; + Ok(results) + } + + // ===== Trash / Soft Delete ===== + + /// Soft delete a media item (set deleted_at timestamp). + async fn soft_delete_media(&self, id: MediaId) -> Result<()>; + + /// Restore a soft-deleted media item. + async fn restore_media(&self, id: MediaId) -> Result<()>; + + /// List all soft-deleted media items. + async fn list_trash(&self, pagination: &Pagination) + -> Result>; + + /// Permanently delete all items in trash. + async fn empty_trash(&self) -> Result; + + /// Permanently delete items in trash older than the specified date. + async fn purge_old_trash(&self, before: DateTime) -> Result; + + /// Count items in trash. + async fn count_trash(&self) -> Result; + + // ===== Markdown Links (Obsidian-style) ===== + + /// Save extracted markdown links for a media item. + /// This replaces any existing links for the source media. + async fn save_markdown_links( + &self, + media_id: MediaId, + links: &[crate::model::MarkdownLink], + ) -> Result<()>; + + /// Get outgoing links from a media item. + async fn get_outgoing_links( + &self, + media_id: MediaId, + ) -> Result>; + + /// Get backlinks (incoming links) to a media item. + async fn get_backlinks( + &self, + media_id: MediaId, + ) -> Result>; + + /// Clear all links for a media item. + async fn clear_links_for_media(&self, media_id: MediaId) -> Result<()>; + + /// Get graph data for visualization. + /// + /// If `center_id` is provided, returns nodes within `depth` hops of that + /// node. If `center_id` is None, returns the entire graph (limited by + /// internal max). + async fn get_graph_data( + &self, + center_id: Option, + depth: u32, + ) -> Result; + + /// Resolve unresolved links by matching target_path against media item paths. + /// Returns the number of links that were resolved. + async fn resolve_links(&self) -> Result; + + /// Update the links_extracted_at timestamp for a media item. + async fn mark_links_extracted(&self, media_id: MediaId) -> Result<()>; + + /// Get count of unresolved links (links where target_media_id is NULL). + async fn count_unresolved_links(&self) -> Result; } /// Comprehensive library statistics. #[derive(Debug, Clone, Default)] pub struct LibraryStatistics { - pub total_media: u64, - pub total_size_bytes: u64, - pub avg_file_size_bytes: u64, - pub media_by_type: Vec<(String, u64)>, - pub storage_by_type: Vec<(String, u64)>, - pub newest_item: Option, - pub oldest_item: Option, - pub top_tags: Vec<(String, u64)>, - pub top_collections: Vec<(String, u64)>, - pub total_tags: u64, - pub total_collections: u64, - pub total_duplicates: u64, + pub total_media: u64, + pub total_size_bytes: u64, + pub avg_file_size_bytes: u64, + pub media_by_type: Vec<(String, u64)>, + pub storage_by_type: Vec<(String, u64)>, + pub newest_item: Option, + pub oldest_item: Option, + pub top_tags: Vec<(String, u64)>, + pub top_collections: Vec<(String, u64)>, + pub total_tags: u64, + pub total_collections: u64, + pub total_duplicates: u64, } pub type DynStorageBackend = Arc; diff --git a/crates/pinakes-core/src/storage/postgres.rs b/crates/pinakes-core/src/storage/postgres.rs index 3ee49ab..c4b697e 100644 --- a/crates/pinakes-core/src/storage/postgres.rs +++ b/crates/pinakes-core/src/storage/postgres.rs @@ -1,803 +1,865 @@ -use std::collections::HashMap; -use std::path::PathBuf; +use std::{collections::HashMap, 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 tokio_postgres::{NoTls, Row, types::ToSql}; 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; +use crate::{ + config::PostgresConfig, + error::{PinakesError, Result}, + media_type::MediaType, + model::*, + search::*, + storage::StorageBackend, +}; pub struct PostgresBackend { - pool: Pool, + 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()); + 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(); + 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); - } + // 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); - } + // 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 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}")) - })?; + 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}")) - })?; + // 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." - ); + 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}")) - })?; + 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}")) - })?; + // Verify connectivity + let _ = pool.get().await.map_err(|e| { + PinakesError::Database(format!("failed to connect to postgres: {e}")) + })?; - Ok(Self { pool }) - } + 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()) + 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}"))) + 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}")) + // 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}"))) + 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()) + 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}"))) + 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()) + 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}"))) + 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, - } + 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); + 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"), + 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"), + // 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"), + // 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"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), - // Trash support - deleted_at: row.try_get("deleted_at").ok().flatten(), + // 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(), - }) + // 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"), - }) + 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)?; + 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"), - }) + 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"); + 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"), - }) + 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. +/// 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 +/// - `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>, + 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 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, - )?; + let fragment = build_search_inner( + query, + param_offset, + params, + &mut type_filters, + &mut tag_filters, + )?; - Ok((fragment, type_filters, 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, + 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; + 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) - }; + // 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())); + 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)) - } - } + 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 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", - } + 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(), - } + 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}")))?; + 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 - } + // 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 ---- + // ---- 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}")))?; + 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?; + client + .execute( + "INSERT INTO root_dirs (path) VALUES ($1) ON CONFLICT (path) DO \ + NOTHING", + &[&path.to_string_lossy().as_ref()], + ) + .await?; - Ok(()) - } + Ok(()) + } - async fn list_root_dirs(&self) -> Result> { - let client = self - .pool - .get() - .await - .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + 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?; + 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()) - } + 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}")))?; + 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?; + client + .execute("DELETE FROM root_dirs WHERE path = $1", &[&path + .to_string_lossy() + .as_ref()]) + .await?; - Ok(()) - } + Ok(()) + } - // ---- Media CRUD ---- + // ---- 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}")))?; + 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; + 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 ( + 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 + 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 + $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?; + &[ + &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) + // 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(()) + SET field_type = EXCLUDED.field_type, field_value = \ + EXCLUDED.field_value", + &[&item.id.0, &name, &ft, &field.value], + ) + .await?; } - 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) - } + Ok(()) + } - async fn get_media(&self, id: MediaId) -> Result { - let client = self - .pool - .get() - .await - .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + 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) + } - 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, + 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, - storage_mode, original_filename, uploaded_at, storage_key, + storage_mode, original_filename, uploaded_at, \ + storage_key, created_at, updated_at, deleted_at, links_extracted_at FROM media_items WHERE id = $1", - &[&id.0], - ) - .await? - .ok_or_else(|| PinakesError::NotFound(format!("media item {id}")))?; + &[&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) - } + 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}")))?; + 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, + 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, - storage_mode, original_filename, uploaded_at, storage_key, + storage_mode, original_filename, uploaded_at, \ + storage_key, created_at, updated_at, deleted_at, links_extracted_at FROM media_items WHERE content_hash = $1", - &[&hash.0], - ) - .await?; + &[&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), - } + 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}")))?; + 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, + 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, - storage_mode, original_filename, uploaded_at, storage_key, + storage_mode, original_filename, uploaded_at, \ + storage_key, created_at, updated_at, deleted_at, links_extracted_at FROM media_items WHERE path = $1", - &[&path_str], - ) - .await?; + &[&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), - } + 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}")))?; + 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, + 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, links_extracted_at @@ -805,365 +867,379 @@ impl StorageBackend for PostgresBackend { 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 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); - } + 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 + // 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?; + &[&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 }); - } + 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; - } - } + 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}")))?; + Ok(items) + } - 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; + async fn update_media(&self, item: &MediaItem) -> 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 - path = $2, file_name = $3, media_type = $4, content_hash = $5, - file_size = $6, title = $7, artist = $8, album = $9, genre = $10, + 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 + 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?; + &[ + &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))); - } + 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?; + // 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) + 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(()) + &[&item.id.0, &name, &ft, &field.value], + ) + .await?; } - async fn delete_media(&self, id: MediaId) -> Result<()> { - let client = self - .pool - .get() - .await - .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + Ok(()) + } - let rows_affected = client - .execute("DELETE FROM media_items WHERE id = $1", &[&id.0]) - .await?; + async fn delete_media(&self, id: MediaId) -> Result<()> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; - if rows_affected == 0 { - return Err(PinakesError::NotFound(format!("media item {id}"))); - } + let rows_affected = client + .execute("DELETE FROM media_items WHERE id = $1", &[&id.0]) + .await?; - Ok(()) + if rows_affected == 0 { + return Err(PinakesError::NotFound(format!("media item {id}"))); } - async fn delete_all_media(&self) -> Result { - let client = self - .pool - .get() - .await - .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + Ok(()) + } - let count: i64 = client - .query_one("SELECT COUNT(*) FROM media_items", &[]) - .await? - .get(0); + async fn delete_all_media(&self) -> Result { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; - client.execute("DELETE FROM media_items", &[]).await?; + let count: i64 = client + .query_one("SELECT COUNT(*) FROM media_items", &[]) + .await? + .get(0); - Ok(count as u64) + 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); + } } - // ---- 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) + 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?; + &[&media_uuids, &tag_uuids], + ) + .await?; - Ok(rows) + 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())); } - // ---- Tags ---- + Ok(()) + } - 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}")))?; + 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}")))?; - 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) + 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?; + &[&media_id.0, &tag_id], + ) + .await?; - Ok(()) - } + 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}")))?; + 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?; + client + .execute( + "DELETE FROM media_tags WHERE media_id = $1 AND tag_id = $2", + &[&media_id.0, &tag_id], + ) + .await?; - Ok(()) - } + 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}")))?; + 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 + 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?; + &[&media_id.0], + ) + .await?; - rows.iter().map(row_to_tag).collect() - } + 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}")))?; + 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 ( + let rows = client + .query( + "WITH RECURSIVE descendants AS ( SELECT id, name, parent_id, created_at FROM tags WHERE parent_id = $1 @@ -1172,609 +1248,663 @@ impl StorageBackend for PostgresBackend { 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?; + SELECT id, name, parent_id, created_at FROM descendants ORDER \ + BY name", + &[&tag_id], + ) + .await?; - rows.iter().map(row_to_tag).collect() - } + rows.iter().map(row_to_tag).collect() + } - // ---- Collections ---- + // ---- 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}")))?; + 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); + 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) + 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?; + &[ + &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, - }) - } + 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}")))?; + 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 + 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()))?; + &[&id], + ) + .await? + .ok_or_else(|| PinakesError::CollectionNotFound(id.to_string()))?; - row_to_collection(&row) - } + 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}")))?; + 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 + let rows = client + .query( + "SELECT id, name, description, kind, filter_query, created_at, \ + updated_at FROM collections ORDER BY name", - &[], - ) - .await?; + &[], + ) + .await?; - rows.iter().map(row_to_collection).collect() + 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())); } - async fn delete_collection(&self, id: Uuid) -> Result<()> { - let client = self - .pool - .get() - .await - .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + Ok(()) + } - let rows_affected = client - .execute("DELETE FROM collections WHERE id = $1", &[&id]) - .await?; + 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}")))?; - if rows_affected == 0 { - return Err(PinakesError::CollectionNotFound(id.to_string())); - } + let now = Utc::now(); - 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) + 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?; + 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?; + // Update the collection's updated_at timestamp + client + .execute("UPDATE collections SET updated_at = $2 WHERE id = $1", &[ + &collection_id, + &now, + ]) + .await?; - Ok(()) - } + 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}")))?; + 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?; + 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?; + let now = Utc::now(); + client + .execute("UPDATE collections SET updated_at = $2 WHERE id = $1", &[ + &collection_id, + &now, + ]) + .await?; - Ok(()) - } + 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}")))?; + 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.file_mtime, m.date_taken, m.latitude, - m.longitude, m.camera_make, m.camera_model, m.rating, m.perceptual_hash, - m.storage_mode, m.original_filename, m.uploaded_at, m.storage_key, - m.created_at, m.updated_at, m.deleted_at, m.links_extracted_at + 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.storage_mode, m.original_filename, m.uploaded_at, \ + m.storage_key, + m.created_at, m.updated_at, m.deleted_at, \ + m.links_extracted_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?; + &[&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) + let mut items = Vec::with_capacity(rows.len()); + for row in &rows { + items.push(row_to_media_item(row)?); } - // ---- Search ---- + // 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?; - async fn search(&self, request: &SearchRequest) -> Result { - let client = self - .pool - .get() - .await - .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + 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 }); + } - 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 item in &mut items { + if let Some(fields) = cf_map.remove(&item.id.0) { + item.custom_fields = fields; } + } + } - 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})" - )); - } + Ok(items) + } - let full_where = if extra_where.is_empty() { - where_clause.clone() - } else { - format!("{where_clause} AND {}", extra_where.join(" AND ")) - }; + // ---- Search ---- - let order_by = sort_order_clause_with_rank(&request.sort, has_fts); + async fn search(&self, request: &SearchRequest) -> Result { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; - // 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.storage_mode, m.original_filename, m.uploaded_at, m.storage_key, - m.created_at, m.updated_at, m.deleted_at, m.links_extracted_at, - ts_rank(m.search_vector, plainto_tsquery('english', ${fts_param_idx})) AS rank + 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.storage_mode, m.original_filename, m.uploaded_at, \ + m.storage_key, + m.created_at, m.updated_at, m.deleted_at, \ + m.links_extracted_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.storage_mode, m.original_filename, m.uploaded_at, m.storage_key, - m.created_at, m.updated_at, m.deleted_at, m.links_extracted_at + 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.storage_mode, m.original_filename, m.uploaded_at, \ + m.storage_key, + m.created_at, m.updated_at, m.deleted_at, \ + m.links_extracted_at FROM media_items m WHERE {full_where} ORDER BY {order_by} LIMIT ${} OFFSET ${}", - param_offset, - param_offset + 1 - ); - (count, select) - }; + 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(); + // 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); + 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)); + // 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 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 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)?); - } + 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 + // 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?; + &[&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 }); - } + 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; - } - } + 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 ---- + Ok(SearchResults { + items, + total_count: total_count as u64, + }) + } - async fn record_audit(&self, entry: &AuditEntry) -> Result<()> { - let client = self - .pool - .get() - .await - .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + // ---- Audit ---- - let action_str = audit_action_to_string(&entry.action); - let media_id = entry.media_id.map(|m| m.0); + async fn record_audit(&self, entry: &AuditEntry) -> Result<()> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; - client - .execute( - "INSERT INTO audit_log (id, media_id, action, details, timestamp) + 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?; + &[ + &entry.id, + &media_id, + &action_str, + &entry.details, + &entry.timestamp, + ], + ) + .await?; - Ok(()) - } + 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}")))?; + 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 + 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 + &[ + &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? - } - }; + &[&(pagination.limit as i64), &(pagination.offset as i64)], + ) + .await? + }, + }; - rows.iter().map(row_to_audit_entry).collect() - } + rows.iter().map(row_to_audit_entry).collect() + } - // ---- Custom fields ---- + // ---- 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}")))?; + 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); + let ft = custom_field_type_to_string(&field.field_type); - client - .execute( - "INSERT INTO custom_fields (media_id, field_name, field_type, field_value) + 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?; + SET field_type = EXCLUDED.field_type, field_value = \ + EXCLUDED.field_value", + &[&media_id.0, &name, &ft, &field.value], + ) + .await?; - Ok(()) - } + 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}")))?; + 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 + let rows = client + .query( + "SELECT field_name, field_type, field_value FROM custom_fields WHERE media_id = $1", - &[&media_id.0], - ) - .await?; + &[&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) + 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 }); } - 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}")))?; + Ok(map) + } - client - .execute( - "DELETE FROM custom_fields WHERE media_id = $1 AND field_name = $2", - &[&media_id.0, &name], - ) - .await?; + 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}")))?; - Ok(()) - } + client + .execute( + "DELETE FROM custom_fields WHERE media_id = $1 AND field_name = $2", + &[&media_id.0, &name], + ) + .await?; - // ---- Duplicates ---- + Ok(()) + } - async fn find_duplicates(&self) -> Result>> { - let client = self - .pool - .get() - .await - .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + // ---- Duplicates ---- - 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 + 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?; + &[], + ) + .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) + let mut items = Vec::with_capacity(rows.len()); + for row in &rows { + items.push(row_to_media_item(row)?); } - async fn find_perceptual_duplicates(&self, threshold: u32) -> Result>> { - let client = self - .pool - .get() - .await - .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + // 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?; - // Get all images with perceptual hashes - let rows = client + 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, @@ -1785,1859 +1915,2211 @@ impl StorageBackend for PostgresBackend { ) .await?; - let mut items = Vec::with_capacity(rows.len()); - for row in &rows { - items.push(row_to_media_item(row)?); - } + 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 + // 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(), + &[&ids], ) - .await - .map_err(|_| PinakesError::Database("library_statistics query timed out".to_string()))? - } + .await?; - 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), - }); + 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(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), - }) - } + // 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(); - 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), - }) - } + for i in 0..items.len() { + if grouped_indices.contains(&i) { + continue; + } - 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)?; + let hash_a = match &items[i].perceptual_hash { + Some(h) => { + match ImageHash::>::from_base64(h) { + Ok(hash) => hash, + Err(_) => continue, + } + }, + None => continue, + }; - 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 mut group = vec![items[i].clone()]; + grouped_indices.insert(i); - 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(), + 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, }; - 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, - }) + 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); + } } - 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(); + Ok(groups) + } - // 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; + // ---- Database management ---- - 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()); - } + async fn database_stats(&self) -> Result { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; - 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 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); - let sql = format!( - "UPDATE users SET {} WHERE id = ${}", - updates.join(", "), - param_idx - ); + 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(), + }) + } - 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); + async fn vacuum(&self) -> Result<()> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; - client.execute(&sql, ¶ms).await?; - } + client.execute("VACUUM ANALYZE", &[]).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) + 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 + 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?; } - 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}")))?; + // Fetch updated user + self.get_user(id).await + } - // 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 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 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 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(()) - } + 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, - }) - } + // ===== 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, + 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"), - })) - } - - 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, + created_at: row.get("created_at"), + } }) - } + .collect(), + ) + } - 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 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_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(()) - } + 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(()) + } - // ===== 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(()) - } + // ===== 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 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_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 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::>>()?; + 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(()) + } - // 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 + // ===== 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; - } - } + &[&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) - } + Ok(items) + } - // ===== 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 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) + } - 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"), - }) - } + // ===== 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 increment_share_views(&self, token: &str) -> Result<()> { - let client = self - .pool - .get() - .await - .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + 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 - .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}")))?; + .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 - .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"), + .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"), + 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; + } + } } - 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()) + 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)); } - 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; + // 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; } - if let Some(d) = description { - set_parts.push(format!("description = ${idx}")); - params.push(Box::new(d.to_string())); - idx += 1; + } + } + + 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; } - 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(", ") + } + } + + 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 ); - 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 - } + 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 delete_playlist(&self, id: Uuid) -> Result<()> { - let client = self - .pool - .get() - .await - .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + 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 - .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}")))?; + .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 - .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"), + .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, + child_cancel: None, + } }) - } + .collect(), + ) + } - 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 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) + } - 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 ===== - // ===== 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}")))?; - 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) + 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(()) - } + &[ + &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}")))?; + 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 + 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?; + &[&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), - })) - } + 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}")))?; + 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(()) - } + 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}")))?; + 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(()) - } + 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}")))?; + 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) - } + 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}")))?; + 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) - } + 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}")))?; + 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 + 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 + &[&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? - }; + &[&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()) - } + 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 + // 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}")))?; + 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?; + let tx = client.transaction().await?; - // Upsert book_metadata - tx.execute( - "INSERT INTO book_metadata ( + // 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) @@ -3645,1192 +4127,1245 @@ impl StorageBackend for PostgresBackend { 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?; + &[ + &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?; + // 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) + // 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(()) + &[ + &metadata.media_id.0, + &author.name, + &author.file_as, + &author.role, + &author.position, + ], + ) + .await?; } - 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}")))?; + // 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?; + } + } - // Get base book metadata - let row = client - .query_opt( - "SELECT isbn, isbn13, publisher, language, page_count, + 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?; + &[&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), - })) + if row.is_none() { + return Ok(None); } - 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}")))?; + let row = row.unwrap(); - client - .execute( - "INSERT INTO book_authors (media_id, author_name, author_sort, role, position) + // 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(()) - } + &[ + &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}")))?; + 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 + 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?; + &[&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()) - } + 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}")))?; + 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 + 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?; + &[&(pagination.limit as i64), &(pagination.offset as i64)], + ) + .await?; - Ok(rows - .iter() - .map(|r| (r.get(0), r.get::<_, i64>(1) as u64)) - .collect()) - } + 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}")))?; + 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 + 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?; + &[], + ) + .await?; - Ok(rows - .iter() - .map(|r| (r.get(0), r.get::<_, i64>(1) as u64)) - .collect()) - } + 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}")))?; + 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, + 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?; + &[&series_name], + ) + .await?; - rows.iter().map(row_to_media_item).collect() - } + 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}")))?; + 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) + 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(()) - } + &[&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}")))?; + 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 + 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?; + &[&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 - }; + 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), - } - })) - } + 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}")))?; + 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 + // 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()))?; + &[&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)?; + 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"); + // 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), - _ => {} - } + // 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 + }; - Ok(results) + // Filter by status if specified + match status { + None => results.push(item), + Some(s) if s == calculated_status => results.push(item), + _ => {}, + } } - #[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}")))?; + Ok(results) + } - // 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, + #[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 + 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, + &[ + &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, + &[&(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.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(); + .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}")))?; + 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 - .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()))?; + .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? + } + }; - Ok(()) - } + let items: Result> = rows.iter().map(row_to_media_item).collect(); + items + } - 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}")))?; + // ========================================================================= + // Managed Storage + // ========================================================================= - // Try to get existing blob - let existing = client - .query_opt( - "SELECT content_hash, file_size, mime_type, reference_count, stored_at, last_verified + 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()))?; + &[&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), - }); - } + 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) + // 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()))?; + &[&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, - }) - } + 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}")))?; + 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 + 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()))?; + &[&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), - })) - } + 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}")))?; + 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()))?; + 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(()) - } + 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}")))?; + 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()))?; + 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()))?; + // 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) - } + 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}")))?; + 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()))?; + 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(()) - } + 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}")))?; + 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 + 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()))?; + &[], + ) + .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, + 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(), + ) + } - // ========================================================================= - // Sync Devices - // ========================================================================= + 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}")) + })?; - 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("DELETE FROM managed_blobs WHERE content_hash = $1", &[ + &hash.0, + ]) + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; - 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) + 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()))?; + &[ + &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()) - } + 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}")))?; + 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 + 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()))?; + &[&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), - }) - } + 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}")))?; + 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 + 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()))?; + &[&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(), + 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), - })) - } + 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 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}")))?; + 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}")) + })?; - 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, + 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()))?; + &[ + &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(()) - } + 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}")))?; + 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()))?; + client + .execute("DELETE FROM sync_devices WHERE id = $1", &[&id.0]) + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; - Ok(()) - } + 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}")))?; + 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()))?; + 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(()) - } + Ok(()) + } - // ========================================================================= - // Sync Log - // ========================================================================= + // ========================================================================= + // 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}")))?; + 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); + // 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, + 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()))?; + &[ + &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(()) - } + 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}")))?; + 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, + 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()))?; + &[&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()) - } + 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}")))?; + 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()))?; + 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)) - } + 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}")))?; + 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()))?; + let result = client + .execute("DELETE FROM sync_log WHERE timestamp < $1", &[&before]) + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; - Ok(result) - } + Ok(result) + } - // ========================================================================= - // Device Sync State - // ========================================================================= + // ========================================================================= + // 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}")))?; + 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, + 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()))?; + &[&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), - })) - } + 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}")))?; + 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) + 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, @@ -4840,1523 +5375,1611 @@ impl StorageBackend for PostgresBackend { 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()))?; + &[ + &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(()) - } + 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}")))?; + 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, + 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()))?; + 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), + 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(), + ) + } - 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}")))?; + // ========================================================================= + // Upload Sessions + // ========================================================================= - 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()))?; + 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}")) + })?; - Ok(()) - } + 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()))?; - 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}")))?; + Ok(()) + } - client - .execute( - "INSERT INTO upload_chunks (upload_id, chunk_index, offset, size, hash, received_at) + 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()))?; + &[ + &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(()) - } + 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}")))?; + 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 + 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()))?; + &[&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()) - } + 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}")))?; + 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()))?; + 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) - } + Ok(result) + } - // ========================================================================= - // Sync Conflicts - // ========================================================================= + // ========================================================================= + // 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}")))?; + 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, + 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()))?; + &[ + &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(()) - } + 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}")))?; + 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, + 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()))?; + 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()) - } + 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}")))?; + 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", - }; + 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()))?; + 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(()) - } + Ok(()) + } - // ========================================================================= - // Shares - // ========================================================================= + // ========================================================================= + // 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}")))?; + 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), - }; + 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, + 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, + 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()))?; + 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()) - } + 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}")))?; + 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 + 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()))?; + &[&id.0], + ) + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; - pg_row_to_share(&row) - } + 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}")))?; + 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 + 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()))?; + &[&token], + ) + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; - pg_row_to_share(&row) - } + 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}")))?; + 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()))?; + 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() - } + 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}")))?; + 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()))?; + 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() - } + 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}")))?; + 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 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 + 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()))?; + &[&target_type, &target_id], + ) + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; - rows.iter().map(pg_row_to_share).collect() - } + 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}")))?; + 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, + 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()))?; + &[ + &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()) - } + 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}")))?; + 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()))?; + client + .execute("DELETE FROM shares WHERE id = $1", &[&id.0]) + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; - Ok(()) - } + 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}")))?; + 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()))?; + 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(()) - } + 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(); + 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, - } + for share in shares { + // Skip expired shares + if let Some(exp) = share.expires_at { + if exp < now { + continue; } + } - Ok(None) + 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, + } } - 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)); - } + Ok(None) + } - // 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 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)); } - async fn batch_delete_shares(&self, ids: &[crate::sharing::ShareId]) -> Result { - if ids.is_empty() { - return Ok(0); - } + // Check collection shares (inheritance) + let client = self.pool.get().await.map_err(|e| { + PinakesError::Database(format!("failed to get connection: {e}")) + })?; - 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()))?; - 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) + 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)); + } } - async fn cleanup_expired_shares(&self) -> Result { - let client = self - .pool - .get() - .await - .map_err(|e| PinakesError::Database(format!("failed to get connection: {e}")))?; + // 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()))?; - 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) + 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)); + } } - // ========================================================================= - // Share Activity - // ========================================================================= + Ok(None) + } - 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}")))?; + async fn batch_delete_shares( + &self, + ids: &[crate::sharing::ShareId], + ) -> Result { + if ids.is_empty() { + return Ok(0); + } - client - .execute( - "INSERT INTO share_activity (id, share_id, actor_id, actor_ip, action, details, timestamp) + 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()))?; + &[ + &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(()) - } + 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}")))?; + 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()))?; + 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()) - } + 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 - // ========================================================================= + // ========================================================================= + // 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}")))?; + 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) + 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()))?; + &[ + ¬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(()) + 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(), + )); } - 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 client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {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()))?; + // 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()))?; - 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()) + 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), + )) + })?; } - 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}")))?; + // 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()))?; - client - .execute( - "UPDATE share_notifications SET is_read = true WHERE id = $1", - &[&id], - ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + Ok(old_path) + } - Ok(()) + 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?; } - 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(()) + // 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), + )) + })?; } - // ===== File Management ===== + // 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()))?; - 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(), - )); - } + Ok(old_path) + } - let client = self - .pool - .get() - .await - .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + // ===== Trash / Soft Delete ===== - // 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()))?; + 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 old_path: String = row.get(0); - let storage_mode: String = row.get(1); + 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()))?; - 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) + if rows_affected == 0 { + return Err(PinakesError::NotFound(format!( + "Media item {} not found or already deleted", + id + ))); } - 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}")))?; + Ok(()) + } - // 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()))?; + async fn restore_media(&self, id: MediaId) -> Result<()> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; - let old_path: String = row.get(0); - let file_name: String = row.get(1); - let storage_mode: String = row.get(2); + 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()))?; - 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) + if rows_affected == 0 { + return Err(PinakesError::NotFound(format!( + "Media item {} not found in trash", + id + ))); } - // ===== Trash / Soft Delete ===== + Ok(()) + } - async fn soft_delete_media(&self, id: MediaId) -> Result<()> { - let client = self - .pool - .get() - .await - .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + 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_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, + 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, + 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()))?; + &[&(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) + let mut items = Vec::new(); + for row in rows { + items.push(row_to_media_item(&row)?); } - async fn empty_trash(&self) -> Result { - let client = self - .pool - .get() - .await - .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + Ok(items) + } - // 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()))?; + async fn empty_trash(&self) -> Result { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; - // 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(); - } + // 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 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 mut client = self - .pool - .get() - .await - .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; - - let media_id_str = media_id.0.to_string(); - - // Wrap DELETE + INSERT in transaction to ensure atomicity - let tx = client - .transaction() - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; - - // Delete existing links for this source - tx.execute( - "DELETE FROM markdown_links WHERE source_media_id = $1", - &[&media_id_str], - ) + // 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 - .map_err(|e| PinakesError::Database(e.to_string()))?; + .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(); + } - // Insert new links - for link in links { - let target_media_id = link.target_media_id.map(|id| id.0.to_string()); - tx.execute( - "INSERT INTO markdown_links ( + // 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 mut client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + let media_id_str = media_id.0.to_string(); + + // Wrap DELETE + INSERT in transaction to ensure atomicity + let tx = client + .transaction() + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; + + // Delete existing links for this source + tx.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()); + tx.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()))?; - } - - // Commit transaction - if this fails, all changes are rolled back - tx.commit() - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; - - Ok(()) + &[ + &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()))?; } - 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}")))?; + // Commit transaction - if this fails, all changes are rolled back + tx.commit() + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; - let media_id_str = media_id.0.to_string(); + Ok(()) + } - let rows = client - .query( - "SELECT id, source_media_id, target_path, target_media_id, + 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()))?; + &[&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) + let mut links = Vec::new(); + for row in rows { + links.push(row_to_markdown_link(&row)?); } - async fn get_backlinks(&self, media_id: MediaId) -> Result> { - let client = self - .pool - .get() - .await - .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + Ok(links) + } - let media_id_str = media_id.0.to_string(); + 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 rows = client - .query( - "SELECT l.id, l.source_media_id, m.title, m.path, + 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], + &[&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()))?; - 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); + for row in rows { + let id: String = row.get(0); + if !visited.contains(&id) { + visited.insert(id.clone()); + next_frontier.push(id); + } + } - 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), - }); + // 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); + } + } } - Ok(backlinks) - } + frontier = next_frontier; + } - 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 + 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()))?; + &[], + ) + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; - for row in rows { - let id: String = row.get(0); - node_ids.insert(id); - } - } + 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 + // 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()))?; + &[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); + 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 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); + // 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, - }); - } - } + 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 + // Build edges + for node_id in &node_ids { + let rows = client .query( "SELECT source_media_id, target_media_id, link_type FROM markdown_links @@ -6366,37 +6989,37 @@ impl StorageBackend for PostgresBackend { .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); + 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), - }); - } - } + 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}")))?; + Ok(crate::model::GraphData { nodes, edges }) + } - // Strategy 1: Exact path match - let result1 = client - .execute( - "UPDATE markdown_links + 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 @@ -6409,20 +7032,21 @@ impl StorageBackend for PostgresBackend { WHERE path = markdown_links.target_path AND deleted_at IS NULL )", - &[], - ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + &[], + ) + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; - // Strategy 2: Filename match - let result2 = client - .execute( - "UPDATE markdown_links + // 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) + OR REPLACE(file_name, '.md', '') = \ + markdown_links.target_path) AND deleted_at IS NULL LIMIT 1 ) @@ -6431,468 +7055,523 @@ impl StorageBackend for PostgresBackend { 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) + OR REPLACE(file_name, '.md', '') = \ + markdown_links.target_path) AND deleted_at IS NULL )", - &[], - ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + &[], + ) + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; - Ok(result1 + result2) - } + 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}")))?; + 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(); + 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()))?; + 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(()) - } + Ok(()) + } - async fn count_unresolved_links(&self) -> Result { - let client = self - .pool - .get() - .await - .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + 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 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) - } + 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, + 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 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 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, - }, - }; + 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), - }) + 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). +/// 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), - } + 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), + 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) + 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); + 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, - }) + 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::*; + use super::*; - #[test] - fn test_media_type_roundtrip() { - use crate::media_type::BuiltinMediaType; + #[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); - } + 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_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_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_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_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_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_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"); - } + #[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"); + } } diff --git a/crates/pinakes-core/src/storage/sqlite.rs b/crates/pinakes-core/src/storage/sqlite.rs index 74e28b1..09e2a61 100644 --- a/crates/pinakes-core/src/storage/sqlite.rs +++ b/crates/pinakes-core/src/storage/sqlite.rs @@ -1,22 +1,31 @@ -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, +}; use chrono::{DateTime, NaiveDateTime, Utc}; use rusqlite::{Connection, Row, params}; use uuid::Uuid; -use crate::error::{PinakesError, Result}; -use crate::media_type::MediaType; -use crate::model::*; -use crate::search::*; -use crate::storage::StorageBackend; +use crate::{ + error::{PinakesError, Result}, + media_type::MediaType, + model::*, + search::*, + storage::StorageBackend, +}; -/// Parse a UUID string from the database, returning a proper error on corruption. +/// Parse a UUID string from the database, returning a proper error on +/// corruption. fn parse_uuid(s: &str) -> rusqlite::Result { - Uuid::parse_str(s).map_err(|e| { - rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e)) - }) + Uuid::parse_str(s).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 0, + rusqlite::types::Type::Text, + Box::new(e), + ) + }) } /// SQLite storage backend using WAL mode for concurrent reads. @@ -25,28 +34,29 @@ fn parse_uuid(s: &str) -> rusqlite::Result { /// `rusqlite::Connection` is synchronous. The connection is wrapped in an /// `Arc>` so it can be shared across tasks safely. pub struct SqliteBackend { - conn: Arc>, + conn: Arc>, } impl SqliteBackend { - /// Open (or create) a database at the given file path. - pub fn new(path: &Path) -> Result { - let conn = Connection::open(path)?; - Self::configure(conn) - } + /// Open (or create) a database at the given file path. + pub fn new(path: &Path) -> Result { + let conn = Connection::open(path)?; + Self::configure(conn) + } - /// Create an in-memory database -- useful for tests. - pub fn in_memory() -> Result { - let conn = Connection::open_in_memory()?; - Self::configure(conn) - } + /// Create an in-memory database -- useful for tests. + pub fn in_memory() -> Result { + let conn = Connection::open_in_memory()?; + Self::configure(conn) + } - fn configure(conn: Connection) -> Result { - conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;")?; - Ok(Self { - conn: Arc::new(Mutex::new(conn)), - }) - } + fn configure(conn: Connection) -> Result { + conn + .execute_batch("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;")?; + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + }) + } } // --------------------------------------------------------------------------- @@ -54,311 +64,327 @@ impl SqliteBackend { // --------------------------------------------------------------------------- fn parse_datetime(s: &str) -> DateTime { - // Try RFC 3339 first (includes timezone), then fall back to a naive format. - if let Ok(dt) = DateTime::parse_from_rfc3339(s) { - return dt.with_timezone(&Utc); - } - if let Ok(naive) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f") { - return naive.and_utc(); - } - if let Ok(naive) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") { - return naive.and_utc(); - } - // Last resort -- epoch - tracing::warn!(value = %s, "failed to parse datetime, falling back to epoch"); - DateTime::default() + // Try RFC 3339 first (includes timezone), then fall back to a naive format. + if let Ok(dt) = DateTime::parse_from_rfc3339(s) { + return dt.with_timezone(&Utc); + } + if let Ok(naive) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f") { + return naive.and_utc(); + } + if let Ok(naive) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") { + return naive.and_utc(); + } + // Last resort -- epoch + tracing::warn!(value = %s, "failed to parse datetime, falling back to epoch"); + DateTime::default() } fn parse_media_type(s: &str) -> MediaType { - use crate::media_type::BuiltinMediaType; + use crate::media_type::BuiltinMediaType; - // MediaType derives Serialize/Deserialize with serde rename_all = "lowercase", so - // a JSON round-trip uses e.g. `"mp3"`. We store the bare lowercase string in the - // database, so we must wrap it in quotes for serde_json. - let quoted = format!("\"{s}\""); - serde_json::from_str("ed).unwrap_or(MediaType::Builtin(BuiltinMediaType::PlainText)) + // MediaType derives Serialize/Deserialize with serde rename_all = + // "lowercase", so a JSON round-trip uses e.g. `"mp3"`. We store the bare + // lowercase string in the database, so we must wrap it in quotes for + // serde_json. + let quoted = format!("\"{s}\""); + serde_json::from_str("ed) + .unwrap_or(MediaType::Builtin(BuiltinMediaType::PlainText)) } fn media_type_to_str(mt: &MediaType) -> String { - // Produces e.g. `"mp3"` -- strip the surrounding quotes. - let s = serde_json::to_string(mt).unwrap_or_else(|_| "\"plaintext\"".to_string()); - s.trim_matches('"').to_string() + // Produces e.g. `"mp3"` -- strip the surrounding quotes. + let s = + serde_json::to_string(mt).unwrap_or_else(|_| "\"plaintext\"".to_string()); + s.trim_matches('"').to_string() } fn row_to_media_item(row: &Row) -> rusqlite::Result { - let id_str: String = row.get("id")?; - let path_str: String = row.get("path")?; - let media_type_str: String = row.get("media_type")?; - let hash_str: String = row.get("content_hash")?; - let created_str: String = row.get("created_at")?; - let updated_str: String = row.get("updated_at")?; + let id_str: String = row.get("id")?; + let path_str: String = row.get("path")?; + let media_type_str: String = row.get("media_type")?; + let hash_str: String = row.get("content_hash")?; + let created_str: String = row.get("created_at")?; + let updated_str: String = row.get("updated_at")?; - Ok(MediaItem { - id: MediaId(parse_uuid(&id_str)?), - path: PathBuf::from(path_str), - file_name: row.get("file_name")?, - media_type: parse_media_type(&media_type_str), - content_hash: ContentHash(hash_str), - 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(), // loaded separately - // file_mtime may not be present in all queries, so handle gracefully - file_mtime: row.get::<_, Option>("file_mtime").unwrap_or(None), + Ok(MediaItem { + id: MediaId(parse_uuid(&id_str)?), + path: PathBuf::from(path_str), + file_name: row.get("file_name")?, + media_type: parse_media_type(&media_type_str), + content_hash: ContentHash(hash_str), + 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(), // loaded separately + // file_mtime may not be present in all queries, so handle gracefully + file_mtime: row.get::<_, Option>("file_mtime").unwrap_or(None), - // Photo-specific fields (may not be present in all queries) - date_taken: row - .get::<_, Option>("date_taken") - .ok() - .flatten() - .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) - .map(|dt| dt.with_timezone(&Utc)), - latitude: row.get::<_, Option>("latitude").ok().flatten(), - longitude: row.get::<_, Option>("longitude").ok().flatten(), - camera_make: row.get::<_, Option>("camera_make").ok().flatten(), - camera_model: row.get::<_, Option>("camera_model").ok().flatten(), - rating: row.get::<_, Option>("rating").ok().flatten(), - perceptual_hash: row - .get::<_, Option>("perceptual_hash") - .ok() - .flatten(), + // Photo-specific fields (may not be present in all queries) + date_taken: row + .get::<_, Option>("date_taken") + .ok() + .flatten() + .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) + .map(|dt| dt.with_timezone(&Utc)), + latitude: row.get::<_, Option>("latitude").ok().flatten(), + longitude: row.get::<_, Option>("longitude").ok().flatten(), + camera_make: row.get::<_, Option>("camera_make").ok().flatten(), + camera_model: row + .get::<_, Option>("camera_model") + .ok() + .flatten(), + rating: row.get::<_, Option>("rating").ok().flatten(), + perceptual_hash: row + .get::<_, Option>("perceptual_hash") + .ok() + .flatten(), - // Managed storage fields (may not be present in all queries) - storage_mode: row - .get::<_, Option>("storage_mode") - .ok() - .flatten() - .and_then(|s| s.parse().ok()) - .unwrap_or_default(), - original_filename: row - .get::<_, Option>("original_filename") - .ok() - .flatten(), - uploaded_at: row - .get::<_, Option>("uploaded_at") - .ok() - .flatten() - .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) - .map(|dt| dt.with_timezone(&Utc)), - storage_key: row.get::<_, Option>("storage_key").ok().flatten(), + // Managed storage fields (may not be present in all queries) + storage_mode: row + .get::<_, Option>("storage_mode") + .ok() + .flatten() + .and_then(|s| s.parse().ok()) + .unwrap_or_default(), + original_filename: row + .get::<_, Option>("original_filename") + .ok() + .flatten(), + uploaded_at: row + .get::<_, Option>("uploaded_at") + .ok() + .flatten() + .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) + .map(|dt| dt.with_timezone(&Utc)), + storage_key: row + .get::<_, Option>("storage_key") + .ok() + .flatten(), - created_at: parse_datetime(&created_str), - updated_at: parse_datetime(&updated_str), + created_at: parse_datetime(&created_str), + updated_at: parse_datetime(&updated_str), - // Trash support - deleted_at: row - .get::<_, Option>("deleted_at") - .ok() - .flatten() - .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) - .map(|dt| dt.with_timezone(&Utc)), + // Trash support + deleted_at: row + .get::<_, Option>("deleted_at") + .ok() + .flatten() + .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) + .map(|dt| dt.with_timezone(&Utc)), - // Markdown links extraction timestamp - links_extracted_at: row - .get::<_, Option>("links_extracted_at") - .ok() - .flatten() - .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) - .map(|dt| dt.with_timezone(&Utc)), - }) + // Markdown links extraction timestamp + links_extracted_at: row + .get::<_, Option>("links_extracted_at") + .ok() + .flatten() + .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) + .map(|dt| dt.with_timezone(&Utc)), + }) } fn row_to_tag(row: &Row) -> rusqlite::Result { - let id_str: String = row.get("id")?; - let parent_str: Option = row.get("parent_id")?; - let created_str: String = row.get("created_at")?; + let id_str: String = row.get("id")?; + let parent_str: Option = row.get("parent_id")?; + let created_str: String = row.get("created_at")?; - Ok(Tag { - id: parse_uuid(&id_str)?, - name: row.get("name")?, - parent_id: parent_str.and_then(|s| Uuid::parse_str(&s).ok()), - created_at: parse_datetime(&created_str), - }) + Ok(Tag { + id: parse_uuid(&id_str)?, + name: row.get("name")?, + parent_id: parent_str.and_then(|s| Uuid::parse_str(&s).ok()), + created_at: parse_datetime(&created_str), + }) } fn row_to_collection(row: &Row) -> rusqlite::Result { - let id_str: String = row.get("id")?; - let kind_str: String = row.get("kind")?; - let created_str: String = row.get("created_at")?; - let updated_str: String = row.get("updated_at")?; + let id_str: String = row.get("id")?; + let kind_str: String = row.get("kind")?; + let created_str: String = row.get("created_at")?; + let updated_str: String = row.get("updated_at")?; - let kind = match kind_str.as_str() { - "virtual" => CollectionKind::Virtual, - _ => CollectionKind::Manual, - }; + let kind = match kind_str.as_str() { + "virtual" => CollectionKind::Virtual, + _ => CollectionKind::Manual, + }; - Ok(Collection { - id: parse_uuid(&id_str)?, - name: row.get("name")?, - description: row.get("description")?, - kind, - filter_query: row.get("filter_query")?, - created_at: parse_datetime(&created_str), - updated_at: parse_datetime(&updated_str), - }) + Ok(Collection { + id: parse_uuid(&id_str)?, + name: row.get("name")?, + description: row.get("description")?, + kind, + filter_query: row.get("filter_query")?, + created_at: parse_datetime(&created_str), + updated_at: parse_datetime(&updated_str), + }) } fn row_to_audit_entry(row: &Row) -> rusqlite::Result { - let id_str: String = row.get("id")?; - let media_id_str: Option = row.get("media_id")?; - let action_str: String = row.get("action")?; - let ts_str: String = row.get("timestamp")?; + let id_str: String = row.get("id")?; + let media_id_str: Option = row.get("media_id")?; + let action_str: String = row.get("action")?; + let ts_str: String = row.get("timestamp")?; - let action = match action_str.as_str() { - "imported" => AuditAction::Imported, - "updated" => AuditAction::Updated, - "deleted" => AuditAction::Deleted, - "tagged" => AuditAction::Tagged, - "untagged" => AuditAction::Untagged, - "added_to_collection" => AuditAction::AddedToCollection, - "removed_from_collection" => AuditAction::RemovedFromCollection, - "opened" => AuditAction::Opened, - "scanned" => AuditAction::Scanned, - _ => AuditAction::Updated, // fallback - }; + let action = match action_str.as_str() { + "imported" => AuditAction::Imported, + "updated" => AuditAction::Updated, + "deleted" => AuditAction::Deleted, + "tagged" => AuditAction::Tagged, + "untagged" => AuditAction::Untagged, + "added_to_collection" => AuditAction::AddedToCollection, + "removed_from_collection" => AuditAction::RemovedFromCollection, + "opened" => AuditAction::Opened, + "scanned" => AuditAction::Scanned, + _ => AuditAction::Updated, // fallback + }; - Ok(AuditEntry { - id: parse_uuid(&id_str)?, - media_id: media_id_str.and_then(|s| Uuid::parse_str(&s).ok().map(MediaId)), - action, - details: row.get("details")?, - timestamp: parse_datetime(&ts_str), - }) + Ok(AuditEntry { + id: parse_uuid(&id_str)?, + media_id: media_id_str.and_then(|s| Uuid::parse_str(&s).ok().map(MediaId)), + action, + details: row.get("details")?, + timestamp: parse_datetime(&ts_str), + }) } fn collection_kind_to_str(kind: CollectionKind) -> &'static str { - match kind { - CollectionKind::Manual => "manual", - CollectionKind::Virtual => "virtual", - } + match kind { + CollectionKind::Manual => "manual", + CollectionKind::Virtual => "virtual", + } } fn custom_field_type_to_str(ft: CustomFieldType) -> &'static str { - match ft { - CustomFieldType::Text => "text", - CustomFieldType::Number => "number", - CustomFieldType::Date => "date", - CustomFieldType::Boolean => "boolean", - } + match ft { + CustomFieldType::Text => "text", + CustomFieldType::Number => "number", + CustomFieldType::Date => "date", + CustomFieldType::Boolean => "boolean", + } } fn str_to_custom_field_type(s: &str) -> CustomFieldType { - match s { - "number" => CustomFieldType::Number, - "date" => CustomFieldType::Date, - "boolean" => CustomFieldType::Boolean, - _ => CustomFieldType::Text, - } + match s { + "number" => CustomFieldType::Number, + "date" => CustomFieldType::Date, + "boolean" => CustomFieldType::Boolean, + _ => CustomFieldType::Text, + } } fn load_user_profile_sync( - db: &Connection, - user_id_str: &str, + db: &Connection, + user_id_str: &str, ) -> rusqlite::Result { - let result = db.query_row( - "SELECT avatar_path, bio, preferences_json FROM user_profiles WHERE user_id = ?", - [user_id_str], - |row| { - let avatar_path: Option = row.get(0)?; - let bio: Option = row.get(1)?; - let prefs_str: String = row.get(2)?; - let preferences: crate::users::UserPreferences = - serde_json::from_str(&prefs_str).unwrap_or_default(); - Ok(crate::users::UserProfile { - avatar_path, - bio, - preferences, - }) - }, - ); - match result { - Ok(profile) => Ok(profile), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(crate::users::UserProfile { - avatar_path: None, - bio: None, - preferences: Default::default(), - }), - Err(e) => Err(e), - } + let result = db.query_row( + "SELECT avatar_path, bio, preferences_json FROM user_profiles WHERE \ + user_id = ?", + [user_id_str], + |row| { + let avatar_path: Option = row.get(0)?; + let bio: Option = row.get(1)?; + let prefs_str: String = row.get(2)?; + let preferences: crate::users::UserPreferences = + serde_json::from_str(&prefs_str).unwrap_or_default(); + Ok(crate::users::UserProfile { + avatar_path, + bio, + preferences, + }) + }, + ); + match result { + Ok(profile) => Ok(profile), + Err(rusqlite::Error::QueryReturnedNoRows) => { + Ok(crate::users::UserProfile { + avatar_path: None, + bio: None, + preferences: Default::default(), + }) + }, + Err(e) => Err(e), + } } fn load_custom_fields_sync( - db: &Connection, - media_id: MediaId, + db: &Connection, + media_id: MediaId, ) -> rusqlite::Result> { - let mut stmt = db.prepare( - "SELECT field_name, field_type, field_value FROM custom_fields WHERE media_id = ?1", - )?; - let rows = stmt.query_map(params![media_id.0.to_string()], |row| { - let name: String = row.get(0)?; - let ft_str: String = row.get(1)?; - let value: String = row.get(2)?; - Ok(( - name, - CustomField { - field_type: str_to_custom_field_type(&ft_str), - value, - }, - )) - })?; - let mut map = HashMap::new(); - for r in rows { - let (name, field) = r?; - map.insert(name, field); - } - Ok(map) + let mut stmt = db.prepare( + "SELECT field_name, field_type, field_value FROM custom_fields WHERE \ + media_id = ?1", + )?; + let rows = stmt.query_map(params![media_id.0.to_string()], |row| { + let name: String = row.get(0)?; + let ft_str: String = row.get(1)?; + let value: String = row.get(2)?; + Ok((name, CustomField { + field_type: str_to_custom_field_type(&ft_str), + value, + })) + })?; + let mut map = HashMap::new(); + for r in rows { + let (name, field) = r?; + map.insert(name, field); + } + Ok(map) } -fn load_custom_fields_batch(db: &Connection, items: &mut [MediaItem]) -> rusqlite::Result<()> { - if items.is_empty() { - return Ok(()); - } - // Build a simple query for all IDs - let ids: Vec = items.iter().map(|i| i.id.0.to_string()).collect(); - let placeholders: Vec = (1..=ids.len()).map(|i| format!("?{i}")).collect(); - let sql = format!( - "SELECT media_id, field_name, field_type, field_value FROM custom_fields WHERE media_id IN ({})", - placeholders.join(", ") - ); - let mut stmt = db.prepare(&sql)?; - let params: Vec<&dyn rusqlite::types::ToSql> = ids - .iter() - .map(|s| s as &dyn rusqlite::types::ToSql) - .collect(); - let rows = stmt.query_map(params.as_slice(), |row| { - let mid_str: String = row.get(0)?; - let name: String = row.get(1)?; - let ft_str: String = row.get(2)?; - let value: String = row.get(3)?; - Ok((mid_str, name, ft_str, value)) - })?; +fn load_custom_fields_batch( + db: &Connection, + items: &mut [MediaItem], +) -> rusqlite::Result<()> { + if items.is_empty() { + return Ok(()); + } + // Build a simple query for all IDs + let ids: Vec = items.iter().map(|i| i.id.0.to_string()).collect(); + let placeholders: Vec = + (1..=ids.len()).map(|i| format!("?{i}")).collect(); + let sql = format!( + "SELECT media_id, field_name, field_type, field_value FROM custom_fields \ + WHERE media_id IN ({})", + placeholders.join(", ") + ); + let mut stmt = db.prepare(&sql)?; + let params: Vec<&dyn rusqlite::types::ToSql> = ids + .iter() + .map(|s| s as &dyn rusqlite::types::ToSql) + .collect(); + let rows = stmt.query_map(params.as_slice(), |row| { + let mid_str: String = row.get(0)?; + let name: String = row.get(1)?; + let ft_str: String = row.get(2)?; + let value: String = row.get(3)?; + Ok((mid_str, name, ft_str, value)) + })?; - let mut fields_map: HashMap> = HashMap::new(); - for r in rows { - let (mid_str, name, ft_str, value) = r?; - fields_map.entry(mid_str).or_default().insert( - name, - CustomField { - field_type: str_to_custom_field_type(&ft_str), - value, - }, - ); - } + let mut fields_map: HashMap> = + HashMap::new(); + for r in rows { + let (mid_str, name, ft_str, value) = r?; + fields_map + .entry(mid_str) + .or_default() + .insert(name, CustomField { + field_type: str_to_custom_field_type(&ft_str), + value, + }); + } - for item in items.iter_mut() { - if let Some(fields) = fields_map.remove(&item.id.0.to_string()) { - item.custom_fields = fields; - } + for item in items.iter_mut() { + if let Some(fields) = fields_map.remove(&item.id.0.to_string()) { + item.custom_fields = fields; } - Ok(()) + } + Ok(()) } // --------------------------------------------------------------------------- @@ -375,180 +401,204 @@ fn load_custom_fields_batch(db: &Connection, items: &mut [MediaItem]) -> rusqlit /// - `params` are bind parameter values corresponding to `?` placeholders in /// where_clauses and join_clauses. fn search_query_to_fts( - query: &SearchQuery, + query: &SearchQuery, ) -> (String, Vec, Vec, Vec, Vec) { - let mut wheres = Vec::new(); - let mut joins = Vec::new(); - let mut params = Vec::new(); - let mut like_terms = Vec::new(); - let fts = build_fts_expr(query, &mut wheres, &mut joins, &mut params, &mut like_terms); - (fts, like_terms, wheres, joins, params) + let mut wheres = Vec::new(); + let mut joins = Vec::new(); + let mut params = Vec::new(); + let mut like_terms = Vec::new(); + let fts = build_fts_expr( + query, + &mut wheres, + &mut joins, + &mut params, + &mut like_terms, + ); + (fts, like_terms, wheres, joins, params) } fn build_fts_expr( - query: &SearchQuery, - wheres: &mut Vec, - joins: &mut Vec, - params: &mut Vec, - like_terms: &mut Vec, + query: &SearchQuery, + wheres: &mut Vec, + joins: &mut Vec, + params: &mut Vec, + like_terms: &mut Vec, ) -> String { - match query { - SearchQuery::FullText(text) => { - if text.is_empty() { - String::new() - } else { - // Collect term for LIKE fallback matching - like_terms.push(text.clone()); - // Add implicit prefix matching for better partial matches - // This allows "mus" to match "music", "musician", etc. - let sanitized = sanitize_fts_token(text); - // If it's a single word, add prefix matching - if !sanitized.contains(' ') && !sanitized.contains('"') { - format!("{}*", sanitized) - } else { - // For phrases, use as-is but also add NEAR for proximity - sanitized - } - } + match query { + SearchQuery::FullText(text) => { + if text.is_empty() { + String::new() + } else { + // Collect term for LIKE fallback matching + like_terms.push(text.clone()); + // Add implicit prefix matching for better partial matches + // This allows "mus" to match "music", "musician", etc. + let sanitized = sanitize_fts_token(text); + // If it's a single word, add prefix matching + if !sanitized.contains(' ') && !sanitized.contains('"') { + format!("{}*", sanitized) + } else { + // For phrases, use as-is but also add NEAR for proximity + sanitized } - SearchQuery::Prefix(prefix) => { - like_terms.push(prefix.clone()); - format!("{}*", sanitize_fts_token(prefix)) - } - SearchQuery::Fuzzy(term) => { - // FTS5 does not natively support fuzzy; use prefix match - // as a best-effort approximation. - like_terms.push(term.clone()); - format!("{}*", sanitize_fts_token(term)) - } - SearchQuery::FieldMatch { field, value } => { - // FTS5 column filter syntax: `column:term` - let safe_field = sanitize_fts_token(field); - let safe_value = sanitize_fts_token(value); - format!("{safe_field}:{safe_value}") - } - SearchQuery::Not(inner) => { - let inner_expr = build_fts_expr(inner, wheres, joins, params, like_terms); - if inner_expr.is_empty() { - String::new() - } else { - format!("NOT {inner_expr}") - } - } - SearchQuery::And(terms) => { - let parts: Vec = terms - .iter() - .map(|t| build_fts_expr(t, wheres, joins, params, like_terms)) - .filter(|s| !s.is_empty()) - .collect(); - parts.join(" ") - } - SearchQuery::Or(terms) => { - let parts: Vec = terms - .iter() - .map(|t| build_fts_expr(t, wheres, joins, params, like_terms)) - .filter(|s| !s.is_empty()) - .collect(); - if parts.len() <= 1 { - parts.into_iter().next().unwrap_or_default() - } else { - format!("({})", parts.join(" OR ")) - } - } - SearchQuery::TypeFilter(type_name) => { - wheres.push("m.media_type = ?".to_string()); - params.push(type_name.clone()); - String::new() - } - SearchQuery::TagFilter(tag_name) => { - // Use a unique alias per tag join to allow multiple tag filters. - let alias_idx = joins.len(); - let alias_mt = format!("mt{alias_idx}"); - let alias_t = format!("t{alias_idx}"); - joins.push(format!( - "JOIN media_tags {alias_mt} ON {alias_mt}.media_id = m.id \ - JOIN tags {alias_t} ON {alias_t}.id = {alias_mt}.tag_id AND {alias_t}.name = ?", - )); - params.push(tag_name.clone()); - String::new() - } - SearchQuery::RangeQuery { field, start, end } => { - let col = match field.as_str() { - "year" => "m.year", - "size" | "file_size" => "m.file_size", - "duration" => "m.duration_secs", - _ => return String::new(), // Unknown field, ignore - }; - match (start, end) { - (Some(s), Some(e)) => { - wheres.push(format!("{col} >= ? AND {col} <= ?")); - params.push(s.to_string()); - params.push(e.to_string()); - } - (Some(s), None) => { - wheres.push(format!("{col} >= ?")); - params.push(s.to_string()); - } - (None, Some(e)) => { - wheres.push(format!("{col} <= ?")); - params.push(e.to_string()); - } - (None, None) => {} - } - String::new() - } - SearchQuery::CompareQuery { field, op, value } => { - let col = match field.as_str() { - "year" => "m.year", - "size" | "file_size" => "m.file_size", - "duration" => "m.duration_secs", - _ => return String::new(), // Unknown field, ignore - }; - let op_sql = match op { - crate::search::CompareOp::GreaterThan => ">", - crate::search::CompareOp::GreaterOrEqual => ">=", - crate::search::CompareOp::LessThan => "<", - crate::search::CompareOp::LessOrEqual => "<=", - }; - wheres.push(format!("{col} {op_sql} ?")); - params.push(value.to_string()); - String::new() - } - SearchQuery::DateQuery { field, value } => { - let col = match field.as_str() { - "created" => "m.created_at", - "modified" | "updated" => "m.updated_at", - _ => return String::new(), - }; - let sql = date_value_to_sqlite_expr(col, value); - if !sql.is_empty() { - wheres.push(sql); - } - String::new() - } - } + } + }, + SearchQuery::Prefix(prefix) => { + like_terms.push(prefix.clone()); + format!("{}*", sanitize_fts_token(prefix)) + }, + SearchQuery::Fuzzy(term) => { + // FTS5 does not natively support fuzzy; use prefix match + // as a best-effort approximation. + like_terms.push(term.clone()); + format!("{}*", sanitize_fts_token(term)) + }, + SearchQuery::FieldMatch { field, value } => { + // FTS5 column filter syntax: `column:term` + let safe_field = sanitize_fts_token(field); + let safe_value = sanitize_fts_token(value); + format!("{safe_field}:{safe_value}") + }, + SearchQuery::Not(inner) => { + let inner_expr = build_fts_expr(inner, wheres, joins, params, like_terms); + if inner_expr.is_empty() { + String::new() + } else { + format!("NOT {inner_expr}") + } + }, + SearchQuery::And(terms) => { + let parts: Vec = terms + .iter() + .map(|t| build_fts_expr(t, wheres, joins, params, like_terms)) + .filter(|s| !s.is_empty()) + .collect(); + parts.join(" ") + }, + SearchQuery::Or(terms) => { + let parts: Vec = terms + .iter() + .map(|t| build_fts_expr(t, wheres, joins, params, like_terms)) + .filter(|s| !s.is_empty()) + .collect(); + if parts.len() <= 1 { + parts.into_iter().next().unwrap_or_default() + } else { + format!("({})", parts.join(" OR ")) + } + }, + SearchQuery::TypeFilter(type_name) => { + wheres.push("m.media_type = ?".to_string()); + params.push(type_name.clone()); + String::new() + }, + SearchQuery::TagFilter(tag_name) => { + // Use a unique alias per tag join to allow multiple tag filters. + let alias_idx = joins.len(); + let alias_mt = format!("mt{alias_idx}"); + let alias_t = format!("t{alias_idx}"); + joins.push(format!( + "JOIN media_tags {alias_mt} ON {alias_mt}.media_id = m.id JOIN tags \ + {alias_t} ON {alias_t}.id = {alias_mt}.tag_id AND {alias_t}.name = ?", + )); + params.push(tag_name.clone()); + String::new() + }, + SearchQuery::RangeQuery { field, start, end } => { + let col = match field.as_str() { + "year" => "m.year", + "size" | "file_size" => "m.file_size", + "duration" => "m.duration_secs", + _ => return String::new(), // Unknown field, ignore + }; + match (start, end) { + (Some(s), Some(e)) => { + wheres.push(format!("{col} >= ? AND {col} <= ?")); + params.push(s.to_string()); + params.push(e.to_string()); + }, + (Some(s), None) => { + wheres.push(format!("{col} >= ?")); + params.push(s.to_string()); + }, + (None, Some(e)) => { + wheres.push(format!("{col} <= ?")); + params.push(e.to_string()); + }, + (None, None) => {}, + } + String::new() + }, + SearchQuery::CompareQuery { field, op, value } => { + let col = match field.as_str() { + "year" => "m.year", + "size" | "file_size" => "m.file_size", + "duration" => "m.duration_secs", + _ => return String::new(), // Unknown field, ignore + }; + let op_sql = match op { + crate::search::CompareOp::GreaterThan => ">", + crate::search::CompareOp::GreaterOrEqual => ">=", + crate::search::CompareOp::LessThan => "<", + crate::search::CompareOp::LessOrEqual => "<=", + }; + wheres.push(format!("{col} {op_sql} ?")); + params.push(value.to_string()); + String::new() + }, + SearchQuery::DateQuery { field, value } => { + let col = match field.as_str() { + "created" => "m.created_at", + "modified" | "updated" => "m.updated_at", + _ => return String::new(), + }; + let sql = date_value_to_sqlite_expr(col, value); + if !sql.is_empty() { + wheres.push(sql); + } + String::new() + }, + } } /// Convert a DateValue to a SQLite datetime comparison expression -fn date_value_to_sqlite_expr(col: &str, value: &crate::search::DateValue) -> String { - use crate::search::DateValue; - match value { - DateValue::Today => format!("date({col}) = date('now')"), - DateValue::Yesterday => format!("date({col}) = date('now', '-1 day')"), - DateValue::ThisWeek => format!("{col} >= datetime('now', 'weekday 0', '-7 days')"), - DateValue::LastWeek => format!( - "{col} >= datetime('now', 'weekday 0', '-14 days') AND {col} < datetime('now', 'weekday 0', '-7 days')" - ), - DateValue::ThisMonth => format!("{col} >= datetime('now', 'start of month')"), - DateValue::LastMonth => format!( - "{col} >= datetime('now', 'start of month', '-1 month') AND {col} < datetime('now', 'start of month')" - ), - DateValue::ThisYear => format!("{col} >= datetime('now', 'start of year')"), - DateValue::LastYear => format!( - "{col} >= datetime('now', 'start of year', '-1 year') AND {col} < datetime('now', 'start of year')" - ), - DateValue::DaysAgo(days) => format!("{col} >= datetime('now', '-{days} days')"), - } +fn date_value_to_sqlite_expr( + col: &str, + value: &crate::search::DateValue, +) -> String { + use crate::search::DateValue; + match value { + DateValue::Today => format!("date({col}) = date('now')"), + DateValue::Yesterday => format!("date({col}) = date('now', '-1 day')"), + DateValue::ThisWeek => { + format!("{col} >= datetime('now', 'weekday 0', '-7 days')") + }, + DateValue::LastWeek => { + format!( + "{col} >= datetime('now', 'weekday 0', '-14 days') AND {col} < \ + datetime('now', 'weekday 0', '-7 days')" + ) + }, + DateValue::ThisMonth => { + format!("{col} >= datetime('now', 'start of month')") + }, + DateValue::LastMonth => { + format!( + "{col} >= datetime('now', 'start of month', '-1 month') AND {col} < \ + datetime('now', 'start of month')" + ) + }, + DateValue::ThisYear => format!("{col} >= datetime('now', 'start of year')"), + DateValue::LastYear => { + format!( + "{col} >= datetime('now', 'start of year', '-1 year') AND {col} < \ + datetime('now', 'start of year')" + ) + }, + DateValue::DaysAgo(days) => { + format!("{col} >= datetime('now', '-{days} days')") + }, + } } /// Sanitize a string for use in FTS5 query expressions. @@ -556,25 +606,26 @@ fn date_value_to_sqlite_expr(col: &str, value: &crate::search::DateValue) -> Str /// Strips control characters, escapes double quotes, and wraps the result /// in double quotes so it is treated as a single FTS5 term. fn sanitize_fts_token(s: &str) -> String { - let cleaned: String = s - .chars() - .filter(|c| !c.is_control()) - .filter(|c| c.is_alphanumeric() || *c == '_' || *c == ' ') - .collect(); - let escaped = cleaned.replace('"', "\"\""); - format!("\"{escaped}\"") + let cleaned: String = s + .chars() + .filter(|c| !c.is_control()) + .filter(|c| c.is_alphanumeric() || *c == '_' || *c == ' ') + .collect(); + let escaped = cleaned.replace('"', "\"\""); + format!("\"{escaped}\"") } fn sort_order_to_sql(sort: &SortOrder) -> &'static str { - match sort { - SortOrder::Relevance => "m.created_at DESC", // FTS rank not easily portable; use date - SortOrder::DateAsc => "m.created_at ASC", - SortOrder::DateDesc => "m.created_at DESC", - SortOrder::NameAsc => "m.file_name ASC", - SortOrder::NameDesc => "m.file_name DESC", - SortOrder::SizeAsc => "m.file_size ASC", - SortOrder::SizeDesc => "m.file_size DESC", - } + match sort { + SortOrder::Relevance => "m.created_at DESC", /* FTS rank not easily + * portable; use date */ + SortOrder::DateAsc => "m.created_at ASC", + SortOrder::DateDesc => "m.created_at DESC", + SortOrder::NameAsc => "m.file_name ASC", + SortOrder::NameDesc => "m.file_name DESC", + SortOrder::SizeAsc => "m.file_size ASC", + SortOrder::SizeDesc => "m.file_size DESC", + } } // --------------------------------------------------------------------------- @@ -583,4655 +634,5594 @@ fn sort_order_to_sql(sort: &SortOrder) -> &'static str { #[async_trait::async_trait] impl StorageBackend for SqliteBackend { - // -- Migrations -------------------------------------------------------- + // -- Migrations -------------------------------------------------------- - async fn run_migrations(&self) -> Result<()> { - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let mut db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - crate::storage::migrations::run_sqlite_migrations(&mut db) + async fn run_migrations(&self) -> Result<()> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let mut db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + crate::storage::migrations::run_sqlite_migrations(&mut db) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + // -- Root directories -------------------------------------------------- + + async fn add_root_dir(&self, path: PathBuf) -> Result<()> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute( + "INSERT OR IGNORE INTO root_dirs (path) VALUES (?1)", + params![path.to_string_lossy().as_ref()], + )?; + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn list_root_dirs(&self) -> Result> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare("SELECT path FROM root_dirs ORDER BY path")?; + let rows = stmt + .query_map([], |row| { + let p: String = row.get(0)?; + Ok(PathBuf::from(p)) + })? + .collect::>>()?; + Ok(rows) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn remove_root_dir(&self, path: &Path) -> Result<()> { + let path = path.to_path_buf(); + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute("DELETE FROM root_dirs WHERE path = ?1", params![ + path.to_string_lossy().as_ref() + ])?; + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + // -- Media CRUD -------------------------------------------------------- + + async fn insert_media(&self, item: &MediaItem) -> Result<()> { + let item = item.clone(); + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + db.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, file_mtime, 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, ?24)", + params![ + item.id.0.to_string(), + item.path.to_string_lossy().as_ref(), + item.file_name, + media_type_to_str(&item.media_type), + item.content_hash.0, + item.file_size as i64, + item.title, + item.artist, + item.album, + item.genre, + item.year, + item.duration_secs, + item.description, + item + .thumbnail_path + .as_ref() + .map(|p| p.to_string_lossy().to_string()), + item.file_mtime, + item.date_taken.as_ref().map(|d| d.to_rfc3339()), + item.latitude, + item.longitude, + item.camera_make, + item.camera_model, + item.rating, + item.perceptual_hash, + item.created_at.to_rfc3339(), + item.updated_at.to_rfc3339(), + ], + )?; + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn count_media(&self) -> Result { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let count: i64 = db.query_row( + "SELECT COUNT(*) FROM media_items WHERE deleted_at IS NULL", + [], + |row| row.get(0), + )?; + Ok(count as u64) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn get_media(&self, id: MediaId) -> Result { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare( + "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, links_extracted_at FROM media_items WHERE id = ?1", + )?; + let mut item = stmt + .query_row(params![id.0.to_string()], row_to_media_item) + .map_err(|e| { + match e { + rusqlite::Error::QueryReturnedNoRows => { + PinakesError::NotFound(format!("media item {id}")) + }, + other => PinakesError::from(other), + } + })?; + item.custom_fields = load_custom_fields_sync(&db, item.id)?; + Ok(item) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn get_media_by_hash( + &self, + hash: &ContentHash, + ) -> Result> { + let hash = hash.clone(); + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare( + "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, links_extracted_at FROM media_items WHERE content_hash = \ + ?1", + )?; + let result = stmt + .query_row(params![hash.0], row_to_media_item) + .optional()?; + if let Some(mut item) = result { + item.custom_fields = load_custom_fields_sync(&db, item.id)?; + Ok(Some(item)) + } else { + Ok(None) + } + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn get_media_by_path( + &self, + path: &std::path::Path, + ) -> Result> { + let path_str = path.to_string_lossy().to_string(); + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare( + "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, links_extracted_at FROM media_items WHERE path = ?1", + )?; + let result = stmt + .query_row(params![path_str], row_to_media_item) + .optional()?; + if let Some(mut item) = result { + item.custom_fields = load_custom_fields_sync(&db, item.id)?; + Ok(Some(item)) + } else { + Ok(None) + } + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn list_media( + &self, + pagination: &Pagination, + ) -> Result> { + let pagination = pagination.clone(); + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + 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, links_extracted_at FROM media_items WHERE deleted_at IS \ + NULL ORDER BY {order_by} LIMIT ?1 OFFSET ?2" + ); + let mut stmt = db.prepare(&sql)?; + let mut rows = stmt + .query_map( + params![pagination.limit as i64, pagination.offset as i64], + row_to_media_item, + )? + .collect::>>()?; + load_custom_fields_batch(&db, &mut rows)?; + Ok(rows) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn update_media(&self, item: &MediaItem) -> Result<()> { + let item = item.clone(); + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let changed = db.execute( + "UPDATE media_items SET path = ?2, file_name = ?3, media_type = ?4, \ + content_hash = ?5, file_size = ?6, title = ?7, artist = ?8, album = \ + ?9, genre = ?10, year = ?11, duration_secs = ?12, description = ?13, \ + thumbnail_path = ?14, file_mtime = ?15, date_taken = ?16, latitude = \ + ?17, longitude = ?18, camera_make = ?19, camera_model = ?20, rating \ + = ?21, perceptual_hash = ?22, updated_at = ?23 WHERE id = ?1", + params![ + item.id.0.to_string(), + item.path.to_string_lossy().as_ref(), + item.file_name, + media_type_to_str(&item.media_type), + item.content_hash.0, + item.file_size as i64, + item.title, + item.artist, + item.album, + item.genre, + item.year, + item.duration_secs, + item.description, + item + .thumbnail_path + .as_ref() + .map(|p| p.to_string_lossy().to_string()), + item.file_mtime, + item.date_taken.as_ref().map(|d| d.to_rfc3339()), + item.latitude, + item.longitude, + item.camera_make, + item.camera_model, + item.rating, + item.perceptual_hash, + item.updated_at.to_rfc3339(), + ], + )?; + if changed == 0 { + return Err(PinakesError::NotFound(format!("media item {}", item.id))); + } + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn delete_media(&self, id: MediaId) -> Result<()> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let changed = db + .execute("DELETE FROM media_items WHERE id = ?1", params![ + id.0.to_string() + ])?; + if changed == 0 { + return Err(PinakesError::NotFound(format!("media item {id}"))); + } + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn delete_all_media(&self) -> Result { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let count: u64 = + db.query_row("SELECT COUNT(*) FROM media_items", [], |row| row.get(0))?; + db.execute("DELETE FROM media_items", [])?; + Ok(count) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + // -- Tags -------------------------------------------------------------- + + async fn create_tag( + &self, + name: &str, + parent_id: Option, + ) -> Result { + let name = name.to_string(); + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let id = Uuid::now_v7(); + let now = Utc::now(); + db.execute( + "INSERT INTO tags (id, name, parent_id, created_at) VALUES (?1, ?2, \ + ?3, ?4)", + params![ + id.to_string(), + name, + parent_id.map(|p| p.to_string()), + now.to_rfc3339(), + ], + )?; + Ok(Tag { + id, + name, + parent_id, + created_at: now, + }) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn get_tag(&self, id: Uuid) -> Result { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare( + "SELECT id, name, parent_id, created_at FROM tags WHERE id = ?1", + )?; + stmt + .query_row(params![id.to_string()], row_to_tag) + .map_err(|e| { + match e { + rusqlite::Error::QueryReturnedNoRows => { + PinakesError::TagNotFound(id.to_string()) + }, + other => PinakesError::from(other), + } }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } - // -- Root directories -------------------------------------------------- + async fn list_tags(&self) -> Result> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare( + "SELECT id, name, parent_id, created_at FROM tags ORDER BY name", + )?; + let rows = stmt + .query_map([], row_to_tag)? + .collect::>>()?; + Ok(rows) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } - async fn add_root_dir(&self, path: PathBuf) -> Result<()> { - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - db.execute( - "INSERT OR IGNORE INTO root_dirs (path) VALUES (?1)", - params![path.to_string_lossy().as_ref()], - )?; - Ok(()) + async fn delete_tag(&self, id: Uuid) -> Result<()> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let changed = + db.execute("DELETE FROM tags WHERE id = ?1", params![id.to_string()])?; + if changed == 0 { + return Err(PinakesError::TagNotFound(id.to_string())); + } + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn tag_media(&self, media_id: MediaId, tag_id: Uuid) -> Result<()> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute( + "INSERT OR IGNORE INTO media_tags (media_id, tag_id) VALUES (?1, ?2)", + params![media_id.0.to_string(), tag_id.to_string()], + )?; + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn untag_media(&self, media_id: MediaId, tag_id: Uuid) -> Result<()> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute( + "DELETE FROM media_tags WHERE media_id = ?1 AND tag_id = ?2", + params![media_id.0.to_string(), tag_id.to_string()], + )?; + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn get_media_tags(&self, media_id: MediaId) -> Result> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare( + "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", + )?; + let rows = stmt + .query_map(params![media_id.0.to_string()], row_to_tag)? + .collect::>>()?; + Ok(rows) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn get_tag_descendants(&self, tag_id: Uuid) -> Result> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare( + "WITH RECURSIVE descendants(id, name, parent_id, created_at) 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", + )?; + let rows = stmt + .query_map(params![tag_id.to_string()], row_to_tag)? + .collect::>>()?; + Ok(rows) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + // -- Collections ------------------------------------------------------- + + async fn create_collection( + &self, + name: &str, + kind: CollectionKind, + description: Option<&str>, + filter_query: Option<&str>, + ) -> Result { + let name = name.to_string(); + let description = description.map(|s| s.to_string()); + let filter_query = filter_query.map(|s| s.to_string()); + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let id = Uuid::now_v7(); + let now = Utc::now(); + db.execute( + "INSERT INTO collections (id, name, description, kind, filter_query, \ + created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + id.to_string(), + name, + description, + collection_kind_to_str(kind), + filter_query, + now.to_rfc3339(), + now.to_rfc3339(), + ], + )?; + Ok(Collection { + id, + name, + description, + kind, + filter_query, + created_at: now, + updated_at: now, + }) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn get_collection(&self, id: Uuid) -> Result { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare( + "SELECT id, name, description, kind, filter_query, created_at, \ + updated_at FROM collections WHERE id = ?1", + )?; + stmt + .query_row(params![id.to_string()], row_to_collection) + .map_err(|e| { + match e { + rusqlite::Error::QueryReturnedNoRows => { + PinakesError::CollectionNotFound(id.to_string()) + }, + other => PinakesError::from(other), + } }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn list_collections(&self) -> Result> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare( + "SELECT id, name, description, kind, filter_query, created_at, \ + updated_at FROM collections ORDER BY name", + )?; + let rows = stmt + .query_map([], row_to_collection)? + .collect::>>()?; + Ok(rows) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn delete_collection(&self, id: Uuid) -> Result<()> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let changed = db + .execute("DELETE FROM collections WHERE id = ?1", params![ + id.to_string() + ])?; + if changed == 0 { + return Err(PinakesError::CollectionNotFound(id.to_string())); + } + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn add_to_collection( + &self, + collection_id: Uuid, + media_id: MediaId, + position: i32, + ) -> Result<()> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let now = Utc::now(); + db.execute( + "INSERT OR REPLACE INTO collection_members (collection_id, media_id, \ + position, added_at) VALUES (?1, ?2, ?3, ?4)", + params![ + collection_id.to_string(), + media_id.0.to_string(), + position, + now.to_rfc3339(), + ], + )?; + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn remove_from_collection( + &self, + collection_id: Uuid, + media_id: MediaId, + ) -> Result<()> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute( + "DELETE FROM collection_members WHERE collection_id = ?1 AND media_id \ + = ?2", + params![collection_id.to_string(), media_id.0.to_string()], + )?; + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn get_collection_members( + &self, + collection_id: Uuid, + ) -> Result> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare( + "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, \ + m.file_size, m.title, m.artist, m.album, m.genre, 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.storage_mode, \ + m.original_filename, m.uploaded_at, m.storage_key, m.created_at, \ + m.updated_at, m.deleted_at, m.links_extracted_at FROM media_items m \ + JOIN collection_members cm ON cm.media_id = m.id WHERE \ + cm.collection_id = ?1 ORDER BY cm.position", + )?; + let mut rows = stmt + .query_map(params![collection_id.to_string()], row_to_media_item)? + .collect::>>()?; + load_custom_fields_batch(&db, &mut rows)?; + Ok(rows) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + // -- Search ------------------------------------------------------------ + + async fn search(&self, request: &SearchRequest) -> Result { + let request = request.clone(); + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + + let (fts_expr, _like_terms, where_clauses, join_clauses, bind_params) = + search_query_to_fts(&request.query); + + let use_fts = !fts_expr.is_empty(); + let order_by = sort_order_to_sql(&request.sort); + + // Build the base query. + let mut sql = String::from( + "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.storage_mode, \ + m.original_filename, m.uploaded_at, m.storage_key, m.created_at, \ + m.updated_at, m.deleted_at, m.links_extracted_at FROM media_items m ", + ); + + if use_fts { + sql.push_str("JOIN media_fts ON media_fts.rowid = m.rowid "); + } + + for j in &join_clauses { + sql.push_str(j); + sql.push(' '); + } + + // Collect all bind parameters: first the filter params, then FTS + // match (if any), then LIMIT and OFFSET. + let mut all_params: Vec = bind_params.clone(); + + let mut conditions = where_clauses.clone(); + if use_fts { + conditions.push("media_fts MATCH ?".to_string()); + all_params.push(fts_expr.clone()); + } + + if !conditions.is_empty() { + sql.push_str("WHERE "); + sql.push_str(&conditions.join(" AND ")); + sql.push(' '); + } + + sql.push_str(&format!("ORDER BY {order_by} LIMIT ? OFFSET ?",)); + all_params.push(request.pagination.limit.to_string()); + all_params.push(request.pagination.offset.to_string()); + + let mut stmt = db.prepare(&sql)?; + let param_refs: Vec<&dyn rusqlite::types::ToSql> = all_params + .iter() + .map(|s| s as &dyn rusqlite::types::ToSql) + .collect(); + let mut items = stmt + .query_map(param_refs.as_slice(), row_to_media_item)? + .collect::>>()?; + load_custom_fields_batch(&db, &mut items)?; + + // Count query (same filters, no LIMIT/OFFSET) + let mut count_sql = String::from("SELECT COUNT(*) FROM media_items m "); + if use_fts { + count_sql.push_str("JOIN media_fts ON media_fts.rowid = m.rowid "); + } + for j in &join_clauses { + count_sql.push_str(j); + count_sql.push(' '); + } + if !conditions.is_empty() { + count_sql.push_str("WHERE "); + count_sql.push_str(&conditions.join(" AND ")); + } + + // Count query uses the same filter params (+ FTS match) but no + // LIMIT/OFFSET + let mut count_params: Vec = bind_params; + if use_fts { + count_params.push(fts_expr); + } + let count_param_refs: Vec<&dyn rusqlite::types::ToSql> = count_params + .iter() + .map(|s| s as &dyn rusqlite::types::ToSql) + .collect(); + let total_count: i64 = + db.query_row(&count_sql, count_param_refs.as_slice(), |row| { + row.get(0) + })?; + + Ok(SearchResults { + items, + total_count: total_count as u64, + }) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + // -- Audit ------------------------------------------------------------- + + async fn record_audit(&self, entry: &AuditEntry) -> Result<()> { + let entry = entry.clone(); + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute( + "INSERT INTO audit_log (id, media_id, action, details, timestamp) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + entry.id.to_string(), + entry.media_id.map(|mid| mid.0.to_string()), + entry.action.to_string(), + entry.details, + entry.timestamp.to_rfc3339(), + ], + )?; + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn list_audit_entries( + &self, + media_id: Option, + pagination: &Pagination, + ) -> Result> { + let pagination = pagination.clone(); + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + + let (sql, bind_media_id) = if let Some(mid) = media_id { + ( + "SELECT id, media_id, action, details, timestamp FROM audit_log \ + WHERE media_id = ?1 ORDER BY timestamp DESC LIMIT ?2 OFFSET ?3" + .to_string(), + Some(mid.0.to_string()), + ) + } else { + ( + "SELECT id, media_id, action, details, timestamp FROM audit_log \ + ORDER BY timestamp DESC LIMIT ?1 OFFSET ?2" + .to_string(), + None, + ) + }; + + let mut stmt = db.prepare(&sql)?; + let rows = if let Some(ref mid_str) = bind_media_id { + stmt + .query_map( + params![mid_str, pagination.limit as i64, pagination.offset as i64], + row_to_audit_entry, + )? + .collect::>>()? + } else { + stmt + .query_map( + params![pagination.limit as i64, pagination.offset as i64], + row_to_audit_entry, + )? + .collect::>>()? + }; + + Ok(rows) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + // -- Custom fields ----------------------------------------------------- + + async fn set_custom_field( + &self, + media_id: MediaId, + name: &str, + field: &CustomField, + ) -> Result<()> { + let name = name.to_string(); + let field = field.clone(); + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute( + "INSERT OR REPLACE INTO custom_fields (media_id, field_name, \ + field_type, field_value) VALUES (?1, ?2, ?3, ?4)", + params![ + media_id.0.to_string(), + name, + custom_field_type_to_str(field.field_type), + field.value, + ], + )?; + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn get_custom_fields( + &self, + media_id: MediaId, + ) -> Result> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare( + "SELECT field_name, field_type, field_value FROM custom_fields WHERE \ + media_id = ?1", + )?; + let rows = stmt.query_map(params![media_id.0.to_string()], |row| { + let name: String = row.get(0)?; + let ft_str: String = row.get(1)?; + let value: String = row.get(2)?; + Ok((name, CustomField { + field_type: str_to_custom_field_type(&ft_str), + value, + })) + })?; + + let mut map = HashMap::new(); + for r in rows { + let (name, field) = r?; + map.insert(name, field); + } + Ok(map) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn delete_custom_field( + &self, + media_id: MediaId, + name: &str, + ) -> Result<()> { + let name = name.to_string(); + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute( + "DELETE FROM custom_fields WHERE media_id = ?1 AND field_name = ?2", + params![media_id.0.to_string(), name], + )?; + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn batch_delete_media(&self, ids: &[MediaId]) -> Result { + if ids.is_empty() { + return Ok(0); } + let ids: Vec = ids.iter().map(|id| id.0.to_string()).collect(); + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + // Use IN clause for batch delete - much faster than individual deletes + // SQLite has a limit of ~500-1000 items in IN clause, so chunk if needed + const CHUNK_SIZE: usize = 500; + db.execute_batch("BEGIN IMMEDIATE")?; + let mut count = 0u64; + for chunk in ids.chunks(CHUNK_SIZE) { + let placeholders: Vec = + (1..=chunk.len()).map(|i| format!("?{}", i)).collect(); + let sql = format!( + "DELETE FROM media_items WHERE id IN ({})", + placeholders.join(", ") + ); + let params: Vec<&dyn rusqlite::ToSql> = + chunk.iter().map(|s| s as &dyn rusqlite::ToSql).collect(); + let rows = db.execute(&sql, params.as_slice())?; + count += rows as u64; + } + db.execute_batch("COMMIT")?; + Ok(count) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } - async fn list_root_dirs(&self) -> Result> { - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare("SELECT path FROM root_dirs ORDER BY path")?; - let rows = stmt - .query_map([], |row| { - let p: String = row.get(0)?; - Ok(PathBuf::from(p)) - })? - .collect::>>()?; - Ok(rows) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? + 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); } - - async fn remove_root_dir(&self, path: &Path) -> Result<()> { - let path = path.to_path_buf(); - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - db.execute( - "DELETE FROM root_dirs WHERE path = ?1", - params![path.to_string_lossy().as_ref()], - )?; - Ok(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - // -- Media CRUD -------------------------------------------------------- - - async fn insert_media(&self, item: &MediaItem) -> Result<()> { - let item = item.clone(); - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - db.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, file_mtime, 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, ?24)", - params![ - item.id.0.to_string(), - item.path.to_string_lossy().as_ref(), - item.file_name, - media_type_to_str(&item.media_type), - item.content_hash.0, - item.file_size as i64, - item.title, - item.artist, - item.album, - item.genre, - item.year, - item.duration_secs, - item.description, - item.thumbnail_path - .as_ref() - .map(|p| p.to_string_lossy().to_string()), - item.file_mtime, - item.date_taken.as_ref().map(|d| d.to_rfc3339()), - item.latitude, - item.longitude, - item.camera_make, - item.camera_model, - item.rating, - item.perceptual_hash, - item.created_at.to_rfc3339(), - item.updated_at.to_rfc3339(), - ], - )?; - Ok(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - async fn count_media(&self) -> Result { - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - let count: i64 = db.query_row( - "SELECT COUNT(*) FROM media_items WHERE deleted_at IS NULL", - [], - |row| row.get(0), - )?; - Ok(count as u64) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - async fn get_media(&self, id: MediaId) -> Result { - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "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, links_extracted_at \ - FROM media_items WHERE id = ?1", - )?; - let mut item = stmt - .query_row(params![id.0.to_string()], row_to_media_item) - .map_err(|e| match e { - rusqlite::Error::QueryReturnedNoRows => { - PinakesError::NotFound(format!("media item {id}")) - } - other => PinakesError::from(other), - })?; - item.custom_fields = load_custom_fields_sync(&db, item.id)?; - Ok(item) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - async fn get_media_by_hash(&self, hash: &ContentHash) -> Result> { - let hash = hash.clone(); - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "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, links_extracted_at \ - FROM media_items WHERE content_hash = ?1", - )?; - let result = stmt - .query_row(params![hash.0], row_to_media_item) - .optional()?; - if let Some(mut item) = result { - item.custom_fields = load_custom_fields_sync(&db, item.id)?; - Ok(Some(item)) - } else { - Ok(None) - } - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - async fn get_media_by_path(&self, path: &std::path::Path) -> Result> { - let path_str = path.to_string_lossy().to_string(); - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "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, links_extracted_at \ - FROM media_items WHERE path = ?1", - )?; - let result = stmt - .query_row(params![path_str], row_to_media_item) - .optional()?; - if let Some(mut item) = result { - item.custom_fields = load_custom_fields_sync(&db, item.id)?; - Ok(Some(item)) - } else { - Ok(None) - } - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - async fn list_media(&self, pagination: &Pagination) -> Result> { - let pagination = pagination.clone(); - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - 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, links_extracted_at \ - FROM media_items \ - WHERE deleted_at IS NULL \ - ORDER BY {order_by} LIMIT ?1 OFFSET ?2" - ); - let mut stmt = db.prepare(&sql)?; - let mut rows = stmt - .query_map( - params![pagination.limit as i64, pagination.offset as i64], - row_to_media_item, - )? - .collect::>>()?; - load_custom_fields_batch(&db, &mut rows)?; - Ok(rows) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - async fn update_media(&self, item: &MediaItem) -> Result<()> { - let item = item.clone(); - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - let changed = db.execute( - "UPDATE media_items SET path = ?2, file_name = ?3, media_type = ?4, \ - content_hash = ?5, file_size = ?6, title = ?7, artist = ?8, album = ?9, \ - genre = ?10, year = ?11, duration_secs = ?12, description = ?13, \ - thumbnail_path = ?14, file_mtime = ?15, date_taken = ?16, latitude = ?17, \ - longitude = ?18, camera_make = ?19, camera_model = ?20, rating = ?21, \ - perceptual_hash = ?22, updated_at = ?23 WHERE id = ?1", - params![ - item.id.0.to_string(), - item.path.to_string_lossy().as_ref(), - item.file_name, - media_type_to_str(&item.media_type), - item.content_hash.0, - item.file_size as i64, - item.title, - item.artist, - item.album, - item.genre, - item.year, - item.duration_secs, - item.description, - item.thumbnail_path - .as_ref() - .map(|p| p.to_string_lossy().to_string()), - item.file_mtime, - item.date_taken.as_ref().map(|d| d.to_rfc3339()), - item.latitude, - item.longitude, - item.camera_make, - item.camera_model, - item.rating, - item.perceptual_hash, - item.updated_at.to_rfc3339(), - ], - )?; - if changed == 0 { - return Err(PinakesError::NotFound(format!("media item {}", item.id))); - } - Ok(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - async fn delete_media(&self, id: MediaId) -> Result<()> { - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - let changed = db.execute( - "DELETE FROM media_items WHERE id = ?1", - params![id.0.to_string()], - )?; - if changed == 0 { - return Err(PinakesError::NotFound(format!("media item {id}"))); - } - Ok(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - async fn delete_all_media(&self) -> Result { - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - let count: u64 = - db.query_row("SELECT COUNT(*) FROM media_items", [], |row| row.get(0))?; - db.execute("DELETE FROM media_items", [])?; - Ok(count) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - // -- Tags -------------------------------------------------------------- - - async fn create_tag(&self, name: &str, parent_id: Option) -> Result { - let name = name.to_string(); - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - let id = Uuid::now_v7(); - let now = Utc::now(); - db.execute( - "INSERT INTO tags (id, name, parent_id, created_at) VALUES (?1, ?2, ?3, ?4)", - params![ - id.to_string(), - name, - parent_id.map(|p| p.to_string()), - now.to_rfc3339(), - ], - )?; - Ok(Tag { - id, - name, - parent_id, - created_at: now, - }) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - async fn get_tag(&self, id: Uuid) -> Result { - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = - db.prepare("SELECT id, name, parent_id, created_at FROM tags WHERE id = ?1")?; - stmt.query_row(params![id.to_string()], row_to_tag) - .map_err(|e| match e { - rusqlite::Error::QueryReturnedNoRows => { - PinakesError::TagNotFound(id.to_string()) - } - other => PinakesError::from(other), - }) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - async fn list_tags(&self) -> Result> { - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = - db.prepare("SELECT id, name, parent_id, created_at FROM tags ORDER BY name")?; - let rows = stmt - .query_map([], row_to_tag)? - .collect::>>()?; - Ok(rows) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - async fn delete_tag(&self, id: Uuid) -> Result<()> { - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - let changed = db.execute("DELETE FROM tags WHERE id = ?1", params![id.to_string()])?; - if changed == 0 { - return Err(PinakesError::TagNotFound(id.to_string())); - } - Ok(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - async fn tag_media(&self, media_id: MediaId, tag_id: Uuid) -> Result<()> { - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - db.execute( - "INSERT OR IGNORE INTO media_tags (media_id, tag_id) VALUES (?1, ?2)", - params![media_id.0.to_string(), tag_id.to_string()], - )?; - Ok(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - async fn untag_media(&self, media_id: MediaId, tag_id: Uuid) -> Result<()> { - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - db.execute( - "DELETE FROM media_tags WHERE media_id = ?1 AND tag_id = ?2", - params![media_id.0.to_string(), tag_id.to_string()], - )?; - Ok(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - async fn get_media_tags(&self, media_id: MediaId) -> Result> { - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "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", - )?; - let rows = stmt - .query_map(params![media_id.0.to_string()], row_to_tag)? - .collect::>>()?; - Ok(rows) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - async fn get_tag_descendants(&self, tag_id: Uuid) -> Result> { - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "WITH RECURSIVE descendants(id, name, parent_id, created_at) 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", - )?; - let rows = stmt - .query_map(params![tag_id.to_string()], row_to_tag)? - .collect::>>()?; - Ok(rows) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - // -- Collections ------------------------------------------------------- - - async fn create_collection( - &self, - name: &str, - kind: CollectionKind, - description: Option<&str>, - filter_query: Option<&str>, - ) -> Result { - let name = name.to_string(); - let description = description.map(|s| s.to_string()); - let filter_query = filter_query.map(|s| s.to_string()); - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - let id = Uuid::now_v7(); - let now = Utc::now(); - db.execute( - "INSERT INTO collections (id, name, description, kind, filter_query, \ - created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - params![ - id.to_string(), - name, - description, - collection_kind_to_str(kind), - filter_query, - now.to_rfc3339(), - now.to_rfc3339(), - ], - )?; - Ok(Collection { - id, - name, - description, - kind, - filter_query, - created_at: now, - updated_at: now, - }) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - async fn get_collection(&self, id: Uuid) -> Result { - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "SELECT id, name, description, kind, filter_query, created_at, updated_at \ - FROM collections WHERE id = ?1", - )?; - stmt.query_row(params![id.to_string()], row_to_collection) - .map_err(|e| match e { - rusqlite::Error::QueryReturnedNoRows => { - PinakesError::CollectionNotFound(id.to_string()) - } - other => PinakesError::from(other), - }) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - async fn list_collections(&self) -> Result> { - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "SELECT id, name, description, kind, filter_query, created_at, updated_at \ - FROM collections ORDER BY name", - )?; - let rows = stmt - .query_map([], row_to_collection)? - .collect::>>()?; - Ok(rows) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - async fn delete_collection(&self, id: Uuid) -> Result<()> { - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - let changed = db.execute( - "DELETE FROM collections WHERE id = ?1", - params![id.to_string()], - )?; - if changed == 0 { - return Err(PinakesError::CollectionNotFound(id.to_string())); - } - Ok(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - async fn add_to_collection( - &self, - collection_id: Uuid, - media_id: MediaId, - position: i32, - ) -> Result<()> { - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - let now = Utc::now(); - db.execute( - "INSERT OR REPLACE INTO collection_members \ - (collection_id, media_id, position, added_at) VALUES (?1, ?2, ?3, ?4)", - params![ - collection_id.to_string(), - media_id.0.to_string(), - position, - now.to_rfc3339(), - ], - )?; - Ok(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - async fn remove_from_collection(&self, collection_id: Uuid, media_id: MediaId) -> Result<()> { - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - db.execute( - "DELETE FROM collection_members WHERE collection_id = ?1 AND media_id = ?2", - params![collection_id.to_string(), media_id.0.to_string()], - )?; - Ok(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - async fn get_collection_members(&self, collection_id: Uuid) -> Result> { - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, \ - m.title, m.artist, m.album, m.genre, 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.storage_mode, m.original_filename, m.uploaded_at, m.storage_key, \ - m.created_at, m.updated_at, m.deleted_at, m.links_extracted_at \ - FROM media_items m \ - JOIN collection_members cm ON cm.media_id = m.id \ - WHERE cm.collection_id = ?1 \ - ORDER BY cm.position", - )?; - let mut rows = stmt - .query_map(params![collection_id.to_string()], row_to_media_item)? - .collect::>>()?; - load_custom_fields_batch(&db, &mut rows)?; - Ok(rows) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - // -- Search ------------------------------------------------------------ - - async fn search(&self, request: &SearchRequest) -> Result { - let request = request.clone(); - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - - let (fts_expr, _like_terms, where_clauses, join_clauses, bind_params) = - search_query_to_fts(&request.query); - - let use_fts = !fts_expr.is_empty(); - let order_by = sort_order_to_sql(&request.sort); - - // Build the base query. - let mut sql = String::from( - "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.storage_mode, m.original_filename, m.uploaded_at, m.storage_key, \ - m.created_at, m.updated_at, m.deleted_at, m.links_extracted_at \ - FROM media_items m ", - ); - - if use_fts { - sql.push_str("JOIN media_fts ON media_fts.rowid = m.rowid "); - } - - for j in &join_clauses { - sql.push_str(j); - sql.push(' '); - } - - // Collect all bind parameters: first the filter params, then FTS - // match (if any), then LIMIT and OFFSET. - let mut all_params: Vec = bind_params.clone(); - - let mut conditions = where_clauses.clone(); - if use_fts { - conditions.push("media_fts MATCH ?".to_string()); - all_params.push(fts_expr.clone()); - } - - if !conditions.is_empty() { - sql.push_str("WHERE "); - sql.push_str(&conditions.join(" AND ")); - sql.push(' '); - } - - sql.push_str(&format!("ORDER BY {order_by} LIMIT ? OFFSET ?",)); - all_params.push(request.pagination.limit.to_string()); - all_params.push(request.pagination.offset.to_string()); - - let mut stmt = db.prepare(&sql)?; - let param_refs: Vec<&dyn rusqlite::types::ToSql> = all_params - .iter() - .map(|s| s as &dyn rusqlite::types::ToSql) - .collect(); - let mut items = stmt - .query_map(param_refs.as_slice(), row_to_media_item)? - .collect::>>()?; - load_custom_fields_batch(&db, &mut items)?; - - // Count query (same filters, no LIMIT/OFFSET) - let mut count_sql = String::from("SELECT COUNT(*) FROM media_items m "); - if use_fts { - count_sql.push_str("JOIN media_fts ON media_fts.rowid = m.rowid "); - } - for j in &join_clauses { - count_sql.push_str(j); - count_sql.push(' '); - } - if !conditions.is_empty() { - count_sql.push_str("WHERE "); - count_sql.push_str(&conditions.join(" AND ")); - } - - // Count query uses the same filter params (+ FTS match) but no LIMIT/OFFSET - let mut count_params: Vec = bind_params; - if use_fts { - count_params.push(fts_expr); - } - let count_param_refs: Vec<&dyn rusqlite::types::ToSql> = count_params - .iter() - .map(|s| s as &dyn rusqlite::types::ToSql) - .collect(); - let total_count: i64 = - db.query_row(&count_sql, count_param_refs.as_slice(), |row| row.get(0))?; - - Ok(SearchResults { - items, - total_count: total_count as u64, - }) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - // -- Audit ------------------------------------------------------------- - - async fn record_audit(&self, entry: &AuditEntry) -> Result<()> { - let entry = entry.clone(); - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - db.execute( - "INSERT INTO audit_log (id, media_id, action, details, timestamp) \ - VALUES (?1, ?2, ?3, ?4, ?5)", - params![ - entry.id.to_string(), - entry.media_id.map(|mid| mid.0.to_string()), - entry.action.to_string(), - entry.details, - entry.timestamp.to_rfc3339(), - ], - )?; - Ok(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - async fn list_audit_entries( - &self, - media_id: Option, - pagination: &Pagination, - ) -> Result> { - let pagination = pagination.clone(); - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - - let (sql, bind_media_id) = if let Some(mid) = media_id { - ( - "SELECT id, media_id, action, details, timestamp FROM audit_log \ - WHERE media_id = ?1 ORDER BY timestamp DESC LIMIT ?2 OFFSET ?3" - .to_string(), - Some(mid.0.to_string()), - ) - } else { - ( - "SELECT id, media_id, action, details, timestamp FROM audit_log \ - ORDER BY timestamp DESC LIMIT ?1 OFFSET ?2" - .to_string(), - None, - ) - }; - - let mut stmt = db.prepare(&sql)?; - let rows = if let Some(ref mid_str) = bind_media_id { - stmt.query_map( - params![mid_str, pagination.limit as i64, pagination.offset as i64], - row_to_audit_entry, - )? - .collect::>>()? - } else { - stmt.query_map( - params![pagination.limit as i64, pagination.offset as i64], - row_to_audit_entry, - )? - .collect::>>()? - }; - - Ok(rows) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - // -- Custom fields ----------------------------------------------------- - - async fn set_custom_field( - &self, - media_id: MediaId, - name: &str, - field: &CustomField, - ) -> Result<()> { - let name = name.to_string(); - let field = field.clone(); - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| PinakesError::Database(e.to_string()))?; - db.execute( - "INSERT OR REPLACE INTO custom_fields (media_id, field_name, field_type, field_value) \ - VALUES (?1, ?2, ?3, ?4)", - params![ - media_id.0.to_string(), - name, - custom_field_type_to_str(field.field_type), - field.value, - ], - )?; - Ok(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - async fn get_custom_fields(&self, media_id: MediaId) -> Result> { - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "SELECT field_name, field_type, field_value FROM custom_fields WHERE media_id = ?1", - )?; - let rows = stmt.query_map(params![media_id.0.to_string()], |row| { - let name: String = row.get(0)?; - let ft_str: String = row.get(1)?; - let value: String = row.get(2)?; - Ok(( - name, - CustomField { - field_type: str_to_custom_field_type(&ft_str), - value, - }, - )) - })?; - - let mut map = HashMap::new(); - for r in rows { - let (name, field) = r?; - map.insert(name, field); - } - Ok(map) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - async fn delete_custom_field(&self, media_id: MediaId, name: &str) -> Result<()> { - let name = name.to_string(); - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - db.execute( - "DELETE FROM custom_fields WHERE media_id = ?1 AND field_name = ?2", - params![media_id.0.to_string(), name], - )?; - Ok(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - async fn batch_delete_media(&self, ids: &[MediaId]) -> Result { - if ids.is_empty() { - return Ok(0); + let media_ids: Vec = + media_ids.iter().map(|id| id.0.to_string()).collect(); + let tag_ids: Vec = + tag_ids.iter().map(|id| id.to_string()).collect(); + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute_batch("BEGIN IMMEDIATE")?; + // Prepare statement once for reuse + let mut stmt = db.prepare_cached( + "INSERT OR IGNORE INTO media_tags (media_id, tag_id) VALUES (?1, ?2)", + )?; + let mut count = 0u64; + for mid in &media_ids { + for tid in &tag_ids { + stmt.execute(params![mid, tid])?; + count += 1; } - let ids: Vec = ids.iter().map(|id| id.0.to_string()).collect(); - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - // Use IN clause for batch delete - much faster than individual deletes - // SQLite has a limit of ~500-1000 items in IN clause, so chunk if needed - const CHUNK_SIZE: usize = 500; - db.execute_batch("BEGIN IMMEDIATE")?; - let mut count = 0u64; - for chunk in ids.chunks(CHUNK_SIZE) { - let placeholders: Vec = - (1..=chunk.len()).map(|i| format!("?{}", i)).collect(); - let sql = format!( - "DELETE FROM media_items WHERE id IN ({})", - placeholders.join(", ") - ); - let params: Vec<&dyn rusqlite::ToSql> = - chunk.iter().map(|s| s as &dyn rusqlite::ToSql).collect(); - let rows = db.execute(&sql, params.as_slice())?; - count += rows as u64; - } - db.execute_batch("COMMIT")?; - Ok(count) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } + } + db.execute_batch("COMMIT")?; + Ok(count) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } - 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 media_ids: Vec = media_ids.iter().map(|id| id.0.to_string()).collect(); - let tag_ids: Vec = tag_ids.iter().map(|id| id.to_string()).collect(); - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - db.execute_batch("BEGIN IMMEDIATE")?; - // Prepare statement once for reuse - let mut stmt = db.prepare_cached( - "INSERT OR IGNORE INTO media_tags (media_id, tag_id) VALUES (?1, ?2)", - )?; - let mut count = 0u64; - for mid in &media_ids { - for tid in &tag_ids { - stmt.execute(params![mid, tid])?; - count += 1; - } - } - db.execute_batch("COMMIT")?; - Ok(count) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } + // -- Duplicates ----------------------------------------------------------- - // -- Duplicates ----------------------------------------------------------- - - async fn find_duplicates(&self) -> Result>> { - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "SELECT * FROM media_items WHERE content_hash IN ( - SELECT content_hash FROM media_items GROUP BY content_hash HAVING COUNT(*) > 1 + async fn find_duplicates(&self) -> Result>> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare( + "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", - )?; - let mut rows: Vec = stmt - .query_map([], row_to_media_item)? - .collect::>>()?; + )?; + let mut rows: Vec = stmt + .query_map([], row_to_media_item)? + .collect::>>()?; - load_custom_fields_batch(&db, &mut rows)?; + load_custom_fields_batch(&db, &mut rows)?; - // Group by content_hash - let mut groups: Vec> = Vec::new(); - let mut current_hash = String::new(); - for item in rows { - 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); - } + // Group by content_hash + let mut groups: Vec> = Vec::new(); + let mut current_hash = String::new(); + for item in rows { + 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) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn find_perceptual_duplicates( + &self, + threshold: u32, + ) -> Result>> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + + // Get all images with perceptual hashes + let mut stmt = db.prepare( + "SELECT * FROM media_items WHERE perceptual_hash IS NOT NULL ORDER BY \ + id", + )?; + let mut items: Vec = stmt + .query_map([], row_to_media_item)? + .collect::>>()?; + + load_custom_fields_batch(&db, &mut items)?; + + // 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, + }; - Ok(groups) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } + let mut group = vec![items[i].clone()]; + grouped_indices.insert(i); - async fn find_perceptual_duplicates(&self, threshold: u32) -> Result>> { - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; + for (j, item_j) in items.iter().enumerate().skip(i + 1) { + if grouped_indices.contains(&j) { + continue; + } - // Get all images with perceptual hashes - let mut stmt = db.prepare( - "SELECT * FROM media_items WHERE perceptual_hash IS NOT NULL ORDER BY id", - )?; - let mut items: Vec = stmt - .query_map([], row_to_media_item)? - .collect::>>()?; + let hash_b = match &item_j.perceptual_hash { + Some(h) => { + match ImageHash::>::from_base64(h) { + Ok(hash) => hash, + Err(_) => continue, + } + }, + None => continue, + }; - load_custom_fields_batch(&db, &mut items)?; + let distance = hash_a.dist(&hash_b); + if distance <= threshold { + group.push(item_j.clone()); + grouped_indices.insert(j); + } + } - // 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(); + // Only add groups with more than one item (actual duplicates) + if group.len() > 1 { + groups.push(group); + } + } - for i in 0..items.len() { - if grouped_indices.contains(&i) { - continue; - } + Ok(groups) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } - let hash_a = match &items[i].perceptual_hash { - Some(h) => match ImageHash::>::from_base64(h) { - Ok(hash) => hash, - Err(_) => continue, - }, - None => continue, - }; + // -- Database management ----------------------------------------------- - let mut group = vec![items[i].clone()]; - grouped_indices.insert(i); + async fn database_stats(&self) -> Result { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let media_count: i64 = + db.query_row("SELECT COUNT(*) FROM media_items", [], |row| row.get(0))?; + let tag_count: i64 = + db.query_row("SELECT COUNT(*) FROM tags", [], |row| row.get(0))?; + let collection_count: i64 = + db.query_row("SELECT COUNT(*) FROM collections", [], |row| row.get(0))?; + let audit_count: i64 = + db.query_row("SELECT COUNT(*) FROM audit_log", [], |row| row.get(0))?; + let page_count: i64 = + db.query_row("PRAGMA page_count", [], |row| row.get(0))?; + let page_size: i64 = + db.query_row("PRAGMA page_size", [], |row| row.get(0))?; + let database_size_bytes = (page_count * page_size) as u64; + 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, + backend_name: "sqlite".to_string(), + }) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } - for (j, item_j) in items.iter().enumerate().skip(i + 1) { - if grouped_indices.contains(&j) { - continue; - } + async fn vacuum(&self) -> Result<()> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute_batch("VACUUM")?; + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } - 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) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - // -- Database management ----------------------------------------------- - - async fn database_stats(&self) -> Result { - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - let media_count: i64 = - db.query_row("SELECT COUNT(*) FROM media_items", [], |row| row.get(0))?; - let tag_count: i64 = db.query_row("SELECT COUNT(*) FROM tags", [], |row| row.get(0))?; - let collection_count: i64 = - db.query_row("SELECT COUNT(*) FROM collections", [], |row| row.get(0))?; - let audit_count: i64 = - db.query_row("SELECT COUNT(*) FROM audit_log", [], |row| row.get(0))?; - let page_count: i64 = db.query_row("PRAGMA page_count", [], |row| row.get(0))?; - let page_size: i64 = db.query_row("PRAGMA page_size", [], |row| row.get(0))?; - let database_size_bytes = (page_count * page_size) as u64; - 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, - backend_name: "sqlite".to_string(), - }) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - async fn vacuum(&self) -> Result<()> { - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - db.execute_batch("VACUUM")?; - Ok(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - async fn clear_all_data(&self) -> Result<()> { - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - db.execute_batch( - "DELETE FROM audit_log; + async fn clear_all_data(&self) -> Result<()> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute_batch( + "DELETE FROM audit_log; DELETE FROM custom_fields; DELETE FROM collection_members; DELETE FROM media_tags; DELETE FROM media_items; DELETE FROM tags; DELETE FROM collections;", - )?; - Ok(()) + )?; + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn list_media_paths( + &self, + ) -> Result> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = + db.prepare("SELECT id, path, content_hash FROM media_items")?; + let rows = stmt.query_map([], |row| { + let id_str: String = row.get(0)?; + let path_str: String = row.get(1)?; + let hash_str: String = row.get(2)?; + let id = parse_uuid(&id_str)?; + Ok(( + MediaId(id), + PathBuf::from(path_str), + ContentHash::new(hash_str), + )) + })?; + let mut results = Vec::new(); + for row in rows { + results.push(row?); + } + Ok(results) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn save_search( + &self, + id: Uuid, + name: &str, + query: &str, + sort_order: Option<&str>, + ) -> Result<()> { + let conn = Arc::clone(&self.conn); + let id_str = id.to_string(); + let name = name.to_string(); + let query = query.to_string(); + let sort_order = sort_order.map(|s| s.to_string()); + let now = chrono::Utc::now().to_rfc3339(); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute( + "INSERT OR REPLACE INTO saved_searches (id, name, query, sort_order, \ + created_at) VALUES (?1, ?2, ?3, ?4, ?5)", + params![id_str, name, query, sort_order, now], + )?; + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn list_saved_searches( + &self, + ) -> Result> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare( + "SELECT id, name, query, sort_order, created_at FROM saved_searches \ + ORDER BY created_at DESC", + )?; + let rows = stmt.query_map([], |row| { + let id_str: String = row.get(0)?; + let name: String = row.get(1)?; + let query: String = row.get(2)?; + let sort_order: Option = row.get(3)?; + let created_at_str: String = row.get(4)?; + let id = parse_uuid(&id_str)?; + Ok(crate::model::SavedSearch { + id, + name, + query, + sort_order, + created_at: parse_datetime(&created_at_str), }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } + })?; + let mut results = Vec::new(); + for row in rows { + results.push(row?); + } + Ok(results) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } - async fn list_media_paths(&self) -> Result> { - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare("SELECT id, path, content_hash FROM media_items")?; - let rows = stmt.query_map([], |row| { - let id_str: String = row.get(0)?; - let path_str: String = row.get(1)?; - let hash_str: String = row.get(2)?; - let id = parse_uuid(&id_str)?; - Ok(( - MediaId(id), - PathBuf::from(path_str), - ContentHash::new(hash_str), - )) - })?; - let mut results = Vec::new(); - for row in rows { - results.push(row?); - } - Ok(results) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } + async fn delete_saved_search(&self, id: Uuid) -> Result<()> { + let conn = Arc::clone(&self.conn); + let id_str = id.to_string(); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute("DELETE FROM saved_searches WHERE id = ?1", params![id_str])?; + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + async fn list_media_ids_for_thumbnails( + &self, + only_missing: bool, + ) -> Result> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + 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 mut stmt = db.prepare(sql)?; + let ids: Vec = stmt + .query_map([], |r| { + let s: String = r.get(0)?; + Ok(MediaId(uuid::Uuid::parse_str(&s).unwrap_or_default())) + })? + .filter_map(|r| r.ok()) + .collect(); + Ok(ids) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } - async fn save_search( - &self, - id: Uuid, - name: &str, - query: &str, - sort_order: Option<&str>, - ) -> Result<()> { - let conn = Arc::clone(&self.conn); - let id_str = id.to_string(); - let name = name.to_string(); - let query = query.to_string(); - let sort_order = sort_order.map(|s| s.to_string()); - let now = chrono::Utc::now().to_rfc3339(); - tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| PinakesError::Database(e.to_string()))?; - db.execute( - "INSERT OR REPLACE INTO saved_searches (id, name, query, sort_order, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", - params![id_str, name, query, sort_order, now], - )?; - Ok(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } + async fn library_statistics(&self) -> Result { + let conn = Arc::clone(&self.conn); + let fut = tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; - async fn list_saved_searches(&self) -> Result> { - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare("SELECT id, name, query, sort_order, created_at FROM saved_searches ORDER BY created_at DESC")?; - let rows = stmt.query_map([], |row| { - let id_str: String = row.get(0)?; - let name: String = row.get(1)?; - let query: String = row.get(2)?; - let sort_order: Option = row.get(3)?; - let created_at_str: String = row.get(4)?; - let id = parse_uuid(&id_str)?; - Ok(crate::model::SavedSearch { - id, - name, - query, - sort_order, - created_at: parse_datetime(&created_at_str), - }) - })?; - let mut results = Vec::new(); - for row in rows { - results.push(row?); - } - Ok(results) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } + let total_media: u64 = + db.query_row("SELECT COUNT(*) FROM media_items", [], |r| r.get(0))?; + let total_size: u64 = db.query_row( + "SELECT COALESCE(SUM(file_size), 0) FROM media_items", + [], + |r| r.get(0), + )?; + let avg_size: u64 = total_size.checked_div(total_media).unwrap_or(0); - async fn delete_saved_search(&self, id: Uuid) -> Result<()> { - let conn = Arc::clone(&self.conn); - let id_str = id.to_string(); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - db.execute("DELETE FROM saved_searches WHERE id = ?1", params![id_str])?; - Ok(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } - async fn list_media_ids_for_thumbnails(&self, only_missing: bool) -> Result> { - let conn = Arc::clone(&self.conn); - tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; - 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 mut stmt = db.prepare(sql)?; - let ids: Vec = stmt - .query_map([], |r| { - let s: String = r.get(0)?; - Ok(MediaId(uuid::Uuid::parse_str(&s).unwrap_or_default())) - })? - .filter_map(|r| r.ok()) - .collect(); - Ok(ids) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } + // Media count by type + let mut stmt = db.prepare( + "SELECT media_type, COUNT(*) FROM media_items GROUP BY media_type \ + ORDER BY COUNT(*) DESC", + )?; + let media_by_type: Vec<(String, u64)> = stmt + .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, u64>(1)?)))? + .filter_map(|r| r.ok()) + .collect(); - async fn library_statistics(&self) -> Result { - let conn = Arc::clone(&self.conn); - let fut = tokio::task::spawn_blocking(move || { - let db = conn - .lock() - .map_err(|e| PinakesError::Database(e.to_string()))?; + // Storage by type + let mut stmt = db.prepare( + "SELECT media_type, COALESCE(SUM(file_size), 0) FROM media_items \ + GROUP BY media_type ORDER BY SUM(file_size) DESC", + )?; + let storage_by_type: Vec<(String, u64)> = stmt + .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, u64>(1)?)))? + .filter_map(|r| r.ok()) + .collect(); - let total_media: u64 = - db.query_row("SELECT COUNT(*) FROM media_items", [], |r| r.get(0))?; - let total_size: u64 = db.query_row( - "SELECT COALESCE(SUM(file_size), 0) FROM media_items", - [], - |r| r.get(0), - )?; - let avg_size: u64 = total_size.checked_div(total_media).unwrap_or(0); + // Newest / oldest + let newest: Option = db + .query_row( + "SELECT created_at FROM media_items ORDER BY created_at DESC LIMIT 1", + [], + |r| r.get(0), + ) + .optional()?; + let oldest: Option = db + .query_row( + "SELECT created_at FROM media_items ORDER BY created_at ASC LIMIT 1", + [], + |r| r.get(0), + ) + .optional()?; - // Media count by type - let mut stmt = db.prepare("SELECT media_type, COUNT(*) FROM media_items GROUP BY media_type ORDER BY COUNT(*) DESC")?; - let media_by_type: Vec<(String, u64)> = stmt - .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, u64>(1)?)))? - .filter_map(|r| r.ok()) - .collect(); + // Top tags + let mut stmt = db.prepare( + "SELECT t.name, COUNT(*) as cnt FROM media_tags mt JOIN tags t ON \ + mt.tag_id = t.id GROUP BY t.id ORDER BY cnt DESC LIMIT 10", + )?; + let top_tags: Vec<(String, u64)> = stmt + .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, u64>(1)?)))? + .filter_map(|r| r.ok()) + .collect(); - // Storage by type - let mut stmt = db.prepare("SELECT media_type, COALESCE(SUM(file_size), 0) FROM media_items GROUP BY media_type ORDER BY SUM(file_size) DESC")?; - let storage_by_type: Vec<(String, u64)> = stmt - .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, u64>(1)?)))? - .filter_map(|r| r.ok()) - .collect(); + // Top collections + let mut stmt = db.prepare( + "SELECT c.name, COUNT(*) as cnt FROM collection_members cm JOIN \ + collections c ON cm.collection_id = c.id GROUP BY c.id ORDER BY cnt \ + DESC LIMIT 10", + )?; + let top_collections: Vec<(String, u64)> = stmt + .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, u64>(1)?)))? + .filter_map(|r| r.ok()) + .collect(); - // Newest / oldest - let newest: Option = db - .query_row( - "SELECT created_at FROM media_items ORDER BY created_at DESC LIMIT 1", - [], - |r| r.get(0), - ) - .optional()?; - let oldest: Option = db - .query_row( - "SELECT created_at FROM media_items ORDER BY created_at ASC LIMIT 1", - [], - |r| r.get(0), - ) - .optional()?; + let total_tags: u64 = + db.query_row("SELECT COUNT(*) FROM tags", [], |r| r.get(0))?; + let total_collections: u64 = + db.query_row("SELECT COUNT(*) FROM collections", [], |r| r.get(0))?; - // Top tags - let mut stmt = db.prepare( - "SELECT t.name, COUNT(*) as cnt FROM media_tags mt JOIN tags t ON mt.tag_id = t.id GROUP BY t.id ORDER BY cnt DESC LIMIT 10" - )?; - let top_tags: Vec<(String, u64)> = stmt - .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, u64>(1)?)))? - .filter_map(|r| r.ok()) - .collect(); + // Duplicates: count of hashes that appear more than once + let total_duplicates: u64 = db.query_row( + "SELECT COUNT(*) FROM (SELECT content_hash FROM media_items GROUP BY \ + content_hash HAVING COUNT(*) > 1)", + [], + |r| r.get(0), + )?; - // Top collections - let mut stmt = db.prepare( - "SELECT c.name, COUNT(*) as cnt FROM collection_members cm JOIN collections c ON cm.collection_id = c.id GROUP BY c.id ORDER BY cnt DESC LIMIT 10" - )?; - let top_collections: Vec<(String, u64)> = stmt - .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, u64>(1)?)))? - .filter_map(|r| r.ok()) - .collect(); + Ok(super::LibraryStatistics { + total_media, + total_size_bytes: total_size, + avg_file_size_bytes: avg_size, + media_by_type, + storage_by_type, + newest_item: newest, + oldest_item: oldest, + top_tags, + top_collections, + total_tags, + total_collections, + total_duplicates, + }) + }); + tokio::time::timeout(std::time::Duration::from_secs(30), fut) + .await + .map_err(|_| { + PinakesError::Database("library_statistics query timed out".to_string()) + })? + .map_err(|e| PinakesError::Database(e.to_string()))? + } - let total_tags: u64 = db.query_row("SELECT COUNT(*) FROM tags", [], |r| r.get(0))?; - let total_collections: u64 = - db.query_row("SELECT COUNT(*) FROM collections", [], |r| r.get(0))?; - - // Duplicates: count of hashes that appear more than once - let total_duplicates: u64 = db.query_row( - "SELECT COUNT(*) FROM (SELECT content_hash FROM media_items GROUP BY content_hash HAVING COUNT(*) > 1)", - [], |r| r.get(0) - )?; - - Ok(super::LibraryStatistics { - total_media, - total_size_bytes: total_size, - avg_file_size_bytes: avg_size, - media_by_type, - storage_by_type, - newest_item: newest, - oldest_item: oldest, - top_tags, - top_collections, - total_tags, - total_collections, - total_duplicates, - }) - }); - tokio::time::timeout(std::time::Duration::from_secs(30), fut) - .await - .map_err(|_| PinakesError::Database("library_statistics query timed out".to_string()))? - .map_err(|e| PinakesError::Database(e.to_string()))? - } - - async fn list_users(&self) -> Result> { - let conn = self.conn.clone(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - let mut stmt = db.prepare( - "SELECT id, username, password_hash, role, created_at, updated_at FROM users ORDER BY created_at DESC" - )?; - let users = stmt - .query_map([], |row| { - let id_str: String = row.get(0)?; - let profile = load_user_profile_sync(&db, &id_str)?; - Ok(crate::users::User { - id: crate::users::UserId(parse_uuid(&id_str)?), - username: row.get(1)?, - password_hash: row.get(2)?, - role: serde_json::from_str(&row.get::<_, String>(3)?) - .unwrap_or(crate::config::UserRole::Viewer), - profile, - created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?) - .unwrap_or_else(|_| chrono::Utc::now().into()) - .with_timezone(&chrono::Utc), - updated_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(5)?) - .unwrap_or_else(|_| chrono::Utc::now().into()) - .with_timezone(&chrono::Utc), - }) - })? - .collect::, _>>()?; - Ok(users) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("list_users query timed out".to_string()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } - - async fn get_user(&self, id: crate::users::UserId) -> Result { - let conn = self.conn.clone(); - let id_str = id.0.to_string(); - let id_str_for_err = id_str.clone(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - let opt = db.query_row( - "SELECT id, username, password_hash, role, created_at, updated_at FROM users WHERE id = ?", - [&id_str], - |row| { - let id_str: String = row.get(0)?; - let profile = load_user_profile_sync(&db, &id_str)?; - Ok(crate::users::User { - id: crate::users::UserId(parse_uuid(&id_str)?), - username: row.get(1)?, - password_hash: row.get(2)?, - role: serde_json::from_str(&row.get::<_, String>(3)?).unwrap_or(crate::config::UserRole::Viewer), - profile, - created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?) - .unwrap_or_else(|_| chrono::Utc::now().into()) - .with_timezone(&chrono::Utc), - updated_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(5)?) - .unwrap_or_else(|_| chrono::Utc::now().into()) - .with_timezone(&chrono::Utc), - }) - } - ).optional()?; - opt.ok_or_else(|| PinakesError::NotFound(format!("user {}", id_str))) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| { - PinakesError::Database(format!("get_user query timed out for {}", id_str_for_err)) - })? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } - - async fn get_user_by_username(&self, username: &str) -> Result { - let conn = self.conn.clone(); - let username = username.to_string(); - let username_for_err = username.clone(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - let opt = db.query_row( - "SELECT id, username, password_hash, role, created_at, updated_at FROM users WHERE username = ?", - [&username], - |row| { - let id_str: String = row.get(0)?; - let profile = load_user_profile_sync(&db, &id_str)?; - Ok(crate::users::User { - id: crate::users::UserId(parse_uuid(&id_str)?), - username: row.get(1)?, - password_hash: row.get(2)?, - role: serde_json::from_str(&row.get::<_, String>(3)?).unwrap_or(crate::config::UserRole::Viewer), - profile, - created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?) - .unwrap_or_else(|_| chrono::Utc::now().into()) - .with_timezone(&chrono::Utc), - updated_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(5)?) - .unwrap_or_else(|_| chrono::Utc::now().into()) - .with_timezone(&chrono::Utc), - }) - } - ).optional()?; - opt.ok_or_else(|| PinakesError::NotFound(format!("user with username {}", username))) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| { - PinakesError::Database(format!( - "get_user_by_username query timed out for {}", - username_for_err - )) - })? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } - - async fn create_user( - &self, - username: &str, - password_hash: &str, - role: crate::config::UserRole, - profile: Option, - ) -> Result { - let conn = self.conn.clone(); - let username = username.to_string(); - let password_hash = password_hash.to_string(); - let fut = tokio::task::spawn_blocking(move || -> Result { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - - let tx = db.unchecked_transaction()?; - - let id = crate::users::UserId(uuid::Uuid::now_v7()); - let id_str = id.0.to_string(); - let now = chrono::Utc::now(); - let role_str = serde_json::to_string(&role) - .map_err(|e| PinakesError::Database(format!("failed to serialize role: {}", e)))?; - - tx.execute( - "INSERT INTO users (id, username, password_hash, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", - rusqlite::params![&id_str, &username, &password_hash, &role_str, now.to_rfc3339(), now.to_rfc3339()], - )?; - - let user_profile = if let Some(prof) = profile.clone() { - let prefs_json = serde_json::to_string(&prof.preferences).map_err(|e| { - PinakesError::Database(format!("failed to serialize preferences: {}", e)) - })?; - tx.execute( - "INSERT INTO user_profiles (user_id, avatar_path, bio, preferences_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", - rusqlite::params![&id_str, &prof.avatar_path, &prof.bio, &prefs_json, now.to_rfc3339(), now.to_rfc3339()], - )?; - prof - } else { - crate::users::UserProfile { - avatar_path: None, - bio: None, - preferences: Default::default(), - } - }; - - tx.commit()?; + async fn list_users(&self) -> Result> { + let conn = self.conn.clone(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + let mut stmt = db.prepare( + "SELECT id, username, password_hash, role, created_at, updated_at \ + FROM users ORDER BY created_at DESC", + )?; + let users = stmt + .query_map([], |row| { + let id_str: String = row.get(0)?; + let profile = load_user_profile_sync(&db, &id_str)?; + Ok(crate::users::User { + id: crate::users::UserId(parse_uuid(&id_str)?), + username: row.get(1)?, + password_hash: row.get(2)?, + role: serde_json::from_str(&row.get::<_, String>(3)?) + .unwrap_or(crate::config::UserRole::Viewer), + profile, + created_at: chrono::DateTime::parse_from_rfc3339( + &row.get::<_, String>(4)?, + ) + .unwrap_or_else(|_| chrono::Utc::now().into()) + .with_timezone(&chrono::Utc), + updated_at: chrono::DateTime::parse_from_rfc3339( + &row.get::<_, String>(5)?, + ) + .unwrap_or_else(|_| chrono::Utc::now().into()) + .with_timezone(&chrono::Utc), + }) + })? + .collect::, _>>()?; + Ok(users) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("list_users query timed out".to_string()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } + async fn get_user( + &self, + id: crate::users::UserId, + ) -> Result { + let conn = self.conn.clone(); + let id_str = id.0.to_string(); + let id_str_for_err = id_str.clone(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + let opt = db + .query_row( + "SELECT id, username, password_hash, role, created_at, updated_at \ + FROM users WHERE id = ?", + [&id_str], + |row| { + let id_str: String = row.get(0)?; + let profile = load_user_profile_sync(&db, &id_str)?; Ok(crate::users::User { - id, - username, - password_hash, - role, - profile: user_profile, - created_at: now, - updated_at: now, + id: crate::users::UserId(parse_uuid(&id_str)?), + username: row.get(1)?, + password_hash: row.get(2)?, + role: serde_json::from_str(&row.get::<_, String>(3)?) + .unwrap_or(crate::config::UserRole::Viewer), + profile, + created_at: chrono::DateTime::parse_from_rfc3339( + &row.get::<_, String>(4)?, + ) + .unwrap_or_else(|_| chrono::Utc::now().into()) + .with_timezone(&chrono::Utc), + updated_at: chrono::DateTime::parse_from_rfc3339( + &row.get::<_, String>(5)?, + ) + .unwrap_or_else(|_| chrono::Utc::now().into()) + .with_timezone(&chrono::Utc), }) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("create_user query timed out".to_string()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + }, + ) + .optional()?; + opt.ok_or_else(|| PinakesError::NotFound(format!("user {}", id_str))) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database(format!( + "get_user query timed out for {}", + id_str_for_err + )) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn update_user( - &self, - id: crate::users::UserId, - password_hash: Option<&str>, - role: Option, - profile: Option, - ) -> Result { - let conn = self.conn.clone(); - let password_hash = password_hash.map(|s| s.to_string()); + async fn get_user_by_username( + &self, + username: &str, + ) -> Result { + let conn = self.conn.clone(); + let username = username.to_string(); + let username_for_err = username.clone(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + let opt = db + .query_row( + "SELECT id, username, password_hash, role, created_at, updated_at \ + FROM users WHERE username = ?", + [&username], + |row| { + let id_str: String = row.get(0)?; + let profile = load_user_profile_sync(&db, &id_str)?; + Ok(crate::users::User { + id: crate::users::UserId(parse_uuid(&id_str)?), + username: row.get(1)?, + password_hash: row.get(2)?, + role: serde_json::from_str(&row.get::<_, String>(3)?) + .unwrap_or(crate::config::UserRole::Viewer), + profile, + created_at: chrono::DateTime::parse_from_rfc3339( + &row.get::<_, String>(4)?, + ) + .unwrap_or_else(|_| chrono::Utc::now().into()) + .with_timezone(&chrono::Utc), + updated_at: chrono::DateTime::parse_from_rfc3339( + &row.get::<_, String>(5)?, + ) + .unwrap_or_else(|_| chrono::Utc::now().into()) + .with_timezone(&chrono::Utc), + }) + }, + ) + .optional()?; + opt.ok_or_else(|| { + PinakesError::NotFound(format!("user with username {}", username)) + }) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database(format!( + "get_user_by_username query timed out for {}", + username_for_err + )) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } + + async fn create_user( + &self, + username: &str, + password_hash: &str, + role: crate::config::UserRole, + profile: Option, + ) -> Result { + let conn = self.conn.clone(); + let username = username.to_string(); + let password_hash = password_hash.to_string(); + let fut = + tokio::task::spawn_blocking(move || -> Result { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + + let tx = db.unchecked_transaction()?; + + let id = crate::users::UserId(uuid::Uuid::now_v7()); let id_str = id.0.to_string(); - let fut = tokio::task::spawn_blocking(move || -> Result { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) + let now = chrono::Utc::now(); + let role_str = serde_json::to_string(&role).map_err(|e| { + PinakesError::Database(format!("failed to serialize role: {}", e)) + })?; + + tx.execute( + "INSERT INTO users (id, username, password_hash, role, created_at, \ + updated_at) VALUES (?, ?, ?, ?, ?, ?)", + rusqlite::params![ + &id_str, + &username, + &password_hash, + &role_str, + now.to_rfc3339(), + now.to_rfc3339() + ], + )?; + + let user_profile = if let Some(prof) = profile.clone() { + let prefs_json = + serde_json::to_string(&prof.preferences).map_err(|e| { + PinakesError::Database(format!( + "failed to serialize preferences: {}", + e + )) })?; + tx.execute( + "INSERT INTO user_profiles (user_id, avatar_path, bio, \ + preferences_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, \ + ?)", + rusqlite::params![ + &id_str, + &prof.avatar_path, + &prof.bio, + &prefs_json, + now.to_rfc3339(), + now.to_rfc3339() + ], + )?; + prof + } else { + crate::users::UserProfile { + avatar_path: None, + bio: None, + preferences: Default::default(), + } + }; - let tx = db.unchecked_transaction()?; - let now = chrono::Utc::now(); + tx.commit()?; - // Update password and/or role if provided - if password_hash.is_some() || role.is_some() { - let mut updates = vec!["updated_at = ?"]; - let mut params: Vec> = Vec::new(); - params.push(Box::new(now.to_rfc3339())); + Ok(crate::users::User { + id, + username, + password_hash, + role, + profile: user_profile, + created_at: now, + updated_at: now, + }) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("create_user query timed out".to_string()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - if let Some(ref pw) = password_hash { - updates.push("password_hash = ?"); - params.push(Box::new(pw.clone())); - } - if let Some(ref r) = role { - updates.push("role = ?"); - let role_str = serde_json::to_string(r).map_err(|e| { - PinakesError::Database(format!("failed to serialize role: {}", e)) - })?; - params.push(Box::new(role_str)); - } + async fn update_user( + &self, + id: crate::users::UserId, + password_hash: Option<&str>, + role: Option, + profile: Option, + ) -> Result { + let conn = self.conn.clone(); + let password_hash = password_hash.map(|s| s.to_string()); + let id_str = id.0.to_string(); + let fut = + tokio::task::spawn_blocking(move || -> Result { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; - params.push(Box::new(id_str.clone())); + let tx = db.unchecked_transaction()?; + let now = chrono::Utc::now(); - let sql = format!("UPDATE users SET {} WHERE id = ?", updates.join(", ")); - let param_refs: Vec<&dyn rusqlite::ToSql> = - params.iter().map(|p| p.as_ref()).collect(); - tx.execute(&sql, param_refs.as_slice())?; - } + // Update password and/or role if provided + if password_hash.is_some() || role.is_some() { + let mut updates = vec!["updated_at = ?"]; + let mut params: Vec> = Vec::new(); + params.push(Box::new(now.to_rfc3339())); - // Update profile if provided - if let Some(prof) = profile { - let prefs_json = serde_json::to_string(&prof.preferences).map_err(|e| { - PinakesError::Database(format!("failed to serialize preferences: {}", e)) - })?; - tx.execute( - "INSERT OR REPLACE INTO user_profiles (user_id, avatar_path, bio, preferences_json, created_at, updated_at) VALUES (?, ?, ?, ?, COALESCE((SELECT created_at FROM user_profiles WHERE user_id = ?), ?), ?)", - rusqlite::params![&id_str, &prof.avatar_path, &prof.bio, &prefs_json, &id_str, now.to_rfc3339(), now.to_rfc3339()], - )?; - } - - tx.commit()?; - - // Fetch updated user - Ok(db.query_row( - "SELECT id, username, password_hash, role, created_at, updated_at FROM users WHERE id = ?", - [&id_str], - |row| { - let id_str: String = row.get(0)?; - let profile = load_user_profile_sync(&db, &id_str)?; - Ok(crate::users::User { - id: crate::users::UserId(parse_uuid(&id_str)?), - username: row.get(1)?, - password_hash: row.get(2)?, - role: serde_json::from_str(&row.get::<_, String>(3)?).unwrap_or(crate::config::UserRole::Viewer), - profile, - created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?) - .unwrap_or_else(|_| chrono::Utc::now().into()) - .with_timezone(&chrono::Utc), - updated_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(5)?) - .unwrap_or_else(|_| chrono::Utc::now().into()) - .with_timezone(&chrono::Utc), - }) - } - )?) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("update_user query timed out".to_string()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } - - async fn delete_user(&self, id: crate::users::UserId) -> Result<()> { - let conn = self.conn.clone(); - let id_str = id.0.to_string(); - let fut = tokio::task::spawn_blocking(move || -> Result<()> { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) + if let Some(ref pw) = password_hash { + updates.push("password_hash = ?"); + params.push(Box::new(pw.clone())); + } + if let Some(ref r) = role { + updates.push("role = ?"); + let role_str = serde_json::to_string(r).map_err(|e| { + PinakesError::Database(format!("failed to serialize role: {}", e)) })?; + params.push(Box::new(role_str)); + } - let tx = db.unchecked_transaction()?; + params.push(Box::new(id_str.clone())); - // Delete profile first due to foreign key - tx.execute("DELETE FROM user_profiles WHERE user_id = ?", [&id_str])?; - // Delete library access - tx.execute("DELETE FROM user_libraries WHERE user_id = ?", [&id_str])?; - // Delete user - let affected = tx.execute("DELETE FROM users WHERE id = ?", [&id_str])?; - if affected == 0 { - return Err(PinakesError::NotFound(format!("user {}", id_str))); - } + let sql = + format!("UPDATE users SET {} WHERE id = ?", updates.join(", ")); + let param_refs: Vec<&dyn rusqlite::ToSql> = + params.iter().map(|p| p.as_ref()).collect(); + tx.execute(&sql, param_refs.as_slice())?; + } - tx.commit()?; - Ok(()) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("delete_user query timed out".to_string()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } - - async fn get_user_libraries( - &self, - user_id: crate::users::UserId, - ) -> Result> { - let conn = self.conn.clone(); - let user_id_str = user_id.0.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) + // Update profile if provided + if let Some(prof) = profile { + let prefs_json = + serde_json::to_string(&prof.preferences).map_err(|e| { + PinakesError::Database(format!( + "failed to serialize preferences: {}", + e + )) })?; - let mut stmt = db.prepare( - "SELECT user_id, root_path, permission, granted_at FROM user_libraries WHERE user_id = ?" - )?; - let libraries = stmt - .query_map([&user_id_str], |row| { - let id_str: String = row.get(0)?; - Ok(crate::users::UserLibraryAccess { - user_id: crate::users::UserId(parse_uuid(&id_str)?), - root_path: row.get(1)?, - permission: serde_json::from_str(&row.get::<_, String>(2)?) - .unwrap_or(crate::users::LibraryPermission::Read), - granted_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(3)?) - .unwrap_or_else(|_| chrono::Utc::now().into()) - .with_timezone(&chrono::Utc), - }) - })? - .filter_map(|r| r.ok()) - .collect::>(); - Ok(libraries) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("get_user_libraries query timed out".to_string()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + tx.execute( + "INSERT OR REPLACE INTO user_profiles (user_id, avatar_path, bio, \ + preferences_json, created_at, updated_at) VALUES (?, ?, ?, ?, \ + COALESCE((SELECT created_at FROM user_profiles WHERE user_id = \ + ?), ?), ?)", + rusqlite::params![ + &id_str, + &prof.avatar_path, + &prof.bio, + &prefs_json, + &id_str, + now.to_rfc3339(), + now.to_rfc3339() + ], + )?; + } - async fn grant_library_access( - &self, - user_id: crate::users::UserId, - root_path: &str, - permission: crate::users::LibraryPermission, - ) -> Result<()> { - let conn = self.conn.clone(); - let root_path = root_path.to_string(); - let user_id_str = user_id.0.to_string(); - let fut = tokio::task::spawn_blocking(move || -> Result<()> { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - let perm_str = serde_json::to_string(&permission).map_err(|e| { - PinakesError::Database(format!("failed to serialize permission: {}", e)) - })?; - let now = chrono::Utc::now(); - db.execute( - "INSERT OR REPLACE INTO user_libraries (user_id, root_path, permission, granted_at) VALUES (?, ?, ?, ?)", - rusqlite::params![&user_id_str, &root_path, &perm_str, now.to_rfc3339()], - )?; - Ok(()) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| { - PinakesError::Database("grant_library_access query timed out".to_string()) - })? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + tx.commit()?; - async fn revoke_library_access( - &self, - user_id: crate::users::UserId, - root_path: &str, - ) -> Result<()> { - let conn = self.conn.clone(); - let root_path = root_path.to_string(); - let user_id_str = user_id.0.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - db.execute( - "DELETE FROM user_libraries WHERE user_id = ? AND root_path = ?", - rusqlite::params![&user_id_str, &root_path], - )?; - Ok(()) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| { - PinakesError::Database("revoke_library_access query timed out".to_string()) - })? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + // Fetch updated user + Ok(db.query_row( + "SELECT id, username, password_hash, role, created_at, updated_at \ + FROM users WHERE id = ?", + [&id_str], + |row| { + let id_str: String = row.get(0)?; + let profile = load_user_profile_sync(&db, &id_str)?; + Ok(crate::users::User { + id: crate::users::UserId(parse_uuid(&id_str)?), + username: row.get(1)?, + password_hash: row.get(2)?, + role: serde_json::from_str(&row.get::<_, String>(3)?) + .unwrap_or(crate::config::UserRole::Viewer), + profile, + created_at: chrono::DateTime::parse_from_rfc3339( + &row.get::<_, String>(4)?, + ) + .unwrap_or_else(|_| chrono::Utc::now().into()) + .with_timezone(&chrono::Utc), + updated_at: chrono::DateTime::parse_from_rfc3339( + &row.get::<_, String>(5)?, + ) + .unwrap_or_else(|_| chrono::Utc::now().into()) + .with_timezone(&chrono::Utc), + }) + }, + )?) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("update_user query timed out".to_string()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - // ===== Ratings ===== - async fn rate_media( - &self, - user_id: crate::users::UserId, - media_id: MediaId, - stars: u8, - review: Option<&str>, - ) -> Result { - let conn = self.conn.clone(); - let user_id_str = user_id.0.to_string(); - let media_id_str = media_id.0.to_string(); - let review = review.map(String::from); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - let id = Uuid::now_v7(); - let id_str = id.to_string(); - let now = chrono::Utc::now(); - db.execute( - "INSERT OR REPLACE INTO ratings (id, user_id, media_id, stars, review_text, created_at) VALUES (?, ?, ?, ?, ?, ?)", - params![&id_str, &user_id_str, &media_id_str, stars as i32, &review, now.to_rfc3339()], - )?; - // SELECT the actual row to get the real id and created_at (INSERT OR REPLACE may have kept existing values) - let (actual_id, actual_created_at) = db.query_row( - "SELECT id, created_at FROM ratings WHERE user_id = ? AND media_id = ?", - params![&user_id_str, &media_id_str], - |row| { - let rid_str: String = row.get(0)?; - let created_str: String = row.get(1)?; - Ok((parse_uuid(&rid_str)?, parse_datetime(&created_str))) - }, - )?; + async fn delete_user(&self, id: crate::users::UserId) -> Result<()> { + let conn = self.conn.clone(); + let id_str = id.0.to_string(); + let fut = tokio::task::spawn_blocking(move || -> Result<()> { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + + let tx = db.unchecked_transaction()?; + + // Delete profile first due to foreign key + tx.execute("DELETE FROM user_profiles WHERE user_id = ?", [&id_str])?; + // Delete library access + tx.execute("DELETE FROM user_libraries WHERE user_id = ?", [&id_str])?; + // Delete user + let affected = tx.execute("DELETE FROM users WHERE id = ?", [&id_str])?; + if affected == 0 { + return Err(PinakesError::NotFound(format!("user {}", id_str))); + } + + tx.commit()?; + Ok(()) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("delete_user query timed out".to_string()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } + + async fn get_user_libraries( + &self, + user_id: crate::users::UserId, + ) -> Result> { + let conn = self.conn.clone(); + let user_id_str = user_id.0.to_string(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + let mut stmt = db.prepare( + "SELECT user_id, root_path, permission, granted_at FROM \ + user_libraries WHERE user_id = ?", + )?; + let libraries = stmt + .query_map([&user_id_str], |row| { + let id_str: String = row.get(0)?; + Ok(crate::users::UserLibraryAccess { + user_id: crate::users::UserId(parse_uuid(&id_str)?), + root_path: row.get(1)?, + permission: serde_json::from_str(&row.get::<_, String>(2)?) + .unwrap_or(crate::users::LibraryPermission::Read), + granted_at: chrono::DateTime::parse_from_rfc3339( + &row.get::<_, String>(3)?, + ) + .unwrap_or_else(|_| chrono::Utc::now().into()) + .with_timezone(&chrono::Utc), + }) + })? + .filter_map(|r| r.ok()) + .collect::>(); + Ok(libraries) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("get_user_libraries query timed out".to_string()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } + + async fn grant_library_access( + &self, + user_id: crate::users::UserId, + root_path: &str, + permission: crate::users::LibraryPermission, + ) -> Result<()> { + let conn = self.conn.clone(); + let root_path = root_path.to_string(); + let user_id_str = user_id.0.to_string(); + let fut = tokio::task::spawn_blocking(move || -> Result<()> { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + let perm_str = serde_json::to_string(&permission).map_err(|e| { + PinakesError::Database(format!("failed to serialize permission: {}", e)) + })?; + let now = chrono::Utc::now(); + db.execute( + "INSERT OR REPLACE INTO user_libraries (user_id, root_path, \ + permission, granted_at) VALUES (?, ?, ?, ?)", + rusqlite::params![ + &user_id_str, + &root_path, + &perm_str, + now.to_rfc3339() + ], + )?; + Ok(()) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database( + "grant_library_access query timed out".to_string(), + ) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } + + async fn revoke_library_access( + &self, + user_id: crate::users::UserId, + root_path: &str, + ) -> Result<()> { + let conn = self.conn.clone(); + let root_path = root_path.to_string(); + let user_id_str = user_id.0.to_string(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + db.execute( + "DELETE FROM user_libraries WHERE user_id = ? AND root_path = ?", + rusqlite::params![&user_id_str, &root_path], + )?; + Ok(()) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database( + "revoke_library_access query timed out".to_string(), + ) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } + + // ===== Ratings ===== + async fn rate_media( + &self, + user_id: crate::users::UserId, + media_id: MediaId, + stars: u8, + review: Option<&str>, + ) -> Result { + let conn = self.conn.clone(); + let user_id_str = user_id.0.to_string(); + let media_id_str = media_id.0.to_string(); + let review = review.map(String::from); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + let id = Uuid::now_v7(); + let id_str = id.to_string(); + let now = chrono::Utc::now(); + db.execute( + "INSERT OR REPLACE INTO ratings (id, user_id, media_id, stars, \ + review_text, created_at) VALUES (?, ?, ?, ?, ?, ?)", + params![ + &id_str, + &user_id_str, + &media_id_str, + stars as i32, + &review, + now.to_rfc3339() + ], + )?; + // SELECT the actual row to get the real id and created_at (INSERT OR + // REPLACE may have kept existing values) + let (actual_id, actual_created_at) = db.query_row( + "SELECT id, created_at FROM ratings WHERE user_id = ? AND media_id = ?", + params![&user_id_str, &media_id_str], + |row| { + let rid_str: String = row.get(0)?; + let created_str: String = row.get(1)?; + Ok((parse_uuid(&rid_str)?, parse_datetime(&created_str))) + }, + )?; + Ok(crate::social::Rating { + id: actual_id, + user_id, + media_id, + stars, + review_text: review, + created_at: actual_created_at, + }) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| PinakesError::Database("rate_media timed out".into()))? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } + + async fn get_media_ratings( + &self, + media_id: MediaId, + ) -> Result> { + let conn = self.conn.clone(); + let media_id_str = media_id.0.to_string(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + let mut stmt = db.prepare( + "SELECT id, user_id, media_id, stars, review_text, created_at FROM \ + ratings WHERE media_id = ? ORDER BY created_at DESC", + )?; + let ratings = stmt + .query_map([&media_id_str], |row| { + let id_str: String = row.get(0)?; + let uid_str: String = row.get(1)?; + let mid_str: String = row.get(2)?; + let created_str: String = row.get(5)?; + Ok(crate::social::Rating { + id: parse_uuid(&id_str)?, + user_id: crate::users::UserId(parse_uuid(&uid_str)?), + media_id: MediaId(parse_uuid(&mid_str)?), + stars: row.get::<_, i32>(3)? as u8, + review_text: row.get(4)?, + created_at: parse_datetime(&created_str), + }) + })? + .filter_map(|r| r.ok()) + .collect(); + Ok(ratings) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("get_media_ratings timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } + + async fn get_user_rating( + &self, + user_id: crate::users::UserId, + media_id: MediaId, + ) -> Result> { + let conn = self.conn.clone(); + let user_id_str = user_id.0.to_string(); + let media_id_str = media_id.0.to_string(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + let result = db + .query_row( + "SELECT id, user_id, media_id, stars, review_text, created_at FROM \ + ratings WHERE user_id = ? AND media_id = ?", + params![&user_id_str, &media_id_str], + |row| { + let id_str: String = row.get(0)?; + let uid_str: String = row.get(1)?; + let mid_str: String = row.get(2)?; + let created_str: String = row.get(5)?; Ok(crate::social::Rating { - id: actual_id, - user_id, - media_id, - stars, - review_text: review, - created_at: actual_created_at, + id: parse_uuid(&id_str)?, + user_id: crate::users::UserId(parse_uuid(&uid_str)?), + media_id: MediaId(parse_uuid(&mid_str)?), + stars: row.get::<_, i32>(3)? as u8, + review_text: row.get(4)?, + created_at: parse_datetime(&created_str), }) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("rate_media timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + }, + ) + .optional()?; + Ok(result) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| PinakesError::Database("get_user_rating timed out".into()))? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn get_media_ratings(&self, media_id: MediaId) -> Result> { - let conn = self.conn.clone(); - let media_id_str = media_id.0.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - let mut stmt = db.prepare( - "SELECT id, user_id, media_id, stars, review_text, created_at FROM ratings WHERE media_id = ? ORDER BY created_at DESC" - )?; - let ratings = stmt - .query_map([&media_id_str], |row| { - let id_str: String = row.get(0)?; - let uid_str: String = row.get(1)?; - let mid_str: String = row.get(2)?; - let created_str: String = row.get(5)?; - Ok(crate::social::Rating { - id: parse_uuid(&id_str)?, - user_id: crate::users::UserId(parse_uuid(&uid_str)?), - media_id: MediaId(parse_uuid(&mid_str)?), - stars: row.get::<_, i32>(3)? as u8, - review_text: row.get(4)?, - created_at: parse_datetime(&created_str), - }) - })? - .filter_map(|r| r.ok()) - .collect(); - Ok(ratings) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("get_media_ratings timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + async fn delete_rating(&self, id: Uuid) -> Result<()> { + let conn = self.conn.clone(); + let id_str = id.to_string(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + db.execute("DELETE FROM ratings WHERE id = ?", [&id_str])?; + Ok(()) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| PinakesError::Database("delete_rating timed out".into()))? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn get_user_rating( - &self, - user_id: crate::users::UserId, - media_id: MediaId, - ) -> Result> { - let conn = self.conn.clone(); - let user_id_str = user_id.0.to_string(); - let media_id_str = media_id.0.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - let result = db.query_row( - "SELECT id, user_id, media_id, stars, review_text, created_at FROM ratings WHERE user_id = ? AND media_id = ?", - params![&user_id_str, &media_id_str], - |row| { - let id_str: String = row.get(0)?; - let uid_str: String = row.get(1)?; - let mid_str: String = row.get(2)?; - let created_str: String = row.get(5)?; - Ok(crate::social::Rating { - id: parse_uuid(&id_str)?, - user_id: crate::users::UserId(parse_uuid(&uid_str)?), - media_id: MediaId(parse_uuid(&mid_str)?), - stars: row.get::<_, i32>(3)? as u8, - review_text: row.get(4)?, - created_at: parse_datetime(&created_str), - }) - }, - ).optional()?; - Ok(result) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("get_user_rating timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + // ===== Comments ===== + async fn add_comment( + &self, + user_id: crate::users::UserId, + media_id: MediaId, + text: &str, + parent_id: Option, + ) -> Result { + let conn = self.conn.clone(); + let user_id_str = user_id.0.to_string(); + let media_id_str = media_id.0.to_string(); + let text = text.to_string(); + let parent_str = parent_id.map(|p| p.to_string()); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + let id = Uuid::now_v7(); + let id_str = id.to_string(); + let now = chrono::Utc::now(); + db.execute( + "INSERT INTO comments (id, user_id, media_id, parent_comment_id, \ + text, created_at) VALUES (?, ?, ?, ?, ?, ?)", + params![ + &id_str, + &user_id_str, + &media_id_str, + &parent_str, + &text, + now.to_rfc3339() + ], + )?; + Ok(crate::social::Comment { + id, + user_id, + media_id, + parent_comment_id: parent_id, + text, + created_at: now, + }) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| PinakesError::Database("add_comment timed out".into()))? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn delete_rating(&self, id: Uuid) -> Result<()> { - let conn = self.conn.clone(); - let id_str = id.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - db.execute("DELETE FROM ratings WHERE id = ?", [&id_str])?; - Ok(()) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("delete_rating timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + async fn get_media_comments( + &self, + media_id: MediaId, + ) -> Result> { + let conn = self.conn.clone(); + let media_id_str = media_id.0.to_string(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + let mut stmt = db.prepare( + "SELECT id, user_id, media_id, parent_comment_id, text, created_at \ + FROM comments WHERE media_id = ? ORDER BY created_at ASC", + )?; + let comments = stmt + .query_map([&media_id_str], |row| { + let id_str: String = row.get(0)?; + let uid_str: String = row.get(1)?; + let mid_str: String = row.get(2)?; + let parent_str: Option = row.get(3)?; + let created_str: String = row.get(5)?; + Ok(crate::social::Comment { + id: parse_uuid(&id_str)?, + user_id: crate::users::UserId(parse_uuid(&uid_str)?), + media_id: MediaId(parse_uuid(&mid_str)?), + parent_comment_id: parent_str + .and_then(|s| Uuid::parse_str(&s).ok()), + text: row.get(4)?, + created_at: parse_datetime(&created_str), + }) + })? + .filter_map(|r| r.ok()) + .collect(); + Ok(comments) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("get_media_comments timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - // ===== Comments ===== - async fn add_comment( - &self, - user_id: crate::users::UserId, - media_id: MediaId, - text: &str, - parent_id: Option, - ) -> Result { - let conn = self.conn.clone(); - let user_id_str = user_id.0.to_string(); - let media_id_str = media_id.0.to_string(); - let text = text.to_string(); - let parent_str = parent_id.map(|p| p.to_string()); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - let id = Uuid::now_v7(); - let id_str = id.to_string(); - let now = chrono::Utc::now(); - db.execute( - "INSERT INTO comments (id, user_id, media_id, parent_comment_id, text, created_at) VALUES (?, ?, ?, ?, ?, ?)", - params![&id_str, &user_id_str, &media_id_str, &parent_str, &text, now.to_rfc3339()], - )?; - Ok(crate::social::Comment { - id, - user_id, - media_id, - parent_comment_id: parent_id, - text, - created_at: now, - }) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("add_comment timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + async fn delete_comment(&self, id: Uuid) -> Result<()> { + let conn = self.conn.clone(); + let id_str = id.to_string(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + db.execute("DELETE FROM comments WHERE id = ?", [&id_str])?; + Ok(()) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| PinakesError::Database("delete_comment timed out".into()))? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn get_media_comments(&self, media_id: MediaId) -> Result> { - let conn = self.conn.clone(); - let media_id_str = media_id.0.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - let mut stmt = db.prepare( - "SELECT id, user_id, media_id, parent_comment_id, text, created_at FROM comments WHERE media_id = ? ORDER BY created_at ASC" - )?; - let comments = stmt - .query_map([&media_id_str], |row| { - let id_str: String = row.get(0)?; - let uid_str: String = row.get(1)?; - let mid_str: String = row.get(2)?; - let parent_str: Option = row.get(3)?; - let created_str: String = row.get(5)?; - Ok(crate::social::Comment { - id: parse_uuid(&id_str)?, - user_id: crate::users::UserId(parse_uuid(&uid_str)?), - media_id: MediaId(parse_uuid(&mid_str)?), - parent_comment_id: parent_str.and_then(|s| Uuid::parse_str(&s).ok()), - text: row.get(4)?, - created_at: parse_datetime(&created_str), - }) - })? - .filter_map(|r| r.ok()) - .collect(); - Ok(comments) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("get_media_comments timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + // ===== Favorites ===== + async fn add_favorite( + &self, + user_id: crate::users::UserId, + media_id: MediaId, + ) -> Result<()> { + let conn = self.conn.clone(); + let user_id_str = user_id.0.to_string(); + let media_id_str = media_id.0.to_string(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + let now = chrono::Utc::now(); + db.execute( + "INSERT OR IGNORE INTO favorites (user_id, media_id, created_at) \ + VALUES (?, ?, ?)", + params![&user_id_str, &media_id_str, now.to_rfc3339()], + )?; + Ok(()) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| PinakesError::Database("add_favorite timed out".into()))? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn delete_comment(&self, id: Uuid) -> Result<()> { - let conn = self.conn.clone(); - let id_str = id.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - db.execute("DELETE FROM comments WHERE id = ?", [&id_str])?; - Ok(()) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("delete_comment timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + async fn remove_favorite( + &self, + user_id: crate::users::UserId, + media_id: MediaId, + ) -> Result<()> { + let conn = self.conn.clone(); + let user_id_str = user_id.0.to_string(); + let media_id_str = media_id.0.to_string(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + db.execute( + "DELETE FROM favorites WHERE user_id = ? AND media_id = ?", + params![&user_id_str, &media_id_str], + )?; + Ok(()) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| PinakesError::Database("remove_favorite timed out".into()))? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - // ===== Favorites ===== - async fn add_favorite(&self, user_id: crate::users::UserId, media_id: MediaId) -> Result<()> { - let conn = self.conn.clone(); - let user_id_str = user_id.0.to_string(); - let media_id_str = media_id.0.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - let now = chrono::Utc::now(); - db.execute( - "INSERT OR IGNORE INTO favorites (user_id, media_id, created_at) VALUES (?, ?, ?)", - params![&user_id_str, &media_id_str, now.to_rfc3339()], - )?; - Ok(()) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("add_favorite timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + async fn get_user_favorites( + &self, + user_id: crate::users::UserId, + pagination: &Pagination, + ) -> Result> { + let conn = self.conn.clone(); + let user_id_str = user_id.0.to_string(); + let limit = pagination.limit as i64; + let offset = pagination.offset as i64; + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + let mut stmt = db.prepare( + "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, \ + m.file_size, m.title, m.artist, m.album, m.genre, m.year, \ + m.duration_secs, m.description, m.thumbnail_path, m.created_at, \ + m.updated_at FROM media_items m JOIN favorites f ON m.id = \ + f.media_id WHERE f.user_id = ? ORDER BY f.created_at DESC LIMIT ? \ + OFFSET ?", + )?; + let mut items: Vec = stmt + .query_map(params![&user_id_str, limit, offset], row_to_media_item)? + .filter_map(|r| r.ok()) + .collect(); + load_custom_fields_batch(&db, &mut items)?; + Ok(items) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("get_user_favorites timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn remove_favorite( - &self, - user_id: crate::users::UserId, - media_id: MediaId, - ) -> Result<()> { - let conn = self.conn.clone(); - let user_id_str = user_id.0.to_string(); - let media_id_str = media_id.0.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - db.execute( - "DELETE FROM favorites WHERE user_id = ? AND media_id = ?", - params![&user_id_str, &media_id_str], - )?; - Ok(()) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("remove_favorite timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + async fn is_favorite( + &self, + user_id: crate::users::UserId, + media_id: MediaId, + ) -> Result { + let conn = self.conn.clone(); + let user_id_str = user_id.0.to_string(); + let media_id_str = media_id.0.to_string(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + let count: i64 = db.query_row( + "SELECT COUNT(*) FROM favorites WHERE user_id = ? AND media_id = ?", + params![&user_id_str, &media_id_str], + |row| row.get(0), + )?; + Ok(count > 0) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| PinakesError::Database("is_favorite timed out".into()))? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn get_user_favorites( - &self, - user_id: crate::users::UserId, - pagination: &Pagination, - ) -> Result> { - let conn = self.conn.clone(); - let user_id_str = user_id.0.to_string(); - let limit = pagination.limit as i64; - let offset = pagination.offset as i64; - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - let mut stmt = db.prepare( - "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, m.description, m.thumbnail_path, m.created_at, m.updated_at FROM media_items m JOIN favorites f ON m.id = f.media_id WHERE f.user_id = ? ORDER BY f.created_at DESC LIMIT ? OFFSET ?" - )?; - let mut items: Vec = stmt - .query_map(params![&user_id_str, limit, offset], row_to_media_item)? - .filter_map(|r| r.ok()) - .collect(); - load_custom_fields_batch(&db, &mut items)?; - Ok(items) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("get_user_favorites timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + // ===== 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 conn = self.conn.clone(); + let media_id_str = media_id.0.to_string(); + let created_by_str = created_by.0.to_string(); + let token = token.to_string(); + let password_hash = password_hash.map(String::from); + let expires_str = expires_at.map(|dt| dt.to_rfc3339()); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + let id = Uuid::now_v7(); + let id_str = id.to_string(); + let now = chrono::Utc::now(); + db.execute( + "INSERT INTO share_links (id, media_id, created_by, token, \ + password_hash, expires_at, view_count, created_at) VALUES (?, ?, ?, \ + ?, ?, ?, 0, ?)", + params![ + &id_str, + &media_id_str, + &created_by_str, + &token, + &password_hash, + &expires_str, + now.to_rfc3339() + ], + )?; + Ok(crate::social::ShareLink { + id, + media_id, + created_by, + token, + password_hash, + expires_at, + view_count: 0, + created_at: now, + }) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("create_share_link timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn is_favorite(&self, user_id: crate::users::UserId, media_id: MediaId) -> Result { - let conn = self.conn.clone(); - let user_id_str = user_id.0.to_string(); - let media_id_str = media_id.0.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - let count: i64 = db.query_row( - "SELECT COUNT(*) FROM favorites WHERE user_id = ? AND media_id = ?", - params![&user_id_str, &media_id_str], - |row| row.get(0), - )?; - Ok(count > 0) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("is_favorite timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + async fn get_share_link( + &self, + token: &str, + ) -> Result { + let conn = self.conn.clone(); + let token = token.to_string(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + db.query_row( + "SELECT id, media_id, created_by, token, password_hash, expires_at, \ + view_count, created_at FROM share_links WHERE token = ?", + [&token], + |row| { + let id_str: String = row.get(0)?; + let mid_str: String = row.get(1)?; + let uid_str: String = row.get(2)?; + let expires_str: Option = row.get(5)?; + let created_str: String = row.get(7)?; + Ok(crate::social::ShareLink { + id: parse_uuid(&id_str)?, + media_id: MediaId(parse_uuid(&mid_str)?), + created_by: crate::users::UserId(parse_uuid(&uid_str)?), + token: row.get(3)?, + password_hash: row.get(4)?, + expires_at: expires_str.map(|s| parse_datetime(&s)), + view_count: row.get::<_, i64>(6)? as u64, + created_at: parse_datetime(&created_str), + }) + }, + ) + .map_err(|e| { + match e { + rusqlite::Error::QueryReturnedNoRows => { + PinakesError::NotFound("share link not found".into()) + }, + _ => PinakesError::Database(e.to_string()), + } + }) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| PinakesError::Database("get_share_link timed out".into()))? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - // ===== 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 conn = self.conn.clone(); - let media_id_str = media_id.0.to_string(); - let created_by_str = created_by.0.to_string(); - let token = token.to_string(); - let password_hash = password_hash.map(String::from); - let expires_str = expires_at.map(|dt| dt.to_rfc3339()); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - let id = Uuid::now_v7(); - let id_str = id.to_string(); - let now = chrono::Utc::now(); - db.execute( - "INSERT INTO share_links (id, media_id, created_by, token, password_hash, expires_at, view_count, created_at) VALUES (?, ?, ?, ?, ?, ?, 0, ?)", - params![&id_str, &media_id_str, &created_by_str, &token, &password_hash, &expires_str, now.to_rfc3339()], - )?; - Ok(crate::social::ShareLink { - id, - media_id, - created_by, - token, - password_hash, - expires_at, - view_count: 0, - created_at: now, - }) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("create_share_link timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + async fn increment_share_views(&self, token: &str) -> Result<()> { + let conn = self.conn.clone(); + let token = token.to_string(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + db.execute( + "UPDATE share_links SET view_count = view_count + 1 WHERE token = ?", + [&token], + )?; + Ok(()) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("increment_share_views timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn get_share_link(&self, token: &str) -> Result { - let conn = self.conn.clone(); - let token = token.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - db.query_row( - "SELECT id, media_id, created_by, token, password_hash, expires_at, view_count, created_at FROM share_links WHERE token = ?", - [&token], - |row| { - let id_str: String = row.get(0)?; - let mid_str: String = row.get(1)?; - let uid_str: String = row.get(2)?; - let expires_str: Option = row.get(5)?; - let created_str: String = row.get(7)?; - Ok(crate::social::ShareLink { - id: parse_uuid(&id_str)?, - media_id: MediaId(parse_uuid(&mid_str)?), - created_by: crate::users::UserId(parse_uuid(&uid_str)?), - token: row.get(3)?, - password_hash: row.get(4)?, - expires_at: expires_str.map(|s| parse_datetime(&s)), - view_count: row.get::<_, i64>(6)? as u64, - created_at: parse_datetime(&created_str), - }) - }, - ).map_err(|e| match e { - rusqlite::Error::QueryReturnedNoRows => PinakesError::NotFound("share link not found".into()), - _ => PinakesError::Database(e.to_string()), - }) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("get_share_link timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + async fn delete_share_link(&self, id: Uuid) -> Result<()> { + let conn = self.conn.clone(); + let id_str = id.to_string(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + db.execute("DELETE FROM share_links WHERE id = ?", [&id_str])?; + Ok(()) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("delete_share_link timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn increment_share_views(&self, token: &str) -> Result<()> { - let conn = self.conn.clone(); - let token = token.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - db.execute( - "UPDATE share_links SET view_count = view_count + 1 WHERE token = ?", - [&token], - )?; - Ok(()) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("increment_share_views timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + // ===== 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 conn = self.conn.clone(); + let owner_id_str = owner_id.0.to_string(); + let name = name.to_string(); + let description = description.map(String::from); + let filter_query = filter_query.map(String::from); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + let id = Uuid::now_v7(); + let id_str = id.to_string(); + let now = chrono::Utc::now(); + db.execute( + "INSERT INTO playlists (id, owner_id, name, description, is_public, \ + is_smart, filter_query, created_at, updated_at) VALUES (?, ?, ?, ?, \ + ?, ?, ?, ?, ?)", + params![ + &id_str, + &owner_id_str, + &name, + &description, + is_public as i32, + is_smart as i32, + &filter_query, + now.to_rfc3339(), + now.to_rfc3339() + ], + )?; + Ok(crate::playlists::Playlist { + id, + owner_id, + name, + description, + is_public, + is_smart, + filter_query, + created_at: now, + updated_at: now, + }) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| PinakesError::Database("create_playlist timed out".into()))? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn delete_share_link(&self, id: Uuid) -> Result<()> { - let conn = self.conn.clone(); - let id_str = id.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - db.execute("DELETE FROM share_links WHERE id = ?", [&id_str])?; - Ok(()) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("delete_share_link timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + async fn get_playlist(&self, id: Uuid) -> Result { + let conn = self.conn.clone(); + let id_str = id.to_string(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + db.query_row( + "SELECT id, owner_id, name, description, is_public, is_smart, \ + filter_query, created_at, updated_at FROM playlists WHERE id = ?", + [&id_str], + |row| { + let id_str: String = row.get(0)?; + let owner_str: String = row.get(1)?; + let created_str: String = row.get(7)?; + let updated_str: String = row.get(8)?; + Ok(crate::playlists::Playlist { + id: parse_uuid(&id_str)?, + owner_id: crate::users::UserId(parse_uuid(&owner_str)?), + name: row.get(2)?, + description: row.get(3)?, + is_public: row.get::<_, i32>(4)? != 0, + is_smart: row.get::<_, i32>(5)? != 0, + filter_query: row.get(6)?, + created_at: parse_datetime(&created_str), + updated_at: parse_datetime(&updated_str), + }) + }, + ) + .map_err(|e| { + match e { + rusqlite::Error::QueryReturnedNoRows => { + PinakesError::NotFound(format!("playlist {id}")) + }, + _ => PinakesError::Database(e.to_string()), + } + }) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| PinakesError::Database("get_playlist timed out".into()))? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - // ===== 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 conn = self.conn.clone(); - let owner_id_str = owner_id.0.to_string(); - let name = name.to_string(); - let description = description.map(String::from); - let filter_query = filter_query.map(String::from); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - let id = Uuid::now_v7(); - let id_str = id.to_string(); - let now = chrono::Utc::now(); - db.execute( - "INSERT INTO playlists (id, owner_id, name, description, is_public, is_smart, filter_query, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - params![&id_str, &owner_id_str, &name, &description, is_public as i32, is_smart as i32, &filter_query, now.to_rfc3339(), now.to_rfc3339()], - )?; + async fn list_playlists( + &self, + owner_id: Option, + ) -> Result> { + let conn = self.conn.clone(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + let (sql, param): (String, Option) = match owner_id { + Some(uid) => { + ( + "SELECT id, owner_id, name, description, is_public, is_smart, \ + filter_query, created_at, updated_at FROM playlists WHERE \ + owner_id = ? OR is_public = 1 ORDER BY updated_at DESC" + .to_string(), + Some(uid.0.to_string()), + ) + }, + None => { + ( + "SELECT id, owner_id, name, description, is_public, is_smart, \ + filter_query, created_at, updated_at FROM playlists ORDER BY \ + updated_at DESC" + .to_string(), + None, + ) + }, + }; + let mut stmt = db.prepare(&sql)?; + let rows = if let Some(ref p) = param { + stmt + .query_map([p], |row| { + let id_str: String = row.get(0)?; + let owner_str: String = row.get(1)?; + let created_str: String = row.get(7)?; + let updated_str: String = row.get(8)?; Ok(crate::playlists::Playlist { - id, - owner_id, - name, - description, - is_public, - is_smart, - filter_query, - created_at: now, - updated_at: now, + id: parse_uuid(&id_str)?, + owner_id: crate::users::UserId(parse_uuid(&owner_str)?), + name: row.get(2)?, + description: row.get(3)?, + is_public: row.get::<_, i32>(4)? != 0, + is_smart: row.get::<_, i32>(5)? != 0, + filter_query: row.get(6)?, + created_at: parse_datetime(&created_str), + updated_at: parse_datetime(&updated_str), }) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("create_playlist timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } - - async fn get_playlist(&self, id: Uuid) -> Result { - let conn = self.conn.clone(); - let id_str = id.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - db.query_row( - "SELECT id, owner_id, name, description, is_public, is_smart, filter_query, created_at, updated_at FROM playlists WHERE id = ?", - [&id_str], - |row| { - let id_str: String = row.get(0)?; - let owner_str: String = row.get(1)?; - let created_str: String = row.get(7)?; - let updated_str: String = row.get(8)?; - Ok(crate::playlists::Playlist { - id: parse_uuid(&id_str)?, - owner_id: crate::users::UserId(parse_uuid(&owner_str)?), - name: row.get(2)?, - description: row.get(3)?, - is_public: row.get::<_, i32>(4)? != 0, - is_smart: row.get::<_, i32>(5)? != 0, - filter_query: row.get(6)?, - created_at: parse_datetime(&created_str), - updated_at: parse_datetime(&updated_str), - }) - }, - ).map_err(|e| match e { - rusqlite::Error::QueryReturnedNoRows => PinakesError::NotFound(format!("playlist {id}")), - _ => PinakesError::Database(e.to_string()), + })? + .filter_map(|r| r.ok()) + .collect() + } else { + stmt + .query_map([], |row| { + let id_str: String = row.get(0)?; + let owner_str: String = row.get(1)?; + let created_str: String = row.get(7)?; + let updated_str: String = row.get(8)?; + Ok(crate::playlists::Playlist { + id: parse_uuid(&id_str)?, + owner_id: crate::users::UserId(parse_uuid(&owner_str)?), + name: row.get(2)?, + description: row.get(3)?, + is_public: row.get::<_, i32>(4)? != 0, + is_smart: row.get::<_, i32>(5)? != 0, + filter_query: row.get(6)?, + created_at: parse_datetime(&created_str), + updated_at: parse_datetime(&updated_str), }) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("get_playlist timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + })? + .filter_map(|r| r.ok()) + .collect() + }; + Ok(rows) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| PinakesError::Database("list_playlists timed out".into()))? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn list_playlists( - &self, - owner_id: Option, - ) -> Result> { - let conn = self.conn.clone(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - let (sql, param): (String, Option) = match owner_id { - Some(uid) => ( - "SELECT id, owner_id, name, description, is_public, is_smart, filter_query, created_at, updated_at FROM playlists WHERE owner_id = ? OR is_public = 1 ORDER BY updated_at DESC".to_string(), - Some(uid.0.to_string()), - ), - None => ( - "SELECT id, owner_id, name, description, is_public, is_smart, filter_query, created_at, updated_at FROM playlists ORDER BY updated_at DESC".to_string(), - None, - ), - }; - let mut stmt = db.prepare(&sql)?; - let rows = if let Some(ref p) = param { - stmt.query_map([p], |row| { - let id_str: String = row.get(0)?; - let owner_str: String = row.get(1)?; - let created_str: String = row.get(7)?; - let updated_str: String = row.get(8)?; - Ok(crate::playlists::Playlist { - id: parse_uuid(&id_str)?, - owner_id: crate::users::UserId(parse_uuid(&owner_str)?), - name: row.get(2)?, - description: row.get(3)?, - is_public: row.get::<_, i32>(4)? != 0, - is_smart: row.get::<_, i32>(5)? != 0, - filter_query: row.get(6)?, - created_at: parse_datetime(&created_str), - updated_at: parse_datetime(&updated_str), - }) - })? - .filter_map(|r| r.ok()) - .collect() - } else { - stmt.query_map([], |row| { - let id_str: String = row.get(0)?; - let owner_str: String = row.get(1)?; - let created_str: String = row.get(7)?; - let updated_str: String = row.get(8)?; - Ok(crate::playlists::Playlist { - id: parse_uuid(&id_str)?, - owner_id: crate::users::UserId(parse_uuid(&owner_str)?), - name: row.get(2)?, - description: row.get(3)?, - is_public: row.get::<_, i32>(4)? != 0, - is_smart: row.get::<_, i32>(5)? != 0, - filter_query: row.get(6)?, - created_at: parse_datetime(&created_str), - updated_at: parse_datetime(&updated_str), - }) - })? - .filter_map(|r| r.ok()) - .collect() - }; - Ok(rows) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("list_playlists timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + async fn update_playlist( + &self, + id: Uuid, + name: Option<&str>, + description: Option<&str>, + is_public: Option, + ) -> Result { + let conn = self.conn.clone(); + let id_str = id.to_string(); + let name = name.map(String::from); + let description = description.map(String::from); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + let now = chrono::Utc::now(); + let mut updates = vec!["updated_at = ?".to_string()]; + let mut sql_params: Vec> = + vec![Box::new(now.to_rfc3339())]; + if let Some(ref n) = name { + updates.push("name = ?".to_string()); + sql_params.push(Box::new(n.clone())); + } + if let Some(ref d) = description { + updates.push("description = ?".to_string()); + sql_params.push(Box::new(d.clone())); + } + if let Some(p) = is_public { + updates.push("is_public = ?".to_string()); + sql_params.push(Box::new(p as i32)); + } + sql_params.push(Box::new(id_str.clone())); + let sql = + format!("UPDATE playlists SET {} WHERE id = ?", updates.join(", ")); + let param_refs: Vec<&dyn rusqlite::ToSql> = + sql_params.iter().map(|p| p.as_ref()).collect(); + db.execute(&sql, param_refs.as_slice())?; + // Fetch updated + db.query_row( + "SELECT id, owner_id, name, description, is_public, is_smart, \ + filter_query, created_at, updated_at FROM playlists WHERE id = ?", + [&id_str], + |row| { + let id_str: String = row.get(0)?; + let owner_str: String = row.get(1)?; + let created_str: String = row.get(7)?; + let updated_str: String = row.get(8)?; + Ok(crate::playlists::Playlist { + id: parse_uuid(&id_str)?, + owner_id: crate::users::UserId(parse_uuid(&owner_str)?), + name: row.get(2)?, + description: row.get(3)?, + is_public: row.get::<_, i32>(4)? != 0, + is_smart: row.get::<_, i32>(5)? != 0, + filter_query: row.get(6)?, + created_at: parse_datetime(&created_str), + updated_at: parse_datetime(&updated_str), + }) + }, + ) + .map_err(|e| { + match e { + rusqlite::Error::QueryReturnedNoRows => { + PinakesError::NotFound(format!("playlist {}", id_str)) + }, + _ => PinakesError::Database(e.to_string()), + } + }) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| PinakesError::Database("update_playlist timed out".into()))? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn update_playlist( - &self, - id: Uuid, - name: Option<&str>, - description: Option<&str>, - is_public: Option, - ) -> Result { - let conn = self.conn.clone(); - let id_str = id.to_string(); - let name = name.map(String::from); - let description = description.map(String::from); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - let now = chrono::Utc::now(); - let mut updates = vec!["updated_at = ?".to_string()]; - let mut sql_params: Vec> = vec![Box::new(now.to_rfc3339())]; - if let Some(ref n) = name { - updates.push("name = ?".to_string()); - sql_params.push(Box::new(n.clone())); - } - if let Some(ref d) = description { - updates.push("description = ?".to_string()); - sql_params.push(Box::new(d.clone())); - } - if let Some(p) = is_public { - updates.push("is_public = ?".to_string()); - sql_params.push(Box::new(p as i32)); - } - sql_params.push(Box::new(id_str.clone())); - let sql = format!("UPDATE playlists SET {} WHERE id = ?", updates.join(", ")); - let param_refs: Vec<&dyn rusqlite::ToSql> = - sql_params.iter().map(|p| p.as_ref()).collect(); - db.execute(&sql, param_refs.as_slice())?; - // Fetch updated - db.query_row( - "SELECT id, owner_id, name, description, is_public, is_smart, filter_query, created_at, updated_at FROM playlists WHERE id = ?", - [&id_str], - |row| { - let id_str: String = row.get(0)?; - let owner_str: String = row.get(1)?; - let created_str: String = row.get(7)?; - let updated_str: String = row.get(8)?; - Ok(crate::playlists::Playlist { - id: parse_uuid(&id_str)?, - owner_id: crate::users::UserId(parse_uuid(&owner_str)?), - name: row.get(2)?, - description: row.get(3)?, - is_public: row.get::<_, i32>(4)? != 0, - is_smart: row.get::<_, i32>(5)? != 0, - filter_query: row.get(6)?, - created_at: parse_datetime(&created_str), - updated_at: parse_datetime(&updated_str), - }) - }, - ).map_err(|e| match e { - rusqlite::Error::QueryReturnedNoRows => PinakesError::NotFound(format!("playlist {}", id_str)), - _ => PinakesError::Database(e.to_string()), - }) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("update_playlist timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + async fn delete_playlist(&self, id: Uuid) -> Result<()> { + let conn = self.conn.clone(); + let id_str = id.to_string(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + db.execute("DELETE FROM playlists WHERE id = ?", [&id_str])?; + Ok(()) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| PinakesError::Database("delete_playlist timed out".into()))? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn delete_playlist(&self, id: Uuid) -> Result<()> { - let conn = self.conn.clone(); - let id_str = id.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - db.execute("DELETE FROM playlists WHERE id = ?", [&id_str])?; - Ok(()) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("delete_playlist timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + async fn add_to_playlist( + &self, + playlist_id: Uuid, + media_id: MediaId, + position: i32, + ) -> Result<()> { + let conn = self.conn.clone(); + let playlist_id_str = playlist_id.to_string(); + let media_id_str = media_id.0.to_string(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + let now = chrono::Utc::now(); + db.execute( + "INSERT OR REPLACE INTO playlist_items (playlist_id, media_id, \ + position, added_at) VALUES (?, ?, ?, ?)", + params![&playlist_id_str, &media_id_str, position, now.to_rfc3339()], + )?; + Ok(()) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| PinakesError::Database("add_to_playlist timed out".into()))? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn add_to_playlist( - &self, - playlist_id: Uuid, - media_id: MediaId, - position: i32, - ) -> Result<()> { - let conn = self.conn.clone(); - let playlist_id_str = playlist_id.to_string(); - let media_id_str = media_id.0.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - let now = chrono::Utc::now(); - db.execute( - "INSERT OR REPLACE INTO playlist_items (playlist_id, media_id, position, added_at) VALUES (?, ?, ?, ?)", - params![&playlist_id_str, &media_id_str, position, now.to_rfc3339()], - )?; - Ok(()) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("add_to_playlist timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + async fn remove_from_playlist( + &self, + playlist_id: Uuid, + media_id: MediaId, + ) -> Result<()> { + let conn = self.conn.clone(); + let playlist_id_str = playlist_id.to_string(); + let media_id_str = media_id.0.to_string(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + db.execute( + "DELETE FROM playlist_items WHERE playlist_id = ? AND media_id = ?", + params![&playlist_id_str, &media_id_str], + )?; + Ok(()) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("remove_from_playlist timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn remove_from_playlist(&self, playlist_id: Uuid, media_id: MediaId) -> Result<()> { - let conn = self.conn.clone(); - let playlist_id_str = playlist_id.to_string(); - let media_id_str = media_id.0.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - db.execute( - "DELETE FROM playlist_items WHERE playlist_id = ? AND media_id = ?", - params![&playlist_id_str, &media_id_str], - )?; - Ok(()) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("remove_from_playlist timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + async fn get_playlist_items( + &self, + playlist_id: Uuid, + ) -> Result> { + let conn = self.conn.clone(); + let playlist_id_str = playlist_id.to_string(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + let mut stmt = db.prepare( + "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, \ + m.file_size, m.title, m.artist, m.album, m.genre, m.year, \ + m.duration_secs, m.description, m.thumbnail_path, m.created_at, \ + m.updated_at FROM media_items m JOIN playlist_items pi ON m.id = \ + pi.media_id WHERE pi.playlist_id = ? ORDER BY pi.position ASC", + )?; + let mut items: Vec = stmt + .query_map([&playlist_id_str], row_to_media_item)? + .filter_map(|r| r.ok()) + .collect(); + load_custom_fields_batch(&db, &mut items)?; + Ok(items) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("get_playlist_items timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn get_playlist_items(&self, playlist_id: Uuid) -> Result> { - let conn = self.conn.clone(); - let playlist_id_str = playlist_id.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - let mut stmt = db.prepare( - "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, m.description, m.thumbnail_path, m.created_at, m.updated_at FROM media_items m JOIN playlist_items pi ON m.id = pi.media_id WHERE pi.playlist_id = ? ORDER BY pi.position ASC" - )?; - let mut items: Vec = stmt - .query_map([&playlist_id_str], row_to_media_item)? - .filter_map(|r| r.ok()) - .collect(); - load_custom_fields_batch(&db, &mut items)?; - Ok(items) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("get_playlist_items timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + async fn reorder_playlist( + &self, + playlist_id: Uuid, + media_id: MediaId, + new_position: i32, + ) -> Result<()> { + let conn = self.conn.clone(); + let playlist_id_str = playlist_id.to_string(); + let media_id_str = media_id.0.to_string(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + db.execute( + "UPDATE playlist_items SET position = ? WHERE playlist_id = ? AND \ + media_id = ?", + params![new_position, &playlist_id_str, &media_id_str], + )?; + Ok(()) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| PinakesError::Database("reorder_playlist timed out".into()))? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn reorder_playlist( - &self, - playlist_id: Uuid, - media_id: MediaId, - new_position: i32, - ) -> Result<()> { - let conn = self.conn.clone(); - let playlist_id_str = playlist_id.to_string(); - let media_id_str = media_id.0.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - db.execute( - "UPDATE playlist_items SET position = ? WHERE playlist_id = ? AND media_id = ?", - params![new_position, &playlist_id_str, &media_id_str], - )?; - Ok(()) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("reorder_playlist timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + // ===== Analytics ===== + async fn record_usage_event( + &self, + event: &crate::analytics::UsageEvent, + ) -> Result<()> { + let conn = self.conn.clone(); + let id_str = event.id.to_string(); + let media_id_str = event.media_id.map(|m| m.0.to_string()); + let user_id_str = event.user_id.map(|u| u.0.to_string()); + let event_type = event.event_type.to_string(); + let ts = event.timestamp.to_rfc3339(); + let duration = event.duration_secs; + let context = event.context_json.clone(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + db.execute( + "INSERT INTO usage_events (id, media_id, user_id, event_type, \ + timestamp, duration_secs, context_json) VALUES (?, ?, ?, ?, ?, ?, ?)", + params![ + &id_str, + &media_id_str, + &user_id_str, + &event_type, + &ts, + &duration, + &context + ], + )?; + Ok(()) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("record_usage_event timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - // ===== Analytics ===== - async fn record_usage_event(&self, event: &crate::analytics::UsageEvent) -> Result<()> { - let conn = self.conn.clone(); - let id_str = event.id.to_string(); - let media_id_str = event.media_id.map(|m| m.0.to_string()); - let user_id_str = event.user_id.map(|u| u.0.to_string()); - let event_type = event.event_type.to_string(); - let ts = event.timestamp.to_rfc3339(); - let duration = event.duration_secs; - let context = event.context_json.clone(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - db.execute( - "INSERT INTO usage_events (id, media_id, user_id, event_type, timestamp, duration_secs, context_json) VALUES (?, ?, ?, ?, ?, ?, ?)", - params![&id_str, &media_id_str, &user_id_str, &event_type, &ts, &duration, &context], - )?; - Ok(()) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("record_usage_event timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + async fn get_usage_events( + &self, + media_id: Option, + user_id: Option, + limit: u64, + ) -> Result> { + let conn = self.conn.clone(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + let mut conditions = Vec::new(); + let mut sql_params: Vec> = Vec::new(); + if let Some(mid) = media_id { + conditions.push("media_id = ?".to_string()); + sql_params.push(Box::new(mid.0.to_string())); + } + if let Some(uid) = user_id { + conditions.push("user_id = ?".to_string()); + sql_params.push(Box::new(uid.0.to_string())); + } + let where_clause = if conditions.is_empty() { + String::new() + } else { + format!("WHERE {}", conditions.join(" AND ")) + }; + sql_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 ?", + where_clause + ); + let mut stmt = db.prepare(&sql)?; + let param_refs: Vec<&dyn rusqlite::ToSql> = + sql_params.iter().map(|p| p.as_ref()).collect(); + let events = stmt + .query_map(param_refs.as_slice(), |row| { + let id_str: String = row.get(0)?; + let mid_str: Option = row.get(1)?; + let uid_str: Option = row.get(2)?; + let event_type_str: String = row.get(3)?; + let ts_str: String = row.get(4)?; + Ok(crate::analytics::UsageEvent { + id: parse_uuid(&id_str)?, + media_id: mid_str + .and_then(|s| Uuid::parse_str(&s).ok()) + .map(MediaId), + user_id: uid_str + .and_then(|s| Uuid::parse_str(&s).ok()) + .map(crate::users::UserId), + event_type: event_type_str + .parse() + .unwrap_or(crate::analytics::UsageEventType::View), + timestamp: parse_datetime(&ts_str), + duration_secs: row.get(5)?, + context_json: row.get(6)?, + }) + })? + .filter_map(|r| r.ok()) + .collect(); + Ok(events) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| PinakesError::Database("get_usage_events timed out".into()))? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn get_usage_events( - &self, - media_id: Option, - user_id: Option, - limit: u64, - ) -> Result> { - let conn = self.conn.clone(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - let mut conditions = Vec::new(); - let mut sql_params: Vec> = Vec::new(); - if let Some(mid) = media_id { - conditions.push("media_id = ?".to_string()); - sql_params.push(Box::new(mid.0.to_string())); - } - if let Some(uid) = user_id { - conditions.push("user_id = ?".to_string()); - sql_params.push(Box::new(uid.0.to_string())); - } - let where_clause = if conditions.is_empty() { - String::new() - } else { - format!("WHERE {}", conditions.join(" AND ")) - }; - sql_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 ?", - where_clause - ); - let mut stmt = db.prepare(&sql)?; - let param_refs: Vec<&dyn rusqlite::ToSql> = - sql_params.iter().map(|p| p.as_ref()).collect(); - let events = stmt - .query_map(param_refs.as_slice(), |row| { - let id_str: String = row.get(0)?; - let mid_str: Option = row.get(1)?; - let uid_str: Option = row.get(2)?; - let event_type_str: String = row.get(3)?; - let ts_str: String = row.get(4)?; - Ok(crate::analytics::UsageEvent { - id: parse_uuid(&id_str)?, - media_id: mid_str.and_then(|s| Uuid::parse_str(&s).ok()).map(MediaId), - user_id: uid_str - .and_then(|s| Uuid::parse_str(&s).ok()) - .map(crate::users::UserId), - event_type: event_type_str - .parse() - .unwrap_or(crate::analytics::UsageEventType::View), - timestamp: parse_datetime(&ts_str), - duration_secs: row.get(5)?, - context_json: row.get(6)?, - }) - })? - .filter_map(|r| r.ok()) - .collect(); - Ok(events) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("get_usage_events timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + async fn get_most_viewed(&self, limit: u64) -> Result> { + let conn = self.conn.clone(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + let mut stmt = db.prepare( + "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, \ + m.file_size, m.title, m.artist, m.album, m.genre, m.year, \ + m.duration_secs, m.description, m.thumbnail_path, 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 ORDER BY view_count DESC LIMIT ?", + )?; + let mut items: Vec<(MediaItem, u64)> = stmt + .query_map([limit as i64], |row| { + let item = row_to_media_item(row)?; + let count: i64 = row.get(16)?; + Ok((item, count as u64)) + })? + .filter_map(|r| r.ok()) + .collect(); + // Load custom fields for each item + let mut media_items: Vec = + items.iter().map(|(i, _)| i.clone()).collect(); + load_custom_fields_batch(&db, &mut media_items)?; + for (i, (item, _)) in items.iter_mut().enumerate() { + item.custom_fields = std::mem::take(&mut media_items[i].custom_fields); + } + Ok(items) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| PinakesError::Database("get_most_viewed timed out".into()))? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn get_most_viewed(&self, limit: u64) -> Result> { - let conn = self.conn.clone(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - let mut stmt = db.prepare( - "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, m.description, m.thumbnail_path, 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 ORDER BY view_count DESC LIMIT ?" - )?; - let mut items: Vec<(MediaItem, u64)> = stmt - .query_map([limit as i64], |row| { - let item = row_to_media_item(row)?; - let count: i64 = row.get(16)?; - Ok((item, count as u64)) - })? - .filter_map(|r| r.ok()) - .collect(); - // Load custom fields for each item - let mut media_items: Vec = items.iter().map(|(i, _)| i.clone()).collect(); - load_custom_fields_batch(&db, &mut media_items)?; - for (i, (item, _)) in items.iter_mut().enumerate() { - item.custom_fields = std::mem::take(&mut media_items[i].custom_fields); - } - Ok(items) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("get_most_viewed timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + async fn get_recently_viewed( + &self, + user_id: crate::users::UserId, + limit: u64, + ) -> Result> { + let conn = self.conn.clone(); + let user_id_str = user_id.0.to_string(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + let mut stmt = db.prepare( + "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, \ + m.file_size, m.title, m.artist, m.album, m.genre, m.year, \ + m.duration_secs, m.description, m.thumbnail_path, m.created_at, \ + m.updated_at FROM media_items m JOIN usage_events ue ON m.id = \ + ue.media_id WHERE ue.user_id = ? AND ue.event_type IN ('view', \ + 'play') GROUP BY m.id ORDER BY MAX(ue.timestamp) DESC LIMIT ?", + )?; + let mut items: Vec = stmt + .query_map(params![&user_id_str, limit as i64], row_to_media_item)? + .filter_map(|r| r.ok()) + .collect(); + load_custom_fields_batch(&db, &mut items)?; + Ok(items) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("get_recently_viewed timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn get_recently_viewed( - &self, - user_id: crate::users::UserId, - limit: u64, - ) -> Result> { - let conn = self.conn.clone(); - let user_id_str = user_id.0.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - let mut stmt = db.prepare( - "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, m.description, m.thumbnail_path, m.created_at, m.updated_at FROM media_items m JOIN usage_events ue ON m.id = ue.media_id WHERE ue.user_id = ? AND ue.event_type IN ('view', 'play') GROUP BY m.id ORDER BY MAX(ue.timestamp) DESC LIMIT ?" - )?; - let mut items: Vec = stmt - .query_map(params![&user_id_str, limit as i64], row_to_media_item)? - .filter_map(|r| r.ok()) - .collect(); - load_custom_fields_batch(&db, &mut items)?; - Ok(items) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("get_recently_viewed timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + async fn update_watch_progress( + &self, + user_id: crate::users::UserId, + media_id: MediaId, + progress_secs: f64, + ) -> Result<()> { + let conn = self.conn.clone(); + let user_id_str = user_id.0.to_string(); + let media_id_str = media_id.0.to_string(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + let id = Uuid::now_v7().to_string(); + let now = chrono::Utc::now(); + db.execute( + "INSERT INTO watch_history (id, user_id, media_id, progress_secs, \ + last_watched) VALUES (?, ?, ?, ?, ?) ON CONFLICT(user_id, media_id) \ + DO UPDATE SET progress_secs = excluded.progress_secs, last_watched = \ + excluded.last_watched", + params![ + &id, + &user_id_str, + &media_id_str, + progress_secs, + now.to_rfc3339() + ], + )?; + Ok(()) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("update_watch_progress timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn update_watch_progress( - &self, - user_id: crate::users::UserId, - media_id: MediaId, - progress_secs: f64, - ) -> Result<()> { - let conn = self.conn.clone(); - let user_id_str = user_id.0.to_string(); - let media_id_str = media_id.0.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - let id = Uuid::now_v7().to_string(); - let now = chrono::Utc::now(); - db.execute( - "INSERT INTO watch_history (id, user_id, media_id, progress_secs, last_watched) VALUES (?, ?, ?, ?, ?) ON CONFLICT(user_id, media_id) DO UPDATE SET progress_secs = excluded.progress_secs, last_watched = excluded.last_watched", - params![&id, &user_id_str, &media_id_str, progress_secs, now.to_rfc3339()], - )?; - Ok(()) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("update_watch_progress timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + async fn get_watch_progress( + &self, + user_id: crate::users::UserId, + media_id: MediaId, + ) -> Result> { + let conn = self.conn.clone(); + let user_id_str = user_id.0.to_string(); + let media_id_str = media_id.0.to_string(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + let result = db + .query_row( + "SELECT progress_secs FROM watch_history WHERE user_id = ? AND \ + media_id = ?", + params![&user_id_str, &media_id_str], + |row| row.get(0), + ) + .optional()?; + Ok(result) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("get_watch_progress timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn get_watch_progress( - &self, - user_id: crate::users::UserId, - media_id: MediaId, - ) -> Result> { - let conn = self.conn.clone(); - let user_id_str = user_id.0.to_string(); - let media_id_str = media_id.0.to_string(); - let fut = - tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - let result = db.query_row( - "SELECT progress_secs FROM watch_history WHERE user_id = ? AND media_id = ?", - params![&user_id_str, &media_id_str], - |row| row.get(0), - ).optional()?; - Ok(result) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("get_watch_progress timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + async fn cleanup_old_events( + &self, + before: chrono::DateTime, + ) -> Result { + let conn = self.conn.clone(); + let before_str = before.to_rfc3339(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + let affected = db + .execute("DELETE FROM usage_events WHERE timestamp < ?", [ + &before_str, + ])?; + Ok(affected as u64) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("cleanup_old_events timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn cleanup_old_events(&self, before: chrono::DateTime) -> Result { - let conn = self.conn.clone(); - let before_str = before.to_rfc3339(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - let affected = db.execute( - "DELETE FROM usage_events WHERE timestamp < ?", - [&before_str], - )?; - Ok(affected as u64) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("cleanup_old_events timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + // ===== Subtitles ===== + async fn add_subtitle( + &self, + subtitle: &crate::subtitles::Subtitle, + ) -> Result<()> { + let conn = self.conn.clone(); + let id_str = subtitle.id.to_string(); + let media_id_str = subtitle.media_id.0.to_string(); + let language = subtitle.language.clone(); + let format = subtitle.format.to_string(); + let file_path = subtitle + .file_path + .as_ref() + .map(|p| p.to_string_lossy().to_string()); + let is_embedded = subtitle.is_embedded; + let track_index = subtitle.track_index.map(|i| i as i64); + let offset_ms = subtitle.offset_ms; + let now = subtitle.created_at.to_rfc3339(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + db.execute( + "INSERT INTO subtitles (id, media_id, language, format, file_path, \ + is_embedded, track_index, offset_ms, created_at) VALUES (?, ?, ?, ?, \ + ?, ?, ?, ?, ?)", + params![ + &id_str, + &media_id_str, + &language, + &format, + &file_path, + is_embedded as i32, + &track_index, + offset_ms, + &now + ], + )?; + Ok(()) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| PinakesError::Database("add_subtitle timed out".into()))? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - // ===== Subtitles ===== - async fn add_subtitle(&self, subtitle: &crate::subtitles::Subtitle) -> Result<()> { - let conn = self.conn.clone(); - let id_str = subtitle.id.to_string(); - let media_id_str = subtitle.media_id.0.to_string(); - let language = subtitle.language.clone(); - let format = subtitle.format.to_string(); - let file_path = subtitle - .file_path - .as_ref() - .map(|p| p.to_string_lossy().to_string()); - let is_embedded = subtitle.is_embedded; - let track_index = subtitle.track_index.map(|i| i as i64); - let offset_ms = subtitle.offset_ms; - let now = subtitle.created_at.to_rfc3339(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - db.execute( - "INSERT INTO subtitles (id, media_id, language, format, file_path, is_embedded, track_index, offset_ms, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - params![&id_str, &media_id_str, &language, &format, &file_path, is_embedded as i32, &track_index, offset_ms, &now], - )?; - Ok(()) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("add_subtitle timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + async fn get_media_subtitles( + &self, + media_id: MediaId, + ) -> Result> { + let conn = self.conn.clone(); + let media_id_str = media_id.0.to_string(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + let mut stmt = db.prepare( + "SELECT id, media_id, language, format, file_path, is_embedded, \ + track_index, offset_ms, created_at FROM subtitles WHERE media_id = ?", + )?; + let subtitles = stmt + .query_map([&media_id_str], |row| { + let id_str: String = row.get(0)?; + let mid_str: String = row.get(1)?; + let format_str: String = row.get(3)?; + let created_str: String = row.get(8)?; + Ok(crate::subtitles::Subtitle { + id: parse_uuid(&id_str)?, + media_id: MediaId(parse_uuid(&mid_str)?), + language: row.get(2)?, + format: format_str + .parse() + .unwrap_or(crate::subtitles::SubtitleFormat::Srt), + file_path: row + .get::<_, Option>(4)? + .map(std::path::PathBuf::from), + is_embedded: row.get::<_, i32>(5)? != 0, + track_index: row.get::<_, Option>(6)?.map(|i| i as usize), + offset_ms: row.get(7)?, + created_at: parse_datetime(&created_str), + }) + })? + .filter_map(|r| r.ok()) + .collect(); + Ok(subtitles) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("get_media_subtitles timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn get_media_subtitles( - &self, - media_id: MediaId, - ) -> Result> { - let conn = self.conn.clone(); - let media_id_str = media_id.0.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - let mut stmt = db.prepare( - "SELECT id, media_id, language, format, file_path, is_embedded, track_index, offset_ms, created_at FROM subtitles WHERE media_id = ?" - )?; - let subtitles = stmt - .query_map([&media_id_str], |row| { - let id_str: String = row.get(0)?; - let mid_str: String = row.get(1)?; - let format_str: String = row.get(3)?; - let created_str: String = row.get(8)?; - Ok(crate::subtitles::Subtitle { - id: parse_uuid(&id_str)?, - media_id: MediaId(parse_uuid(&mid_str)?), - language: row.get(2)?, - format: format_str - .parse() - .unwrap_or(crate::subtitles::SubtitleFormat::Srt), - file_path: row - .get::<_, Option>(4)? - .map(std::path::PathBuf::from), - is_embedded: row.get::<_, i32>(5)? != 0, - track_index: row.get::<_, Option>(6)?.map(|i| i as usize), - offset_ms: row.get(7)?, - created_at: parse_datetime(&created_str), - }) - })? - .filter_map(|r| r.ok()) - .collect(); - Ok(subtitles) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("get_media_subtitles timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + async fn delete_subtitle(&self, id: Uuid) -> Result<()> { + let conn = self.conn.clone(); + let id_str = id.to_string(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + db.execute("DELETE FROM subtitles WHERE id = ?", [&id_str])?; + Ok(()) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| PinakesError::Database("delete_subtitle timed out".into()))? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn delete_subtitle(&self, id: Uuid) -> Result<()> { - let conn = self.conn.clone(); - let id_str = id.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - db.execute("DELETE FROM subtitles WHERE id = ?", [&id_str])?; - Ok(()) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("delete_subtitle timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + async fn update_subtitle_offset( + &self, + id: Uuid, + offset_ms: i64, + ) -> Result<()> { + let conn = self.conn.clone(); + let id_str = id.to_string(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + db.execute("UPDATE subtitles SET offset_ms = ? WHERE id = ?", params![ + offset_ms, &id_str + ])?; + Ok(()) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("update_subtitle_offset timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn update_subtitle_offset(&self, id: Uuid, offset_ms: i64) -> Result<()> { - let conn = self.conn.clone(); - let id_str = id.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - db.execute( - "UPDATE subtitles SET offset_ms = ? WHERE id = ?", - params![offset_ms, &id_str], - )?; - Ok(()) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("update_subtitle_offset timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + // ===== External Metadata (Enrichment) ===== + async fn store_external_metadata( + &self, + meta: &crate::enrichment::ExternalMetadata, + ) -> Result<()> { + let conn = self.conn.clone(); + let id_str = meta.id.to_string(); + let media_id_str = meta.media_id.0.to_string(); + let source = meta.source.to_string(); + let external_id = meta.external_id.clone(); + let metadata_json = meta.metadata_json.clone(); + let confidence = meta.confidence; + let last_updated = meta.last_updated.to_rfc3339(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + db.execute( + "INSERT OR REPLACE INTO external_metadata (id, media_id, source, \ + external_id, metadata_json, confidence, last_updated) VALUES (?, ?, \ + ?, ?, ?, ?, ?)", + params![ + &id_str, + &media_id_str, + &source, + &external_id, + &metadata_json, + confidence, + &last_updated + ], + )?; + Ok(()) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("store_external_metadata timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - // ===== External Metadata (Enrichment) ===== - async fn store_external_metadata( - &self, - meta: &crate::enrichment::ExternalMetadata, - ) -> Result<()> { - let conn = self.conn.clone(); - let id_str = meta.id.to_string(); - let media_id_str = meta.media_id.0.to_string(); - let source = meta.source.to_string(); - let external_id = meta.external_id.clone(); - let metadata_json = meta.metadata_json.clone(); - let confidence = meta.confidence; - let last_updated = meta.last_updated.to_rfc3339(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - db.execute( - "INSERT OR REPLACE INTO external_metadata (id, media_id, source, external_id, metadata_json, confidence, last_updated) VALUES (?, ?, ?, ?, ?, ?, ?)", - params![&id_str, &media_id_str, &source, &external_id, &metadata_json, confidence, &last_updated], - )?; - Ok(()) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("store_external_metadata timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + async fn get_external_metadata( + &self, + media_id: MediaId, + ) -> Result> { + let conn = self.conn.clone(); + let media_id_str = media_id.0.to_string(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + let mut stmt = db.prepare( + "SELECT id, media_id, source, external_id, metadata_json, confidence, \ + last_updated FROM external_metadata WHERE media_id = ?", + )?; + let metas = stmt + .query_map([&media_id_str], |row| { + let id_str: String = row.get(0)?; + let mid_str: String = row.get(1)?; + let source_str: String = row.get(2)?; + let updated_str: String = row.get(6)?; + Ok(crate::enrichment::ExternalMetadata { + id: parse_uuid(&id_str)?, + media_id: MediaId(parse_uuid(&mid_str)?), + source: source_str + .parse() + .unwrap_or(crate::enrichment::EnrichmentSourceType::MusicBrainz), + external_id: row.get(3)?, + metadata_json: row.get(4)?, + confidence: row.get(5)?, + last_updated: parse_datetime(&updated_str), + }) + })? + .filter_map(|r| r.ok()) + .collect(); + Ok(metas) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("get_external_metadata timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn get_external_metadata( - &self, - media_id: MediaId, - ) -> Result> { - let conn = self.conn.clone(); - let media_id_str = media_id.0.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - let mut stmt = db.prepare( - "SELECT id, media_id, source, external_id, metadata_json, confidence, last_updated FROM external_metadata WHERE media_id = ?" - )?; - let metas = stmt - .query_map([&media_id_str], |row| { - let id_str: String = row.get(0)?; - let mid_str: String = row.get(1)?; - let source_str: String = row.get(2)?; - let updated_str: String = row.get(6)?; - Ok(crate::enrichment::ExternalMetadata { - id: parse_uuid(&id_str)?, - media_id: MediaId(parse_uuid(&mid_str)?), - source: source_str - .parse() - .unwrap_or(crate::enrichment::EnrichmentSourceType::MusicBrainz), - external_id: row.get(3)?, - metadata_json: row.get(4)?, - confidence: row.get(5)?, - last_updated: parse_datetime(&updated_str), - }) - })? - .filter_map(|r| r.ok()) - .collect(); - Ok(metas) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("get_external_metadata timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + async fn delete_external_metadata(&self, id: Uuid) -> Result<()> { + let conn = self.conn.clone(); + let id_str = id.to_string(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + db.execute("DELETE FROM external_metadata WHERE id = ?", [&id_str])?; + Ok(()) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("delete_external_metadata timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn delete_external_metadata(&self, id: Uuid) -> Result<()> { - let conn = self.conn.clone(); - let id_str = id.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - db.execute("DELETE FROM external_metadata WHERE id = ?", [&id_str])?; - Ok(()) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("delete_external_metadata timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + // ===== Transcode Sessions ===== + async fn create_transcode_session( + &self, + session: &crate::transcode::TranscodeSession, + ) -> Result<()> { + let conn = self.conn.clone(); + let id_str = session.id.to_string(); + let media_id_str = session.media_id.0.to_string(); + let user_id_str = session.user_id.map(|u| u.0.to_string()); + let profile = session.profile.clone(); + let cache_path = session.cache_path.to_string_lossy().to_string(); + let status = session.status.as_str().to_string(); + let progress = session.progress; + let error_message = session.status.error_message().map(String::from); + let created_at = session.created_at.to_rfc3339(); + let expires_at = session.expires_at.map(|dt| dt.to_rfc3339()); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + db.execute( + "INSERT INTO transcode_sessions (id, media_id, user_id, profile, \ + cache_path, status, progress, error_message, created_at, expires_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + &id_str, + &media_id_str, + &user_id_str, + &profile, + &cache_path, + &status, + progress, + &error_message, + &created_at, + &expires_at + ], + )?; + Ok(()) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("create_transcode_session timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - // ===== Transcode Sessions ===== - async fn create_transcode_session( - &self, - session: &crate::transcode::TranscodeSession, - ) -> Result<()> { - let conn = self.conn.clone(); - let id_str = session.id.to_string(); - let media_id_str = session.media_id.0.to_string(); - let user_id_str = session.user_id.map(|u| u.0.to_string()); - let profile = session.profile.clone(); - let cache_path = session.cache_path.to_string_lossy().to_string(); - let status = session.status.as_str().to_string(); - let progress = session.progress; - let error_message = session.status.error_message().map(String::from); - let created_at = session.created_at.to_rfc3339(); - let expires_at = session.expires_at.map(|dt| dt.to_rfc3339()); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - db.execute( - "INSERT INTO transcode_sessions (id, media_id, user_id, profile, cache_path, status, progress, error_message, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - params![&id_str, &media_id_str, &user_id_str, &profile, &cache_path, &status, progress, &error_message, &created_at, &expires_at], - )?; - Ok(()) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("create_transcode_session timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + async fn get_transcode_session( + &self, + id: Uuid, + ) -> Result { + let conn = self.conn.clone(); + let id_str = id.to_string(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + db.query_row( + "SELECT id, media_id, user_id, profile, cache_path, status, progress, \ + error_message, created_at, expires_at FROM transcode_sessions WHERE \ + id = ?", + [&id_str], + |row| { + let id_str: String = row.get(0)?; + let mid_str: String = row.get(1)?; + let uid_str: Option = row.get(2)?; + let status_str: String = row.get(5)?; + let error_msg: Option = row.get(7)?; + let created_str: String = row.get(8)?; + let expires_str: Option = row.get(9)?; + Ok(crate::transcode::TranscodeSession { + id: parse_uuid(&id_str)?, + media_id: MediaId(parse_uuid(&mid_str)?), + user_id: uid_str + .and_then(|s| Uuid::parse_str(&s).ok()) + .map(crate::users::UserId), + profile: row.get(3)?, + cache_path: std::path::PathBuf::from(row.get::<_, String>(4)?), + status: crate::transcode::TranscodeStatus::from_db( + &status_str, + error_msg.as_deref(), + ), + progress: row.get(6)?, + created_at: parse_datetime(&created_str), + expires_at: expires_str.map(|s| parse_datetime(&s)), + duration_secs: None, + child_cancel: None, + }) + }, + ) + .map_err(|e| { + match e { + rusqlite::Error::QueryReturnedNoRows => { + PinakesError::NotFound(format!("transcode session {id}")) + }, + _ => PinakesError::Database(e.to_string()), + } + }) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("get_transcode_session timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn get_transcode_session(&self, id: Uuid) -> Result { - let conn = self.conn.clone(); - let id_str = id.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - db.query_row( - "SELECT id, media_id, user_id, profile, cache_path, status, progress, error_message, created_at, expires_at FROM transcode_sessions WHERE id = ?", - [&id_str], - |row| { - let id_str: String = row.get(0)?; - let mid_str: String = row.get(1)?; - let uid_str: Option = row.get(2)?; - let status_str: String = row.get(5)?; - let error_msg: Option = row.get(7)?; - let created_str: String = row.get(8)?; - let expires_str: Option = row.get(9)?; - Ok(crate::transcode::TranscodeSession { - id: parse_uuid(&id_str)?, - media_id: MediaId(parse_uuid(&mid_str)?), - user_id: uid_str.and_then(|s| Uuid::parse_str(&s).ok()).map(crate::users::UserId), - profile: row.get(3)?, - cache_path: std::path::PathBuf::from(row.get::<_, String>(4)?), - status: crate::transcode::TranscodeStatus::from_db(&status_str, error_msg.as_deref()), - progress: row.get(6)?, - created_at: parse_datetime(&created_str), - expires_at: expires_str.map(|s| parse_datetime(&s)), - duration_secs: None, - child_cancel: None, - }) - }, - ).map_err(|e| match e { - rusqlite::Error::QueryReturnedNoRows => PinakesError::NotFound(format!("transcode session {id}")), - _ => PinakesError::Database(e.to_string()), - }) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("get_transcode_session timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + async fn list_transcode_sessions( + &self, + media_id: Option, + ) -> Result> { + let conn = self.conn.clone(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + let (sql, param) = match media_id { + Some(mid) => { + ( + "SELECT id, media_id, user_id, profile, cache_path, status, \ + progress, error_message, created_at, expires_at FROM \ + transcode_sessions WHERE media_id = ? ORDER BY created_at DESC" + .to_string(), + Some(mid.0.to_string()), + ) + }, + None => { + ( + "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" + .to_string(), + None, + ) + }, + }; + let mut stmt = db.prepare(&sql)?; + let parse_row = + |row: &Row| -> rusqlite::Result { + let id_str: String = row.get(0)?; + let mid_str: String = row.get(1)?; + let uid_str: Option = row.get(2)?; + let status_str: String = row.get(5)?; + let error_msg: Option = row.get(7)?; + let created_str: String = row.get(8)?; + let expires_str: Option = row.get(9)?; + Ok(crate::transcode::TranscodeSession { + id: parse_uuid(&id_str)?, + media_id: MediaId(parse_uuid(&mid_str)?), + user_id: uid_str + .and_then(|s| Uuid::parse_str(&s).ok()) + .map(crate::users::UserId), + profile: row.get(3)?, + cache_path: std::path::PathBuf::from(row.get::<_, String>(4)?), + status: crate::transcode::TranscodeStatus::from_db( + &status_str, + error_msg.as_deref(), + ), + progress: row.get(6)?, + created_at: parse_datetime(&created_str), + expires_at: expires_str.map(|s| parse_datetime(&s)), + duration_secs: None, + child_cancel: None, + }) + }; + let sessions: Vec<_> = if let Some(ref p) = param { + stmt + .query_map([p], parse_row)? + .filter_map(|r| r.ok()) + .collect() + } else { + stmt + .query_map([], parse_row)? + .filter_map(|r| r.ok()) + .collect() + }; + Ok(sessions) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("list_transcode_sessions timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn list_transcode_sessions( - &self, - media_id: Option, - ) -> Result> { - let conn = self.conn.clone(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - let (sql, param) = match media_id { - Some(mid) => ( - "SELECT id, media_id, user_id, profile, cache_path, status, progress, error_message, created_at, expires_at FROM transcode_sessions WHERE media_id = ? ORDER BY created_at DESC".to_string(), - Some(mid.0.to_string()), - ), - None => ( - "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".to_string(), - None, - ), - }; - let mut stmt = db.prepare(&sql)?; - let parse_row = |row: &Row| -> rusqlite::Result { - let id_str: String = row.get(0)?; - let mid_str: String = row.get(1)?; - let uid_str: Option = row.get(2)?; - let status_str: String = row.get(5)?; - let error_msg: Option = row.get(7)?; - let created_str: String = row.get(8)?; - let expires_str: Option = row.get(9)?; - Ok(crate::transcode::TranscodeSession { - id: parse_uuid(&id_str)?, - media_id: MediaId(parse_uuid(&mid_str)?), - user_id: uid_str - .and_then(|s| Uuid::parse_str(&s).ok()) - .map(crate::users::UserId), - profile: row.get(3)?, - cache_path: std::path::PathBuf::from(row.get::<_, String>(4)?), - status: crate::transcode::TranscodeStatus::from_db( - &status_str, - error_msg.as_deref(), - ), - progress: row.get(6)?, - created_at: parse_datetime(&created_str), - expires_at: expires_str.map(|s| parse_datetime(&s)), - duration_secs: None, - child_cancel: None, - }) - }; - let sessions: Vec<_> = if let Some(ref p) = param { - stmt.query_map([p], parse_row)? - .filter_map(|r| r.ok()) - .collect() - } else { - stmt.query_map([], parse_row)? - .filter_map(|r| r.ok()) - .collect() - }; - Ok(sessions) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("list_transcode_sessions timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + async fn update_transcode_status( + &self, + id: Uuid, + status: crate::transcode::TranscodeStatus, + progress: f32, + ) -> Result<()> { + let conn = self.conn.clone(); + let id_str = id.to_string(); + let status_str = status.as_str().to_string(); + let error_message = status.error_message().map(String::from); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + db.execute( + "UPDATE transcode_sessions SET status = ?, progress = ?, \ + error_message = ? WHERE id = ?", + params![&status_str, progress, &error_message, &id_str], + )?; + Ok(()) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("update_transcode_status timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn update_transcode_status( - &self, - id: Uuid, - status: crate::transcode::TranscodeStatus, - progress: f32, - ) -> Result<()> { - let conn = self.conn.clone(); - let id_str = id.to_string(); - let status_str = status.as_str().to_string(); - let error_message = status.error_message().map(String::from); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - db.execute( - "UPDATE transcode_sessions SET status = ?, progress = ?, error_message = ? WHERE id = ?", - params![&status_str, progress, &error_message, &id_str], - )?; - Ok(()) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("update_transcode_status timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + async fn cleanup_expired_transcodes( + &self, + before: chrono::DateTime, + ) -> Result { + let conn = self.conn.clone(); + let before_str = before.to_rfc3339(); + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + let affected = db.execute( + "DELETE FROM transcode_sessions WHERE expires_at IS NOT NULL AND \ + expires_at < ?", + [&before_str], + )?; + Ok(affected as u64) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("cleanup_expired_transcodes timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn cleanup_expired_transcodes( - &self, - before: chrono::DateTime, - ) -> Result { - let conn = self.conn.clone(); - let before_str = before.to_rfc3339(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - let affected = db.execute( - "DELETE FROM transcode_sessions WHERE expires_at IS NOT NULL AND expires_at < ?", - [&before_str], - )?; - Ok(affected as u64) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("cleanup_expired_transcodes timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + // ===== Session Management ===== - // ===== Session Management ===== + async fn create_session( + &self, + session: &crate::storage::SessionData, + ) -> Result<()> { + let conn = self.conn.clone(); + let session_token = session.session_token.clone(); + let user_id = session.user_id.clone(); + let username = session.username.clone(); + let role = session.role.clone(); + let created_at = session.created_at.to_rfc3339(); + let expires_at = session.expires_at.to_rfc3339(); + let last_accessed = session.last_accessed.to_rfc3339(); - async fn create_session(&self, session: &crate::storage::SessionData) -> Result<()> { - let conn = self.conn.clone(); - let session_token = session.session_token.clone(); - let user_id = session.user_id.clone(); - let username = session.username.clone(); - let role = session.role.clone(); - let created_at = session.created_at.to_rfc3339(); - let expires_at = session.expires_at.to_rfc3339(); - let last_accessed = session.last_accessed.to_rfc3339(); - - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - db.execute( - "INSERT INTO sessions (session_token, user_id, username, role, created_at, expires_at, last_accessed) + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + db.execute( + "INSERT INTO sessions (session_token, user_id, username, role, \ + created_at, expires_at, last_accessed) VALUES (?, ?, ?, ?, ?, ?, ?)", - params![ - &session_token, - &user_id, - &username, - &role, - &created_at, - &expires_at, - &last_accessed - ], - )?; - Ok(()) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("create_session timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + params![ + &session_token, + &user_id, + &username, + &role, + &created_at, + &expires_at, + &last_accessed + ], + )?; + Ok(()) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| PinakesError::Database("create_session timed out".into()))? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn get_session( - &self, - session_token: &str, - ) -> Result> { - let conn = self.conn.clone(); - let token = session_token.to_string(); + async fn get_session( + &self, + session_token: &str, + ) -> Result> { + let conn = self.conn.clone(); + let token = session_token.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; - let result = db - .query_row( - "SELECT session_token, user_id, username, role, created_at, expires_at, last_accessed + let result = db + .query_row( + "SELECT session_token, user_id, username, role, created_at, \ + expires_at, last_accessed FROM sessions WHERE session_token = ?", - [&token], - |row| { - let created_at_str: String = row.get(4)?; - let expires_at_str: String = row.get(5)?; - let last_accessed_str: String = row.get(6)?; + [&token], + |row| { + let created_at_str: String = row.get(4)?; + let expires_at_str: String = row.get(5)?; + let last_accessed_str: String = row.get(6)?; - Ok(crate::storage::SessionData { - session_token: row.get(0)?, - user_id: row.get(1)?, - username: row.get(2)?, - role: row.get(3)?, - created_at: chrono::DateTime::parse_from_rfc3339(&created_at_str) - .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))? - .with_timezone(&chrono::Utc), - expires_at: chrono::DateTime::parse_from_rfc3339(&expires_at_str) - .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))? - .with_timezone(&chrono::Utc), - last_accessed: chrono::DateTime::parse_from_rfc3339(&last_accessed_str) - .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))? - .with_timezone(&chrono::Utc), - }) - }, - ) - .optional()?; + Ok(crate::storage::SessionData { + session_token: row.get(0)?, + user_id: row.get(1)?, + username: row.get(2)?, + role: row.get(3)?, + created_at: chrono::DateTime::parse_from_rfc3339( + &created_at_str, + ) + .map_err(|e| { + rusqlite::Error::ToSqlConversionFailure(Box::new(e)) + })? + .with_timezone(&chrono::Utc), + expires_at: chrono::DateTime::parse_from_rfc3339( + &expires_at_str, + ) + .map_err(|e| { + rusqlite::Error::ToSqlConversionFailure(Box::new(e)) + })? + .with_timezone(&chrono::Utc), + last_accessed: chrono::DateTime::parse_from_rfc3339( + &last_accessed_str, + ) + .map_err(|e| { + rusqlite::Error::ToSqlConversionFailure(Box::new(e)) + })? + .with_timezone(&chrono::Utc), + }) + }, + ) + .optional()?; - Ok(result) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("get_session timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + Ok(result) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| PinakesError::Database("get_session timed out".into()))? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn touch_session(&self, session_token: &str) -> Result<()> { - let conn = self.conn.clone(); - let token = session_token.to_string(); - let now = chrono::Utc::now().to_rfc3339(); + async fn touch_session(&self, session_token: &str) -> Result<()> { + let conn = self.conn.clone(); + let token = session_token.to_string(); + let now = chrono::Utc::now().to_rfc3339(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - db.execute( - "UPDATE sessions SET last_accessed = ? WHERE session_token = ?", - params![&now, &token], - )?; - Ok(()) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("touch_session timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + db.execute( + "UPDATE sessions SET last_accessed = ? WHERE session_token = ?", + params![&now, &token], + )?; + Ok(()) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| PinakesError::Database("touch_session timed out".into()))? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn delete_session(&self, session_token: &str) -> Result<()> { - let conn = self.conn.clone(); - let token = session_token.to_string(); + async fn delete_session(&self, session_token: &str) -> Result<()> { + let conn = self.conn.clone(); + let token = session_token.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - db.execute("DELETE FROM sessions WHERE session_token = ?", [&token])?; - Ok(()) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("delete_session timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + db.execute("DELETE FROM sessions WHERE session_token = ?", [&token])?; + Ok(()) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| PinakesError::Database("delete_session timed out".into()))? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn delete_user_sessions(&self, username: &str) -> Result { - let conn = self.conn.clone(); - let user = username.to_string(); + async fn delete_user_sessions(&self, username: &str) -> Result { + let conn = self.conn.clone(); + let user = username.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - let affected = db.execute("DELETE FROM sessions WHERE username = ?", [&user])?; - Ok(affected as u64) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("delete_user_sessions timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + let affected = + db.execute("DELETE FROM sessions WHERE username = ?", [&user])?; + Ok(affected as u64) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("delete_user_sessions timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn delete_expired_sessions(&self) -> Result { - let conn = self.conn.clone(); - let now = chrono::Utc::now().to_rfc3339(); + async fn delete_expired_sessions(&self) -> Result { + let conn = self.conn.clone(); + let now = chrono::Utc::now().to_rfc3339(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; - let affected = db.execute("DELETE FROM sessions WHERE expires_at < ?", [&now])?; - Ok(affected as u64) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("delete_expired_sessions timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; + let affected = + db.execute("DELETE FROM sessions WHERE expires_at < ?", [&now])?; + Ok(affected as u64) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("delete_expired_sessions timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - async fn list_active_sessions( - &self, - username: Option<&str>, - ) -> Result> { - let conn = self.conn.clone(); - let user_filter = username.map(|s| s.to_string()); - let now = chrono::Utc::now().to_rfc3339(); + async fn list_active_sessions( + &self, + username: Option<&str>, + ) -> Result> { + let conn = self.conn.clone(); + let user_filter = username.map(|s| s.to_string()); + let now = chrono::Utc::now().to_rfc3339(); - let fut = tokio::task::spawn_blocking(move || { - let db = conn.lock().map_err(|e| { - PinakesError::Database(format!("failed to acquire database lock: {}", e)) - })?; + let fut = tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| { + PinakesError::Database(format!( + "failed to acquire database lock: {}", + e + )) + })?; - let (query, params): (&str, Vec) = if let Some(user) = user_filter { - ( - "SELECT session_token, user_id, username, role, created_at, expires_at, last_accessed + let (query, params): (&str, Vec) = if let Some(user) = user_filter + { + ( + "SELECT session_token, user_id, username, role, created_at, \ + expires_at, last_accessed FROM sessions WHERE expires_at > ? AND username = ? ORDER BY last_accessed DESC", - vec![now, user], - ) - } else { - ( - "SELECT session_token, user_id, username, role, created_at, expires_at, last_accessed + vec![now, user], + ) + } else { + ( + "SELECT session_token, user_id, username, role, created_at, \ + expires_at, last_accessed FROM sessions WHERE expires_at > ? ORDER BY last_accessed DESC", - vec![now], - ) - }; + vec![now], + ) + }; - let mut stmt = db.prepare(query)?; - let param_refs: Vec<&dyn rusqlite::ToSql> = - params.iter().map(|p| p as &dyn rusqlite::ToSql).collect(); - let rows = stmt.query_map(¶m_refs[..], |row| { - let created_at_str: String = row.get(4)?; - let expires_at_str: String = row.get(5)?; - let last_accessed_str: String = row.get(6)?; + let mut stmt = db.prepare(query)?; + let param_refs: Vec<&dyn rusqlite::ToSql> = + params.iter().map(|p| p as &dyn rusqlite::ToSql).collect(); + let rows = stmt.query_map(¶m_refs[..], |row| { + let created_at_str: String = row.get(4)?; + let expires_at_str: String = row.get(5)?; + let last_accessed_str: String = row.get(6)?; - Ok(crate::storage::SessionData { - session_token: row.get(0)?, - user_id: row.get(1)?, - username: row.get(2)?, - role: row.get(3)?, - created_at: chrono::DateTime::parse_from_rfc3339(&created_at_str) - .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))? - .with_timezone(&chrono::Utc), - expires_at: chrono::DateTime::parse_from_rfc3339(&expires_at_str) - .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))? - .with_timezone(&chrono::Utc), - last_accessed: chrono::DateTime::parse_from_rfc3339(&last_accessed_str) - .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))? - .with_timezone(&chrono::Utc), - }) - })?; + Ok(crate::storage::SessionData { + session_token: row.get(0)?, + user_id: row.get(1)?, + username: row.get(2)?, + role: row.get(3)?, + created_at: chrono::DateTime::parse_from_rfc3339(&created_at_str) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))? + .with_timezone(&chrono::Utc), + expires_at: chrono::DateTime::parse_from_rfc3339(&expires_at_str) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))? + .with_timezone(&chrono::Utc), + last_accessed: chrono::DateTime::parse_from_rfc3339( + &last_accessed_str, + ) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))? + .with_timezone(&chrono::Utc), + }) + })?; - rows.collect::, _>>() - .map_err(|e| e.into()) - }); - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("list_active_sessions timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))? - } + rows + .collect::, _>>() + .map_err(|e| e.into()) + }); + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("list_active_sessions timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })? + } - // Book Management Methods + // Book Management Methods - async fn upsert_book_metadata(&self, metadata: &crate::model::BookMetadata) -> Result<()> { - let conn = self.conn.clone(); - let media_id_str = metadata.media_id.to_string(); - let isbn = metadata.isbn.clone(); - let isbn13 = metadata.isbn13.clone(); - let publisher = metadata.publisher.clone(); - let language = metadata.language.clone(); - let page_count = metadata.page_count; - let publication_date = metadata.publication_date.map(|d| d.to_string()); - let series_name = metadata.series_name.clone(); - let series_index = metadata.series_index; - let format = metadata.format.clone(); - let authors = metadata.authors.clone(); - let identifiers = metadata.identifiers.clone(); + async fn upsert_book_metadata( + &self, + metadata: &crate::model::BookMetadata, + ) -> Result<()> { + let conn = self.conn.clone(); + let media_id_str = metadata.media_id.to_string(); + let isbn = metadata.isbn.clone(); + let isbn13 = metadata.isbn13.clone(); + let publisher = metadata.publisher.clone(); + let language = metadata.language.clone(); + let page_count = metadata.page_count; + let publication_date = metadata.publication_date.map(|d| d.to_string()); + let series_name = metadata.series_name.clone(); + let series_index = metadata.series_index; + let format = metadata.format.clone(); + let authors = metadata.authors.clone(); + let identifiers = metadata.identifiers.clone(); - let fut = tokio::task::spawn_blocking(move || { - let mut conn = conn.lock().unwrap(); - let tx = conn.transaction()?; + let fut = tokio::task::spawn_blocking(move || { + let mut conn = conn.lock().unwrap(); + let tx = conn.transaction()?; - // Upsert book_metadata - tx.execute( - "INSERT INTO book_metadata ( + // 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 = datetime('now')", - rusqlite::params![ - media_id_str, - isbn, - isbn13, - publisher, - language, - page_count, - publication_date, - series_name, - series_index, - format - ], - )?; + series_index = ?9, format = ?10, updated_at = \ + datetime('now')", + rusqlite::params![ + media_id_str, + isbn, + isbn13, + publisher, + language, + page_count, + publication_date, + series_name, + series_index, + format + ], + )?; - // Clear existing authors and identifiers - tx.execute( - "DELETE FROM book_authors WHERE media_id = ?1", - [&media_id_str], - )?; - tx.execute( - "DELETE FROM book_identifiers WHERE media_id = ?1", - [&media_id_str], - )?; + // Clear existing authors and identifiers + tx.execute("DELETE FROM book_authors WHERE media_id = ?1", [ + &media_id_str, + ])?; + tx.execute("DELETE FROM book_identifiers WHERE media_id = ?1", [ + &media_id_str, + ])?; - // Insert authors - for author in &authors { - tx.execute( - "INSERT INTO book_authors (media_id, author_name, author_sort, role, position) + // Insert authors + for author in &authors { + tx.execute( + "INSERT INTO book_authors (media_id, author_name, author_sort, \ + role, position) VALUES (?1, ?2, ?3, ?4, ?5)", - rusqlite::params![ - media_id_str, - author.name, - author.file_as, - author.role, - author.position - ], - )?; - } + rusqlite::params![ + media_id_str, + author.name, + author.file_as, + author.role, + author.position + ], + )?; + } - // Insert identifiers - for (id_type, values) in &identifiers { - for value in values { - tx.execute( - "INSERT INTO book_identifiers (media_id, identifier_type, identifier_value) + // Insert identifiers + for (id_type, values) in &identifiers { + for value in values { + tx.execute( + "INSERT INTO book_identifiers (media_id, identifier_type, \ + identifier_value) VALUES (?1, ?2, ?3)", - rusqlite::params![media_id_str, id_type, value], - )?; - } - } + rusqlite::params![media_id_str, id_type, value], + )?; + } + } - tx.commit()?; - Ok::<_, rusqlite::Error>(()) - }); + tx.commit()?; + Ok::<_, rusqlite::Error>(()) + }); - tokio::time::timeout(std::time::Duration::from_secs(30), fut) - .await - .map_err(|_| PinakesError::Database("upsert_book_metadata timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))??; - Ok(()) - } + tokio::time::timeout(std::time::Duration::from_secs(30), fut) + .await + .map_err(|_| { + PinakesError::Database("upsert_book_metadata timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })??; + Ok(()) + } - async fn get_book_metadata( - &self, - media_id: MediaId, - ) -> Result> { - let conn = self.conn.clone(); - let media_id_str = media_id.to_string(); + async fn get_book_metadata( + &self, + media_id: MediaId, + ) -> Result> { + let conn = self.conn.clone(); + let media_id_str = media_id.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let fut = tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); - // Get base book metadata - let metadata_row = conn - .query_row( - "SELECT isbn, isbn13, publisher, language, page_count, + // Get base book metadata + let metadata_row = conn + .query_row( + "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_str], - |row| { - Ok(( - row.get::<_, Option>(0)?, - row.get::<_, Option>(1)?, - row.get::<_, Option>(2)?, - row.get::<_, Option>(3)?, - row.get::<_, Option>(4)?, - row.get::<_, Option>(5)?, - row.get::<_, Option>(6)?, - row.get::<_, Option>(7)?, - row.get::<_, Option>(8)?, - row.get::<_, String>(9)?, - row.get::<_, String>(10)?, - )) - }, - ) - .optional()?; - - if metadata_row.is_none() { - return Ok::<_, rusqlite::Error>(None); - } - - let ( - isbn, - isbn13, - publisher, - language, - page_count, - publication_date, - series_name, - series_index, - format, - created_at, - updated_at, - ) = metadata_row.unwrap(); - - // Get authors - let mut stmt = conn.prepare( - "SELECT author_name, author_sort, role, position - FROM book_authors WHERE media_id = ?1 ORDER BY position", - )?; - let authors: Vec = stmt - .query_map([&media_id_str], |row| { - Ok(crate::model::AuthorInfo { - name: row.get(0)?, - file_as: row.get(1)?, - role: row.get(2)?, - position: row.get(3)?, - }) - })? - .collect::>>()?; - - // Get identifiers - let mut stmt = conn.prepare( - "SELECT identifier_type, identifier_value - FROM book_identifiers WHERE media_id = ?1", - )?; - let mut identifiers: std::collections::HashMap> = - std::collections::HashMap::new(); - for row in stmt.query_map([&media_id_str], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) - })? { - let (id_type, value) = row?; - identifiers.entry(id_type).or_default().push(value); - } - - let parsed_date = publication_date - .and_then(|d| chrono::NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok()); - - Ok(Some(crate::model::BookMetadata { - media_id, - isbn, - isbn13, - publisher, - language, - page_count, - publication_date: parsed_date, - series_name, - series_index, - format, - authors, - identifiers, - created_at: chrono::DateTime::parse_from_rfc3339(&created_at) - .unwrap() - .with_timezone(&chrono::Utc), - updated_at: chrono::DateTime::parse_from_rfc3339(&updated_at) - .unwrap() - .with_timezone(&chrono::Utc), - })) - }); - - Ok( - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("get_book_metadata timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))??, + [&media_id_str], + |row| { + Ok(( + row.get::<_, Option>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, Option>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, Option>(6)?, + row.get::<_, Option>(7)?, + row.get::<_, Option>(8)?, + row.get::<_, String>(9)?, + row.get::<_, String>(10)?, + )) + }, ) - } + .optional()?; - async fn add_book_author( - &self, - media_id: MediaId, - author: &crate::model::AuthorInfo, - ) -> Result<()> { - let conn = self.conn.clone(); - let media_id_str = media_id.to_string(); - let author_clone = author.clone(); + if metadata_row.is_none() { + return Ok::<_, rusqlite::Error>(None); + } - let fut = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.execute( - "INSERT INTO book_authors (media_id, author_name, author_sort, role, position) + let ( + isbn, + isbn13, + publisher, + language, + page_count, + publication_date, + series_name, + series_index, + format, + created_at, + updated_at, + ) = metadata_row.unwrap(); + + // Get authors + let mut stmt = conn.prepare( + "SELECT author_name, author_sort, role, position + FROM book_authors WHERE media_id = ?1 ORDER BY position", + )?; + let authors: Vec = stmt + .query_map([&media_id_str], |row| { + Ok(crate::model::AuthorInfo { + name: row.get(0)?, + file_as: row.get(1)?, + role: row.get(2)?, + position: row.get(3)?, + }) + })? + .collect::>>()?; + + // Get identifiers + let mut stmt = conn.prepare( + "SELECT identifier_type, identifier_value + FROM book_identifiers WHERE media_id = ?1", + )?; + let mut identifiers: std::collections::HashMap> = + std::collections::HashMap::new(); + for row in stmt.query_map([&media_id_str], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })? { + let (id_type, value) = row?; + identifiers.entry(id_type).or_default().push(value); + } + + let parsed_date = publication_date + .and_then(|d| chrono::NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok()); + + Ok(Some(crate::model::BookMetadata { + media_id, + isbn, + isbn13, + publisher, + language, + page_count, + publication_date: parsed_date, + series_name, + series_index, + format, + authors, + identifiers, + created_at: chrono::DateTime::parse_from_rfc3339(&created_at) + .unwrap() + .with_timezone(&chrono::Utc), + updated_at: chrono::DateTime::parse_from_rfc3339(&updated_at) + .unwrap() + .with_timezone(&chrono::Utc), + })) + }); + + Ok( + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("get_book_metadata timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })??, + ) + } + + async fn add_book_author( + &self, + media_id: MediaId, + author: &crate::model::AuthorInfo, + ) -> Result<()> { + let conn = self.conn.clone(); + let media_id_str = media_id.to_string(); + let author_clone = author.clone(); + + let fut = tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.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", - rusqlite::params![ - media_id_str, - author_clone.name, - author_clone.file_as, - author_clone.role, - author_clone.position - ], - )?; - Ok::<_, rusqlite::Error>(()) - }); + rusqlite::params![ + media_id_str, + author_clone.name, + author_clone.file_as, + author_clone.role, + author_clone.position + ], + )?; + Ok::<_, rusqlite::Error>(()) + }); - tokio::time::timeout(std::time::Duration::from_secs(5), fut) - .await - .map_err(|_| PinakesError::Database("add_book_author timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))??; - Ok(()) - } + tokio::time::timeout(std::time::Duration::from_secs(5), fut) + .await + .map_err(|_| PinakesError::Database("add_book_author timed out".into()))? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })??; + Ok(()) + } - async fn get_book_authors(&self, media_id: MediaId) -> Result> { - let conn = self.conn.clone(); - let media_id_str = media_id.to_string(); + async fn get_book_authors( + &self, + media_id: MediaId, + ) -> Result> { + let conn = self.conn.clone(); + let media_id_str = media_id.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT author_name, author_sort, role, position + let fut = tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT author_name, author_sort, role, position FROM book_authors WHERE media_id = ?1 ORDER BY position", - )?; - let authors: Vec = stmt - .query_map([&media_id_str], |row| { - Ok(crate::model::AuthorInfo { - name: row.get(0)?, - file_as: row.get(1)?, - role: row.get(2)?, - position: row.get(3)?, - }) - })? - .collect::>>()?; - Ok::<_, rusqlite::Error>(authors) - }); + )?; + let authors: Vec = stmt + .query_map([&media_id_str], |row| { + Ok(crate::model::AuthorInfo { + name: row.get(0)?, + file_as: row.get(1)?, + role: row.get(2)?, + position: row.get(3)?, + }) + })? + .collect::>>()?; + Ok::<_, rusqlite::Error>(authors) + }); - Ok(tokio::time::timeout(std::time::Duration::from_secs(5), fut) - .await - .map_err(|_| PinakesError::Database("get_book_authors timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))??) - } + Ok( + tokio::time::timeout(std::time::Duration::from_secs(5), fut) + .await + .map_err(|_| { + PinakesError::Database("get_book_authors timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })??, + ) + } - async fn list_all_authors(&self, pagination: &Pagination) -> Result> { - let conn = self.conn.clone(); - let offset = pagination.offset; - let limit = pagination.limit; + async fn list_all_authors( + &self, + pagination: &Pagination, + ) -> Result> { + let conn = self.conn.clone(); + let offset = pagination.offset; + let limit = pagination.limit; - let fut = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT author_name, COUNT(DISTINCT media_id) as book_count + let fut = tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + let mut stmt = conn.prepare( + "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", - )?; - let authors: Vec<(String, u64)> = stmt - .query_map([limit as i64, offset as i64], |row| { - Ok((row.get(0)?, row.get::<_, i64>(1)? as u64)) - })? - .collect::>>()?; - Ok::<_, rusqlite::Error>(authors) - }); + )?; + let authors: Vec<(String, u64)> = stmt + .query_map([limit as i64, offset as i64], |row| { + Ok((row.get(0)?, row.get::<_, i64>(1)? as u64)) + })? + .collect::>>()?; + Ok::<_, rusqlite::Error>(authors) + }); - Ok( - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("list_all_authors timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))??, - ) - } + Ok( + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("list_all_authors timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })??, + ) + } - async fn list_series(&self) -> Result> { - let conn = self.conn.clone(); + async fn list_series(&self) -> Result> { + let conn = self.conn.clone(); - let fut = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT series_name, COUNT(*) as book_count + let fut = tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT series_name, COUNT(*) as book_count FROM book_metadata WHERE series_name IS NOT NULL GROUP BY series_name ORDER BY series_name", - )?; - let series: Vec<(String, u64)> = stmt - .query_map([], |row| Ok((row.get(0)?, row.get::<_, i64>(1)? as u64)))? - .collect::>>()?; - Ok::<_, rusqlite::Error>(series) - }); + )?; + let series: Vec<(String, u64)> = stmt + .query_map([], |row| Ok((row.get(0)?, row.get::<_, i64>(1)? as u64)))? + .collect::>>()?; + Ok::<_, rusqlite::Error>(series) + }); - Ok( - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("list_series timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))??, - ) - } + Ok( + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| PinakesError::Database("list_series timed out".into()))? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })??, + ) + } - async fn get_series_books(&self, series_name: &str) -> Result> { - let conn = self.conn.clone(); - let series = series_name.to_string(); + async fn get_series_books( + &self, + series_name: &str, + ) -> Result> { + let conn = self.conn.clone(); + let series = series_name.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, - m.file_size, m.title, m.artist, m.album, m.genre, m.year, - m.duration_secs, m.description, m.thumbnail_path, m.file_mtime, + let fut = tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, + m.file_size, m.title, m.artist, m.album, m.genre, \ + 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", - )?; - let items = stmt - .query_map([&series], row_to_media_item)? - .collect::>>()?; - Ok::<_, rusqlite::Error>(items) - }); + )?; + let items = stmt + .query_map([&series], row_to_media_item)? + .collect::>>()?; + Ok::<_, rusqlite::Error>(items) + }); - Ok( - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("get_series_books timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))??, - ) - } + Ok( + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("get_series_books timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })??, + ) + } - async fn update_reading_progress( - &self, - user_id: uuid::Uuid, - media_id: MediaId, - current_page: i32, - ) -> Result<()> { - // Reuse watch_history table: progress_secs stores current page for books - let conn = self.conn.clone(); - let user_id_str = user_id.to_string(); - let media_id_str = media_id.to_string(); + async fn update_reading_progress( + &self, + user_id: uuid::Uuid, + media_id: MediaId, + current_page: i32, + ) -> Result<()> { + // Reuse watch_history table: progress_secs stores current page for books + let conn = self.conn.clone(); + let user_id_str = user_id.to_string(); + let media_id_str = media_id.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.execute( - "INSERT INTO watch_history (user_id, media_id, progress_secs, last_watched_at) + let fut = tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.execute( + "INSERT INTO watch_history (user_id, media_id, progress_secs, \ + last_watched_at) VALUES (?1, ?2, ?3, datetime('now')) ON CONFLICT(user_id, media_id) DO UPDATE SET progress_secs = ?3, last_watched_at = datetime('now')", - rusqlite::params![user_id_str, media_id_str, current_page as f64], - )?; - Ok::<_, rusqlite::Error>(()) - }); + rusqlite::params![user_id_str, media_id_str, current_page as f64], + )?; + Ok::<_, rusqlite::Error>(()) + }); - tokio::time::timeout(std::time::Duration::from_secs(5), fut) - .await - .map_err(|_| PinakesError::Database("update_reading_progress timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))??; - Ok(()) - } + tokio::time::timeout(std::time::Duration::from_secs(5), fut) + .await + .map_err(|_| { + PinakesError::Database("update_reading_progress timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })??; + Ok(()) + } - async fn get_reading_progress( - &self, - user_id: uuid::Uuid, - media_id: MediaId, - ) -> Result> { - let conn = self.conn.clone(); - let user_id_str = user_id.to_string(); - let media_id_str = media_id.to_string(); + async fn get_reading_progress( + &self, + user_id: uuid::Uuid, + media_id: MediaId, + ) -> Result> { + let conn = self.conn.clone(); + let user_id_str = user_id.to_string(); + let media_id_str = media_id.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - let result = conn - .query_row( - "SELECT wh.progress_secs, bm.page_count, wh.last_watched_at + let fut = tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + let result = conn + .query_row( + "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_str, &media_id_str], - |row| { - let current_page = row.get::<_, f64>(0)? as i32; - let total_pages = row.get::<_, Option>(1)?; - let last_read_str = row.get::<_, String>(2)?; - Ok((current_page, total_pages, last_read_str)) - }, - ) - .optional()?; + [&user_id_str, &media_id_str], + |row| { + let current_page = row.get::<_, f64>(0)? as i32; + let total_pages = row.get::<_, Option>(1)?; + let last_read_str = row.get::<_, String>(2)?; + Ok((current_page, total_pages, last_read_str)) + }, + ) + .optional()?; - Ok::<_, rusqlite::Error>(result.map(|(current_page, total_pages, last_read_str)| { - crate::model::ReadingProgress { - media_id, - user_id, - current_page, - total_pages, - 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 - }, - last_read_at: chrono::DateTime::parse_from_rfc3339(&last_read_str) - .unwrap() - .with_timezone(&chrono::Utc), - } - })) - }); + Ok::<_, rusqlite::Error>(result.map( + |(current_page, total_pages, last_read_str)| { + crate::model::ReadingProgress { + media_id, + user_id, + current_page, + total_pages, + 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 + }, + last_read_at: chrono::DateTime::parse_from_rfc3339(&last_read_str) + .unwrap() + .with_timezone(&chrono::Utc), + } + }, + )) + }); - Ok(tokio::time::timeout(std::time::Duration::from_secs(5), fut) - .await - .map_err(|_| PinakesError::Database("get_reading_progress timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))??) - } + Ok( + tokio::time::timeout(std::time::Duration::from_secs(5), fut) + .await + .map_err(|_| { + PinakesError::Database("get_reading_progress timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })??, + ) + } - async fn get_reading_list( - &self, - user_id: uuid::Uuid, - status: Option, - ) -> Result> { - let conn = self.conn.clone(); - let user_id_str = user_id.to_string(); + async fn get_reading_list( + &self, + user_id: uuid::Uuid, + status: Option, + ) -> Result> { + let conn = self.conn.clone(); + let user_id_str = user_id.to_string(); - let fut = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let fut = tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); - // Query books with reading progress for this user - // Join with book_metadata to get page counts and media_items for the items - let mut stmt = conn.prepare( - "SELECT m.*, wh.progress_secs, bm.page_count + // Query books with reading progress for this user + // Join with book_metadata to get page counts and media_items for the + // items + let mut stmt = conn.prepare( + "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", - )?; + )?; - let rows = stmt.query_map([&user_id_str], |row| { - // Parse the media item - let item = row_to_media_item(row)?; - // Get progress info (after all MediaItem columns) - let col_offset = 27; // MediaItem has ~27 columns - let current_page = row.get::<_, f64>(col_offset)? as i32; - let total_pages = row.get::<_, Option>(col_offset + 1)?; - Ok((item, current_page, total_pages)) - })?; + let rows = stmt.query_map([&user_id_str], |row| { + // Parse the media item + let item = row_to_media_item(row)?; + // Get progress info (after all MediaItem columns) + let col_offset = 27; // MediaItem has ~27 columns + let current_page = row.get::<_, f64>(col_offset)? as i32; + let total_pages = row.get::<_, Option>(col_offset + 1)?; + Ok((item, current_page, total_pages)) + })?; - let mut results = Vec::new(); - for row in rows { - match row { - Ok((item, current_page, total_pages)) => { - // Calculate status based on progress - let calculated_status = 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), - _ => {} - } - } - Err(_) => continue, + let mut results = Vec::new(); + for row in rows { + match row { + Ok((item, current_page, total_pages)) => { + // Calculate status based on progress + let calculated_status = 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::<_, rusqlite::Error>(results) - }); + }, + Err(_) => continue, + } + } + Ok::<_, rusqlite::Error>(results) + }); - Ok( - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("get_reading_list timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))??, - ) - } + Ok( + tokio::time::timeout(std::time::Duration::from_secs(10), fut) + .await + .map_err(|_| { + PinakesError::Database("get_reading_list timed out".into()) + })? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })??, + ) + } - #[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 conn = self.conn.clone(); - let isbn = isbn.map(String::from); - let author = author.map(String::from); - let series = series.map(String::from); - let publisher = publisher.map(String::from); - let language = language.map(String::from); - let offset = pagination.offset; - let limit = pagination.limit; + #[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 conn = self.conn.clone(); + let isbn = isbn.map(String::from); + let author = author.map(String::from); + let series = series.map(String::from); + let publisher = publisher.map(String::from); + let language = language.map(String::from); + let offset = pagination.offset; + let limit = pagination.limit; - let fut = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let fut = tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); - let mut query = String::from( - "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, + let mut query = String::from( + "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", - ); + ); - let mut conditions = Vec::new(); - let mut params: Vec> = Vec::new(); + let mut conditions = Vec::new(); + let mut params: Vec> = Vec::new(); - if let Some(ref i) = isbn { - conditions.push("(bm.isbn = ? OR bm.isbn13 = ?)"); - params.push(Box::new(i.clone())); - params.push(Box::new(i.clone())); - } - if let Some(ref a) = author { - query.push_str(" INNER JOIN book_authors ba ON m.id = ba.media_id"); - conditions.push("ba.author_name LIKE ?"); - params.push(Box::new(format!("%{}%", a))); - } - if let Some(ref s) = series { - conditions.push("bm.series_name LIKE ?"); - params.push(Box::new(format!("%{}%", s))); - } - if let Some(ref p) = publisher { - conditions.push("bm.publisher LIKE ?"); - params.push(Box::new(format!("%{}%", p))); - } - if let Some(ref l) = language { - conditions.push("bm.language = ?"); - params.push(Box::new(l.clone())); - } + if let Some(ref i) = isbn { + conditions.push("(bm.isbn = ? OR bm.isbn13 = ?)"); + params.push(Box::new(i.clone())); + params.push(Box::new(i.clone())); + } + if let Some(ref a) = author { + query.push_str(" INNER JOIN book_authors ba ON m.id = ba.media_id"); + conditions.push("ba.author_name LIKE ?"); + params.push(Box::new(format!("%{}%", a))); + } + if let Some(ref s) = series { + conditions.push("bm.series_name LIKE ?"); + params.push(Box::new(format!("%{}%", s))); + } + if let Some(ref p) = publisher { + conditions.push("bm.publisher LIKE ?"); + params.push(Box::new(format!("%{}%", p))); + } + if let Some(ref l) = language { + conditions.push("bm.language = ?"); + params.push(Box::new(l.clone())); + } - if !conditions.is_empty() { - query.push_str(" WHERE "); - query.push_str(&conditions.join(" AND ")); - } + if !conditions.is_empty() { + query.push_str(" WHERE "); + query.push_str(&conditions.join(" AND ")); + } - query.push_str(" ORDER BY m.title LIMIT ? OFFSET ?"); - params.push(Box::new(limit as i64)); - params.push(Box::new(offset as i64)); + query.push_str(" ORDER BY m.title LIMIT ? OFFSET ?"); + params.push(Box::new(limit as i64)); + params.push(Box::new(offset as i64)); - let params_refs: Vec<&dyn rusqlite::ToSql> = - params.iter().map(|p| p.as_ref()).collect(); + let params_refs: Vec<&dyn rusqlite::ToSql> = + params.iter().map(|p| p.as_ref()).collect(); - let mut stmt = conn.prepare(&query)?; - let items = stmt - .query_map(&*params_refs, row_to_media_item)? - .collect::>>()?; - Ok::<_, rusqlite::Error>(items) - }); + let mut stmt = conn.prepare(&query)?; + let items = stmt + .query_map(&*params_refs, row_to_media_item)? + .collect::>>()?; + Ok::<_, rusqlite::Error>(items) + }); - Ok( - tokio::time::timeout(std::time::Duration::from_secs(10), fut) - .await - .map_err(|_| PinakesError::Database("search_books timed out".into()))? - .map_err(|e: tokio::task::JoinError| PinakesError::Database(e.to_string()))??, - ) - } - - // ===== Managed Storage ===== - - async fn insert_managed_media(&self, item: &MediaItem) -> Result<()> { - let conn = self.conn.clone(); - let item = item.clone(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.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)", - params![ - item.id.0.to_string(), - item.path.to_string_lossy().to_string(), - item.file_name, - media_type_to_str(&item.media_type), - item.content_hash.0, - item.file_size as i64, - item.title, - item.artist, - item.album, - item.genre, - item.year, - item.duration_secs, - item.description, - item.thumbnail_path.as_ref().map(|p| p.to_string_lossy().to_string()), - item.storage_mode.to_string(), - item.original_filename, - item.uploaded_at.map(|dt| dt.to_rfc3339()), - item.storage_key, - item.created_at.to_rfc3339(), - item.updated_at.to_rfc3339(), - ], - )?; - Ok::<_, rusqlite::Error>(()) - }) + Ok( + tokio::time::timeout(std::time::Duration::from_secs(10), fut) .await - .map_err(|e| PinakesError::Database(e.to_string()))??; - Ok(()) - } + .map_err(|_| PinakesError::Database("search_books timed out".into()))? + .map_err(|e: tokio::task::JoinError| { + PinakesError::Database(e.to_string()) + })??, + ) + } - async fn get_or_create_blob( - &self, - hash: &ContentHash, - size: u64, - mime_type: &str, - ) -> Result { - let conn = self.conn.clone(); - let hash_str = hash.0.clone(); - let mime = mime_type.to_string(); - let now = chrono::Utc::now().to_rfc3339(); + // ===== Managed Storage ===== - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + async fn insert_managed_media(&self, item: &MediaItem) -> Result<()> { + let conn = self.conn.clone(); + let item = item.clone(); + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.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)", + params![ + item.id.0.to_string(), + item.path.to_string_lossy().to_string(), + item.file_name, + media_type_to_str(&item.media_type), + item.content_hash.0, + item.file_size as i64, + item.title, + item.artist, + item.album, + item.genre, + item.year, + item.duration_secs, + item.description, + item + .thumbnail_path + .as_ref() + .map(|p| p.to_string_lossy().to_string()), + item.storage_mode.to_string(), + item.original_filename, + item.uploaded_at.map(|dt| dt.to_rfc3339()), + item.storage_key, + item.created_at.to_rfc3339(), + item.updated_at.to_rfc3339(), + ], + )?; + Ok::<_, rusqlite::Error>(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; + Ok(()) + } - // Try to get existing blob - let existing = conn.query_row( - "SELECT content_hash, file_size, mime_type, reference_count, stored_at, last_verified + async fn get_or_create_blob( + &self, + hash: &ContentHash, + size: u64, + mime_type: &str, + ) -> Result { + let conn = self.conn.clone(); + let hash_str = hash.0.clone(); + let mime = mime_type.to_string(); + let now = chrono::Utc::now().to_rfc3339(); + + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + + // Try to get existing blob + let existing = conn + .query_row( + "SELECT content_hash, file_size, mime_type, reference_count, \ + stored_at, last_verified FROM managed_blobs WHERE content_hash = ?1", - params![&hash_str], - |row| { - Ok(ManagedBlob { - content_hash: ContentHash(row.get::<_, String>(0)?), - file_size: row.get::<_, i64>(1)? as u64, - mime_type: row.get(2)?, - reference_count: row.get::<_, i32>(3)? as u32, - stored_at: parse_datetime(&row.get::<_, String>(4)?), - last_verified: row.get::<_, Option>(5)?.map(|s| parse_datetime(&s)), - }) - }, - ).optional()?; - - if let Some(blob) = existing { - return Ok(blob); - } - - // Create new blob - conn.execute( - "INSERT INTO managed_blobs (content_hash, file_size, mime_type, reference_count, stored_at) - VALUES (?1, ?2, ?3, 1, ?4)", - params![&hash_str, size as i64, &mime, &now], - )?; - + params![&hash_str], + |row| { Ok(ManagedBlob { - content_hash: ContentHash(hash_str), - file_size: size, - mime_type: mime, - reference_count: 1, - stored_at: chrono::Utc::now(), - last_verified: None, + content_hash: ContentHash(row.get::<_, String>(0)?), + file_size: row.get::<_, i64>(1)? as u64, + mime_type: row.get(2)?, + reference_count: row.get::<_, i32>(3)? as u32, + stored_at: parse_datetime(&row.get::<_, String>(4)?), + last_verified: row + .get::<_, Option>(5)? + .map(|s| parse_datetime(&s)), }) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - } + }, + ) + .optional()?; - async fn get_blob(&self, hash: &ContentHash) -> Result> { - let conn = self.conn.clone(); - let hash_str = hash.0.clone(); + if let Some(blob) = existing { + return Ok(blob); + } - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.query_row( - "SELECT content_hash, file_size, mime_type, reference_count, stored_at, last_verified + // Create new blob + conn.execute( + "INSERT INTO managed_blobs (content_hash, file_size, mime_type, \ + reference_count, stored_at) + VALUES (?1, ?2, ?3, 1, ?4)", + params![&hash_str, size as i64, &mime, &now], + )?; + + Ok(ManagedBlob { + content_hash: ContentHash(hash_str), + file_size: size, + mime_type: mime, + reference_count: 1, + stored_at: chrono::Utc::now(), + last_verified: None, + }) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn get_blob(&self, hash: &ContentHash) -> Result> { + let conn = self.conn.clone(); + let hash_str = hash.0.clone(); + + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn + .query_row( + "SELECT content_hash, file_size, mime_type, reference_count, \ + stored_at, last_verified FROM managed_blobs WHERE content_hash = ?1", - params![&hash_str], - |row| { - Ok(ManagedBlob { - content_hash: ContentHash(row.get::<_, String>(0)?), - file_size: row.get::<_, i64>(1)? as u64, - mime_type: row.get(2)?, - reference_count: row.get::<_, i32>(3)? as u32, - stored_at: parse_datetime(&row.get::<_, String>(4)?), - last_verified: row.get::<_, Option>(5)?.map(|s| parse_datetime(&s)), - }) - }, - ).optional() - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - .map_err(|e| PinakesError::Database(e.to_string())) - } - - async fn increment_blob_ref(&self, hash: &ContentHash) -> Result<()> { - let conn = self.conn.clone(); - let hash_str = hash.0.clone(); - - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.execute( - "UPDATE managed_blobs SET reference_count = reference_count + 1 WHERE content_hash = ?1", - params![&hash_str], - )?; - Ok::<_, rusqlite::Error>(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; - Ok(()) - } - - async fn decrement_blob_ref(&self, hash: &ContentHash) -> Result { - let conn = self.conn.clone(); - let hash_str = hash.0.clone(); - - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.execute( - "UPDATE managed_blobs SET reference_count = reference_count - 1 WHERE content_hash = ?1", - params![&hash_str], - )?; - - // Check if reference count is now 0 - let count: i32 = conn.query_row( - "SELECT reference_count FROM managed_blobs WHERE content_hash = ?1", - params![&hash_str], - |row| row.get(0), - ).unwrap_or(0); - - Ok::<_, rusqlite::Error>(count <= 0) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - .map_err(|e| PinakesError::Database(e.to_string())) - } - - async fn update_blob_verified(&self, hash: &ContentHash) -> Result<()> { - let conn = self.conn.clone(); - let hash_str = hash.0.clone(); - let now = chrono::Utc::now().to_rfc3339(); - - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.execute( - "UPDATE managed_blobs SET last_verified = ?1 WHERE content_hash = ?2", - params![&now, &hash_str], - )?; - Ok::<_, rusqlite::Error>(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; - Ok(()) - } - - async fn list_orphaned_blobs(&self) -> Result> { - let conn = self.conn.clone(); - - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT content_hash, file_size, mime_type, reference_count, stored_at, last_verified - FROM managed_blobs WHERE reference_count <= 0", - )?; - let blobs = stmt.query_map([], |row| { - Ok(ManagedBlob { - content_hash: ContentHash(row.get::<_, String>(0)?), - file_size: row.get::<_, i64>(1)? as u64, - mime_type: row.get(2)?, - reference_count: row.get::<_, i32>(3)? as u32, - stored_at: parse_datetime(&row.get::<_, String>(4)?), - last_verified: row.get::<_, Option>(5)?.map(|s| parse_datetime(&s)), - }) - })?.collect::>>()?; - Ok::<_, rusqlite::Error>(blobs) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - .map_err(|e| PinakesError::Database(e.to_string())) - } - - async fn delete_blob(&self, hash: &ContentHash) -> Result<()> { - let conn = self.conn.clone(); - let hash_str = hash.0.clone(); - - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.execute( - "DELETE FROM managed_blobs WHERE content_hash = ?1", - params![&hash_str], - )?; - Ok::<_, rusqlite::Error>(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; - Ok(()) - } - - async fn managed_storage_stats(&self) -> Result { - let conn = self.conn.clone(); - - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - - let total_blobs: u64 = - conn.query_row("SELECT COUNT(*) FROM managed_blobs", [], |row| { - row.get::<_, i64>(0) - })? as u64; - - let total_size: u64 = conn.query_row( - "SELECT COALESCE(SUM(file_size), 0) FROM managed_blobs", - [], - |row| row.get::<_, i64>(0), - )? as u64; - - let unique_size: u64 = conn.query_row( - "SELECT COALESCE(SUM(file_size), 0) FROM managed_blobs WHERE reference_count = 1", - [], - |row| row.get::<_, i64>(0), - )? as u64; - - let managed_media_count: u64 = conn.query_row( - "SELECT COUNT(*) FROM media_items WHERE storage_mode = 'managed'", - [], - |row| row.get::<_, i64>(0), - )? as u64; - - let orphaned_blobs: u64 = conn.query_row( - "SELECT COUNT(*) FROM managed_blobs WHERE reference_count <= 0", - [], - |row| row.get::<_, i64>(0), - )? as u64; - - let dedup_ratio = if total_size > 0 { - unique_size as f64 / total_size as f64 - } else { - 1.0 - }; - - Ok::<_, rusqlite::Error>(ManagedStorageStats { - total_blobs, - total_size_bytes: total_size, - unique_size_bytes: unique_size, - deduplication_ratio: dedup_ratio, - managed_media_count, - orphaned_blobs, + params![&hash_str], + |row| { + Ok(ManagedBlob { + content_hash: ContentHash(row.get::<_, String>(0)?), + file_size: row.get::<_, i64>(1)? as u64, + mime_type: row.get(2)?, + reference_count: row.get::<_, i32>(3)? as u32, + stored_at: parse_datetime(&row.get::<_, String>(4)?), + last_verified: row + .get::<_, Option>(5)? + .map(|s| parse_datetime(&s)), }) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - .map_err(|e| PinakesError::Database(e.to_string())) - } + }, + ) + .optional() + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + .map_err(|e| PinakesError::Database(e.to_string())) + } - // ===== Sync Devices ===== + async fn increment_blob_ref(&self, hash: &ContentHash) -> Result<()> { + let conn = self.conn.clone(); + let hash_str = hash.0.clone(); - async fn register_device( - &self, - device: &crate::sync::SyncDevice, - token_hash: &str, - ) -> Result { - let conn = self.conn.clone(); - let device = device.clone(); - let token_hash = token_hash.to_string(); + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.execute( + "UPDATE managed_blobs SET reference_count = reference_count + 1 WHERE \ + content_hash = ?1", + params![&hash_str], + )?; + Ok::<_, rusqlite::Error>(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; + Ok(()) + } - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.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) + async fn decrement_blob_ref(&self, hash: &ContentHash) -> Result { + let conn = self.conn.clone(); + let hash_str = hash.0.clone(); + + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.execute( + "UPDATE managed_blobs SET reference_count = reference_count - 1 WHERE \ + content_hash = ?1", + params![&hash_str], + )?; + + // Check if reference count is now 0 + let count: i32 = conn + .query_row( + "SELECT reference_count FROM managed_blobs WHERE content_hash = ?1", + params![&hash_str], + |row| row.get(0), + ) + .unwrap_or(0); + + Ok::<_, rusqlite::Error>(count <= 0) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + .map_err(|e| PinakesError::Database(e.to_string())) + } + + async fn update_blob_verified(&self, hash: &ContentHash) -> Result<()> { + let conn = self.conn.clone(); + let hash_str = hash.0.clone(); + let now = chrono::Utc::now().to_rfc3339(); + + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.execute( + "UPDATE managed_blobs SET last_verified = ?1 WHERE content_hash = ?2", + params![&now, &hash_str], + )?; + Ok::<_, rusqlite::Error>(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; + Ok(()) + } + + async fn list_orphaned_blobs(&self) -> Result> { + let conn = self.conn.clone(); + + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT content_hash, file_size, mime_type, reference_count, \ + stored_at, last_verified + FROM managed_blobs WHERE reference_count <= 0", + )?; + let blobs = stmt + .query_map([], |row| { + Ok(ManagedBlob { + content_hash: ContentHash(row.get::<_, String>(0)?), + file_size: row.get::<_, i64>(1)? as u64, + mime_type: row.get(2)?, + reference_count: row.get::<_, i32>(3)? as u32, + stored_at: parse_datetime(&row.get::<_, String>(4)?), + last_verified: row + .get::<_, Option>(5)? + .map(|s| parse_datetime(&s)), + }) + })? + .collect::>>()?; + Ok::<_, rusqlite::Error>(blobs) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + .map_err(|e| PinakesError::Database(e.to_string())) + } + + async fn delete_blob(&self, hash: &ContentHash) -> Result<()> { + let conn = self.conn.clone(); + let hash_str = hash.0.clone(); + + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.execute( + "DELETE FROM managed_blobs WHERE content_hash = ?1", + params![&hash_str], + )?; + Ok::<_, rusqlite::Error>(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; + Ok(()) + } + + async fn managed_storage_stats(&self) -> Result { + let conn = self.conn.clone(); + + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + + let total_blobs: u64 = + conn.query_row("SELECT COUNT(*) FROM managed_blobs", [], |row| { + row.get::<_, i64>(0) + })? as u64; + + let total_size: u64 = conn.query_row( + "SELECT COALESCE(SUM(file_size), 0) FROM managed_blobs", + [], + |row| row.get::<_, i64>(0), + )? as u64; + + let unique_size: u64 = conn.query_row( + "SELECT COALESCE(SUM(file_size), 0) FROM managed_blobs WHERE \ + reference_count = 1", + [], + |row| row.get::<_, i64>(0), + )? as u64; + + let managed_media_count: u64 = conn.query_row( + "SELECT COUNT(*) FROM media_items WHERE storage_mode = 'managed'", + [], + |row| row.get::<_, i64>(0), + )? as u64; + + let orphaned_blobs: u64 = conn.query_row( + "SELECT COUNT(*) FROM managed_blobs WHERE reference_count <= 0", + [], + |row| row.get::<_, i64>(0), + )? as u64; + + let dedup_ratio = if total_size > 0 { + unique_size as f64 / total_size as f64 + } else { + 1.0 + }; + + Ok::<_, rusqlite::Error>(ManagedStorageStats { + total_blobs, + total_size_bytes: total_size, + unique_size_bytes: unique_size, + deduplication_ratio: dedup_ratio, + managed_media_count, + orphaned_blobs, + }) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + .map_err(|e| PinakesError::Database(e.to_string())) + } + + // ===== Sync Devices ===== + + async fn register_device( + &self, + device: &crate::sync::SyncDevice, + token_hash: &str, + ) -> Result { + let conn = self.conn.clone(); + let device = device.clone(); + let token_hash = token_hash.to_string(); + + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.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)", - params![ - device.id.0.to_string(), - device.user_id.0.to_string(), - device.name, - device.device_type.to_string(), - device.client_version, - device.os_info, - token_hash, - device.last_seen_at.to_rfc3339(), - device.sync_cursor, - device.enabled, - device.created_at.to_rfc3339(), - device.updated_at.to_rfc3339(), - ], - )?; - Ok::<_, rusqlite::Error>(device) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - .map_err(|e| PinakesError::Database(e.to_string())) - } + params![ + device.id.0.to_string(), + device.user_id.0.to_string(), + device.name, + device.device_type.to_string(), + device.client_version, + device.os_info, + token_hash, + device.last_seen_at.to_rfc3339(), + device.sync_cursor, + device.enabled, + device.created_at.to_rfc3339(), + device.updated_at.to_rfc3339(), + ], + )?; + Ok::<_, rusqlite::Error>(device) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + .map_err(|e| PinakesError::Database(e.to_string())) + } - async fn get_device(&self, id: crate::sync::DeviceId) -> Result { - let conn = self.conn.clone(); + async fn get_device( + &self, + id: crate::sync::DeviceId, + ) -> Result { + let conn = self.conn.clone(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.query_row( - "SELECT id, user_id, name, device_type, client_version, os_info, - last_sync_at, last_seen_at, sync_cursor, enabled, created_at, updated_at + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.query_row( + "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", - params![id.0.to_string()], - |row| { - Ok(crate::sync::SyncDevice { - id: crate::sync::DeviceId(parse_uuid(&row.get::<_, String>(0)?)?), - user_id: crate::users::UserId(parse_uuid(&row.get::<_, String>(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::<_, Option>(6)?.map(|s| parse_datetime(&s)), - last_seen_at: parse_datetime(&row.get::<_, String>(7)?), - sync_cursor: row.get(8)?, - enabled: row.get(9)?, - created_at: parse_datetime(&row.get::<_, String>(10)?), - updated_at: parse_datetime(&row.get::<_, String>(11)?), - }) - }, - ) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - .map_err(|e| PinakesError::Database(e.to_string())) - } + params![id.0.to_string()], + |row| { + Ok(crate::sync::SyncDevice { + id: crate::sync::DeviceId(parse_uuid( + &row.get::<_, String>(0)?, + )?), + user_id: crate::users::UserId(parse_uuid( + &row.get::<_, String>(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::<_, Option>(6)? + .map(|s| parse_datetime(&s)), + last_seen_at: parse_datetime(&row.get::<_, String>(7)?), + sync_cursor: row.get(8)?, + enabled: row.get(9)?, + created_at: parse_datetime(&row.get::<_, String>(10)?), + updated_at: parse_datetime(&row.get::<_, String>(11)?), + }) + }, + ) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + .map_err(|e| PinakesError::Database(e.to_string())) + } - async fn get_device_by_token( - &self, - token_hash: &str, - ) -> Result> { - let conn = self.conn.clone(); - let token_hash = token_hash.to_string(); + async fn get_device_by_token( + &self, + token_hash: &str, + ) -> Result> { + let conn = self.conn.clone(); + let token_hash = token_hash.to_string(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.query_row( - "SELECT id, user_id, name, device_type, client_version, os_info, - last_sync_at, last_seen_at, sync_cursor, enabled, created_at, updated_at + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn + .query_row( + "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", - params![&token_hash], - |row| { - Ok(crate::sync::SyncDevice { - id: crate::sync::DeviceId(parse_uuid(&row.get::<_, String>(0)?)?), - user_id: crate::users::UserId(parse_uuid(&row.get::<_, String>(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::<_, Option>(6)?.map(|s| parse_datetime(&s)), - last_seen_at: parse_datetime(&row.get::<_, String>(7)?), - sync_cursor: row.get(8)?, - enabled: row.get(9)?, - created_at: parse_datetime(&row.get::<_, String>(10)?), - updated_at: parse_datetime(&row.get::<_, String>(11)?), - }) - }, - ) - .optional() - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - .map_err(|e| PinakesError::Database(e.to_string())) - } + params![&token_hash], + |row| { + Ok(crate::sync::SyncDevice { + id: crate::sync::DeviceId(parse_uuid( + &row.get::<_, String>(0)?, + )?), + user_id: crate::users::UserId(parse_uuid( + &row.get::<_, String>(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::<_, Option>(6)? + .map(|s| parse_datetime(&s)), + last_seen_at: parse_datetime(&row.get::<_, String>(7)?), + sync_cursor: row.get(8)?, + enabled: row.get(9)?, + created_at: parse_datetime(&row.get::<_, String>(10)?), + updated_at: parse_datetime(&row.get::<_, String>(11)?), + }) + }, + ) + .optional() + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + .map_err(|e| PinakesError::Database(e.to_string())) + } - async fn list_user_devices( - &self, - user_id: crate::users::UserId, - ) -> Result> { - let conn = self.conn.clone(); + async fn list_user_devices( + &self, + user_id: crate::users::UserId, + ) -> Result> { + let conn = self.conn.clone(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT id, user_id, name, device_type, client_version, os_info, - last_sync_at, last_seen_at, sync_cursor, enabled, created_at, updated_at - FROM sync_devices WHERE user_id = ?1 ORDER BY last_seen_at DESC", - )?; - let devices = stmt - .query_map(params![user_id.0.to_string()], |row| { - Ok(crate::sync::SyncDevice { - id: crate::sync::DeviceId(parse_uuid(&row.get::<_, String>(0)?)?), - user_id: crate::users::UserId(parse_uuid(&row.get::<_, String>(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::<_, Option>(6)?.map(|s| parse_datetime(&s)), - last_seen_at: parse_datetime(&row.get::<_, String>(7)?), - sync_cursor: row.get(8)?, - enabled: row.get(9)?, - created_at: parse_datetime(&row.get::<_, String>(10)?), - updated_at: parse_datetime(&row.get::<_, String>(11)?), - }) - })? - .collect::>>()?; - Ok::<_, rusqlite::Error>(devices) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - .map_err(|e| PinakesError::Database(e.to_string())) - } + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, user_id, name, device_type, client_version, os_info, + last_sync_at, last_seen_at, sync_cursor, enabled, \ + created_at, updated_at + FROM sync_devices WHERE user_id = ?1 ORDER BY last_seen_at \ + DESC", + )?; + let devices = stmt + .query_map(params![user_id.0.to_string()], |row| { + Ok(crate::sync::SyncDevice { + id: crate::sync::DeviceId(parse_uuid( + &row.get::<_, String>(0)?, + )?), + user_id: crate::users::UserId(parse_uuid( + &row.get::<_, String>(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::<_, Option>(6)? + .map(|s| parse_datetime(&s)), + last_seen_at: parse_datetime(&row.get::<_, String>(7)?), + sync_cursor: row.get(8)?, + enabled: row.get(9)?, + created_at: parse_datetime(&row.get::<_, String>(10)?), + updated_at: parse_datetime(&row.get::<_, String>(11)?), + }) + })? + .collect::>>()?; + Ok::<_, rusqlite::Error>(devices) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + .map_err(|e| PinakesError::Database(e.to_string())) + } - async fn update_device(&self, device: &crate::sync::SyncDevice) -> Result<()> { - let conn = self.conn.clone(); - let device = device.clone(); + async fn update_device( + &self, + device: &crate::sync::SyncDevice, + ) -> Result<()> { + let conn = self.conn.clone(); + let device = device.clone(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.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, + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.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", - params![ - device.name, - device.device_type.to_string(), - device.client_version, - device.os_info, - device.last_sync_at.map(|dt| dt.to_rfc3339()), - device.last_seen_at.to_rfc3339(), - device.sync_cursor, - device.enabled, - device.updated_at.to_rfc3339(), - device.id.0.to_string(), - ], - )?; - Ok::<_, rusqlite::Error>(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; - Ok(()) - } + params![ + device.name, + device.device_type.to_string(), + device.client_version, + device.os_info, + device.last_sync_at.map(|dt| dt.to_rfc3339()), + device.last_seen_at.to_rfc3339(), + device.sync_cursor, + device.enabled, + device.updated_at.to_rfc3339(), + device.id.0.to_string(), + ], + )?; + Ok::<_, rusqlite::Error>(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; + Ok(()) + } - async fn delete_device(&self, id: crate::sync::DeviceId) -> Result<()> { - let conn = self.conn.clone(); + async fn delete_device(&self, id: crate::sync::DeviceId) -> Result<()> { + let conn = self.conn.clone(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.execute( - "DELETE FROM sync_devices WHERE id = ?1", - params![id.0.to_string()], - )?; - Ok::<_, rusqlite::Error>(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; - Ok(()) - } + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.execute("DELETE FROM sync_devices WHERE id = ?1", params![ + id.0.to_string() + ])?; + Ok::<_, rusqlite::Error>(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; + Ok(()) + } - async fn touch_device(&self, id: crate::sync::DeviceId) -> Result<()> { - let conn = self.conn.clone(); - let now = chrono::Utc::now().to_rfc3339(); + async fn touch_device(&self, id: crate::sync::DeviceId) -> Result<()> { + let conn = self.conn.clone(); + let now = chrono::Utc::now().to_rfc3339(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.execute( - "UPDATE sync_devices SET last_seen_at = ?1, updated_at = ?1 WHERE id = ?2", - params![&now, id.0.to_string()], - )?; - Ok::<_, rusqlite::Error>(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; - Ok(()) - } + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.execute( + "UPDATE sync_devices SET last_seen_at = ?1, updated_at = ?1 WHERE id \ + = ?2", + params![&now, id.0.to_string()], + )?; + Ok::<_, rusqlite::Error>(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; + Ok(()) + } - // ===== Sync Log ===== + // ===== Sync Log ===== - async fn record_sync_change(&self, change: &crate::sync::SyncLogEntry) -> Result<()> { - let conn = self.conn.clone(); - let change = change.clone(); + async fn record_sync_change( + &self, + change: &crate::sync::SyncLogEntry, + ) -> Result<()> { + let conn = self.conn.clone(); + let change = change.clone(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); - // Get and increment sequence - let seq: i64 = conn.query_row( - "UPDATE sync_sequence SET current_value = current_value + 1 WHERE id = 1 RETURNING current_value", - [], - |row| row.get(0), - )?; + // Get and increment sequence + let seq: i64 = conn.query_row( + "UPDATE sync_sequence SET current_value = current_value + 1 WHERE id \ + = 1 RETURNING current_value", + [], + |row| row.get(0), + )?; - conn.execute( - "INSERT INTO sync_log (id, sequence, change_type, media_id, path, content_hash, + conn.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)", - params![ - change.id.to_string(), - seq, - change.change_type.to_string(), - change.media_id.map(|m| m.0.to_string()), - 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.to_string()), - change.timestamp.to_rfc3339(), - ], - )?; - Ok::<_, rusqlite::Error>(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; - Ok(()) - } + params![ + change.id.to_string(), + seq, + change.change_type.to_string(), + change.media_id.map(|m| m.0.to_string()), + 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.to_string()), + change.timestamp.to_rfc3339(), + ], + )?; + Ok::<_, rusqlite::Error>(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; + Ok(()) + } - async fn get_changes_since( - &self, - cursor: i64, - limit: u64, - ) -> Result> { - let conn = self.conn.clone(); + async fn get_changes_since( + &self, + cursor: i64, + limit: u64, + ) -> Result> { + let conn = self.conn.clone(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT id, sequence, change_type, media_id, path, content_hash, + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + let mut stmt = conn.prepare( + "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", - )?; - let entries = stmt - .query_map(params![cursor, limit as i64], |row| { - Ok(crate::sync::SyncLogEntry { - id: parse_uuid(&row.get::<_, String>(0)?)?, - sequence: row.get(1)?, - change_type: row - .get::<_, String>(2)? - .parse() - .unwrap_or(crate::sync::SyncChangeType::Modified), - media_id: row - .get::<_, Option>(3)? - .and_then(|s| Uuid::parse_str(&s).ok().map(MediaId)), - path: row.get(4)?, - content_hash: row.get::<_, Option>(5)?.map(ContentHash), - file_size: row.get::<_, Option>(6)?.map(|s| s as u64), - metadata_json: row.get(7)?, - changed_by_device: row - .get::<_, Option>(8)? - .and_then(|s| Uuid::parse_str(&s).ok().map(crate::sync::DeviceId)), - timestamp: parse_datetime(&row.get::<_, String>(9)?), - }) - })? - .collect::>>()?; - Ok::<_, rusqlite::Error>(entries) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - .map_err(|e| PinakesError::Database(e.to_string())) - } + )?; + let entries = stmt + .query_map(params![cursor, limit as i64], |row| { + Ok(crate::sync::SyncLogEntry { + id: parse_uuid(&row.get::<_, String>(0)?)?, + sequence: row.get(1)?, + change_type: row + .get::<_, String>(2)? + .parse() + .unwrap_or(crate::sync::SyncChangeType::Modified), + media_id: row + .get::<_, Option>(3)? + .and_then(|s| Uuid::parse_str(&s).ok().map(MediaId)), + path: row.get(4)?, + content_hash: row + .get::<_, Option>(5)? + .map(ContentHash), + file_size: row.get::<_, Option>(6)?.map(|s| s as u64), + metadata_json: row.get(7)?, + changed_by_device: row.get::<_, Option>(8)?.and_then(|s| { + Uuid::parse_str(&s).ok().map(crate::sync::DeviceId) + }), + timestamp: parse_datetime(&row.get::<_, String>(9)?), + }) + })? + .collect::>>()?; + Ok::<_, rusqlite::Error>(entries) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + .map_err(|e| PinakesError::Database(e.to_string())) + } - async fn get_current_sync_cursor(&self) -> Result { - let conn = self.conn.clone(); + async fn get_current_sync_cursor(&self) -> Result { + let conn = self.conn.clone(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.query_row( - "SELECT current_value FROM sync_sequence WHERE id = 1", - [], - |row| row.get(0), - ) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - .map_err(|e| PinakesError::Database(e.to_string())) - } + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.query_row( + "SELECT current_value FROM sync_sequence WHERE id = 1", + [], + |row| row.get(0), + ) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + .map_err(|e| PinakesError::Database(e.to_string())) + } - async fn cleanup_old_sync_log(&self, before: DateTime) -> Result { - let conn = self.conn.clone(); - let before_str = before.to_rfc3339(); + async fn cleanup_old_sync_log(&self, before: DateTime) -> Result { + let conn = self.conn.clone(); + let before_str = before.to_rfc3339(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.execute( - "DELETE FROM sync_log WHERE timestamp < ?1", - params![&before_str], - ) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - .map(|n| n as u64) - .map_err(|e| PinakesError::Database(e.to_string())) - } + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.execute("DELETE FROM sync_log WHERE timestamp < ?1", params![ + &before_str + ]) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + .map(|n| n as u64) + .map_err(|e| PinakesError::Database(e.to_string())) + } - // ===== Device Sync State ===== + // ===== Device Sync State ===== - async fn get_device_sync_state( - &self, - device_id: crate::sync::DeviceId, - path: &str, - ) -> Result> { - let conn = self.conn.clone(); - let path = path.to_string(); + async fn get_device_sync_state( + &self, + device_id: crate::sync::DeviceId, + path: &str, + ) -> Result> { + let conn = self.conn.clone(); + let path = path.to_string(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.query_row( - "SELECT device_id, path, local_hash, server_hash, local_mtime, server_mtime, + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn + .query_row( + "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", - params![device_id.0.to_string(), &path], - |row| { - Ok(crate::sync::DeviceSyncState { - device_id: crate::sync::DeviceId(parse_uuid(&row.get::<_, String>(0)?)?), - path: row.get(1)?, - local_hash: row.get(2)?, - server_hash: row.get(3)?, - local_mtime: row.get(4)?, - server_mtime: row.get(5)?, - sync_status: row - .get::<_, String>(6)? - .parse() - .unwrap_or(crate::sync::FileSyncStatus::Synced), - last_synced_at: row - .get::<_, Option>(7)? - .map(|s| parse_datetime(&s)), - conflict_info_json: row.get(8)?, - }) - }, - ) - .optional() - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - .map_err(|e| PinakesError::Database(e.to_string())) - } + params![device_id.0.to_string(), &path], + |row| { + Ok(crate::sync::DeviceSyncState { + device_id: crate::sync::DeviceId(parse_uuid( + &row.get::<_, String>(0)?, + )?), + path: row.get(1)?, + local_hash: row.get(2)?, + server_hash: row.get(3)?, + local_mtime: row.get(4)?, + server_mtime: row.get(5)?, + sync_status: row + .get::<_, String>(6)? + .parse() + .unwrap_or(crate::sync::FileSyncStatus::Synced), + last_synced_at: row + .get::<_, Option>(7)? + .map(|s| parse_datetime(&s)), + conflict_info_json: row.get(8)?, + }) + }, + ) + .optional() + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + .map_err(|e| PinakesError::Database(e.to_string())) + } - async fn upsert_device_sync_state(&self, state: &crate::sync::DeviceSyncState) -> Result<()> { - let conn = self.conn.clone(); - let state = state.clone(); + async fn upsert_device_sync_state( + &self, + state: &crate::sync::DeviceSyncState, + ) -> Result<()> { + let conn = self.conn.clone(); + let state = state.clone(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.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) + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.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, @@ -5241,1343 +6231,1518 @@ impl StorageBackend for SqliteBackend { sync_status = excluded.sync_status, last_synced_at = excluded.last_synced_at, conflict_info_json = excluded.conflict_info_json", - params![ - state.device_id.0.to_string(), - state.path, - state.local_hash, - state.server_hash, - state.local_mtime, - state.server_mtime, - state.sync_status.to_string(), - state.last_synced_at.map(|dt| dt.to_rfc3339()), - state.conflict_info_json, - ], - )?; - Ok::<_, rusqlite::Error>(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; - Ok(()) - } + params![ + state.device_id.0.to_string(), + state.path, + state.local_hash, + state.server_hash, + state.local_mtime, + state.server_mtime, + state.sync_status.to_string(), + state.last_synced_at.map(|dt| dt.to_rfc3339()), + state.conflict_info_json, + ], + )?; + Ok::<_, rusqlite::Error>(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; + Ok(()) + } - async fn list_pending_sync( - &self, - device_id: crate::sync::DeviceId, - ) -> Result> { - let conn = self.conn.clone(); + async fn list_pending_sync( + &self, + device_id: crate::sync::DeviceId, + ) -> Result> { + let conn = self.conn.clone(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT device_id, path, local_hash, server_hash, local_mtime, server_mtime, + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + let mut stmt = conn.prepare( + "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')", - )?; - let states = stmt.query_map(params![device_id.0.to_string()], |row| { - Ok(crate::sync::DeviceSyncState { - device_id: crate::sync::DeviceId(parse_uuid(&row.get::<_, String>(0)?)?), - path: row.get(1)?, - local_hash: row.get(2)?, - server_hash: row.get(3)?, - local_mtime: row.get(4)?, - server_mtime: row.get(5)?, - sync_status: row.get::<_, String>(6)?.parse().unwrap_or(crate::sync::FileSyncStatus::Synced), - last_synced_at: row.get::<_, Option>(7)?.map(|s| parse_datetime(&s)), - conflict_info_json: row.get(8)?, - }) - })?.collect::>>()?; - Ok::<_, rusqlite::Error>(states) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - .map_err(|e| PinakesError::Database(e.to_string())) - } + WHERE device_id = ?1 AND sync_status IN ('pending_upload', \ + 'pending_download', 'conflict')", + )?; + let states = stmt + .query_map(params![device_id.0.to_string()], |row| { + Ok(crate::sync::DeviceSyncState { + device_id: crate::sync::DeviceId(parse_uuid( + &row.get::<_, String>(0)?, + )?), + path: row.get(1)?, + local_hash: row.get(2)?, + server_hash: row.get(3)?, + local_mtime: row.get(4)?, + server_mtime: row.get(5)?, + sync_status: row + .get::<_, String>(6)? + .parse() + .unwrap_or(crate::sync::FileSyncStatus::Synced), + last_synced_at: row + .get::<_, Option>(7)? + .map(|s| parse_datetime(&s)), + conflict_info_json: row.get(8)?, + }) + })? + .collect::>>()?; + Ok::<_, rusqlite::Error>(states) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + .map_err(|e| PinakesError::Database(e.to_string())) + } - // ===== Upload Sessions ===== + // ===== Upload Sessions ===== - async fn create_upload_session(&self, session: &crate::sync::UploadSession) -> Result<()> { - let conn = self.conn.clone(); - let session = session.clone(); + async fn create_upload_session( + &self, + session: &crate::sync::UploadSession, + ) -> Result<()> { + let conn = self.conn.clone(); + let session = session.clone(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.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) + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.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)", - params![ - session.id.to_string(), - session.device_id.0.to_string(), - 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.to_rfc3339(), - session.expires_at.to_rfc3339(), - session.last_activity.to_rfc3339(), - ], - )?; - Ok::<_, rusqlite::Error>(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; - Ok(()) - } + params![ + session.id.to_string(), + session.device_id.0.to_string(), + 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.to_rfc3339(), + session.expires_at.to_rfc3339(), + session.last_activity.to_rfc3339(), + ], + )?; + Ok::<_, rusqlite::Error>(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; + Ok(()) + } - async fn get_upload_session(&self, id: Uuid) -> Result { - let conn = self.conn.clone(); + async fn get_upload_session( + &self, + id: Uuid, + ) -> Result { + let conn = self.conn.clone(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.query_row( - "SELECT id, device_id, target_path, expected_hash, expected_size, chunk_size, - chunk_count, status, created_at, expires_at, last_activity + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.query_row( + "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", - params![id.to_string()], - |row| { - Ok(crate::sync::UploadSession { - id: parse_uuid(&row.get::<_, String>(0)?)?, - device_id: crate::sync::DeviceId(parse_uuid(&row.get::<_, String>(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: parse_datetime(&row.get::<_, String>(8)?), - expires_at: parse_datetime(&row.get::<_, String>(9)?), - last_activity: parse_datetime(&row.get::<_, String>(10)?), - }) - }, - ) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - .map_err(|e| PinakesError::Database(e.to_string())) - } + params![id.to_string()], + |row| { + Ok(crate::sync::UploadSession { + id: parse_uuid(&row.get::<_, String>(0)?)?, + device_id: crate::sync::DeviceId(parse_uuid( + &row.get::<_, String>(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: parse_datetime(&row.get::<_, String>(8)?), + expires_at: parse_datetime(&row.get::<_, String>(9)?), + last_activity: parse_datetime(&row.get::<_, String>(10)?), + }) + }, + ) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + .map_err(|e| PinakesError::Database(e.to_string())) + } - async fn update_upload_session(&self, session: &crate::sync::UploadSession) -> Result<()> { - let conn = self.conn.clone(); - let session = session.clone(); + async fn update_upload_session( + &self, + session: &crate::sync::UploadSession, + ) -> Result<()> { + let conn = self.conn.clone(); + let session = session.clone(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.execute( - "UPDATE upload_sessions SET status = ?1, last_activity = ?2 WHERE id = ?3", - params![ - session.status.to_string(), - session.last_activity.to_rfc3339(), - session.id.to_string(), - ], - )?; - Ok::<_, rusqlite::Error>(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; - Ok(()) - } + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.execute( + "UPDATE upload_sessions SET status = ?1, last_activity = ?2 WHERE id \ + = ?3", + params![ + session.status.to_string(), + session.last_activity.to_rfc3339(), + session.id.to_string(), + ], + )?; + Ok::<_, rusqlite::Error>(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; + Ok(()) + } - async fn record_chunk(&self, upload_id: Uuid, chunk: &crate::sync::ChunkInfo) -> Result<()> { - let conn = self.conn.clone(); - let chunk = chunk.clone(); + async fn record_chunk( + &self, + upload_id: Uuid, + chunk: &crate::sync::ChunkInfo, + ) -> Result<()> { + let conn = self.conn.clone(); + let chunk = chunk.clone(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.execute( - "INSERT INTO upload_chunks (upload_id, chunk_index, offset, size, hash, received_at) + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.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", - params![ - upload_id.to_string(), - chunk.chunk_index as i64, - chunk.offset as i64, - chunk.size as i64, - chunk.hash, - chunk.received_at.to_rfc3339(), - ], - )?; - Ok::<_, rusqlite::Error>(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; - Ok(()) - } + params![ + upload_id.to_string(), + chunk.chunk_index as i64, + chunk.offset as i64, + chunk.size as i64, + chunk.hash, + chunk.received_at.to_rfc3339(), + ], + )?; + Ok::<_, rusqlite::Error>(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; + Ok(()) + } - async fn get_upload_chunks(&self, upload_id: Uuid) -> Result> { - let conn = self.conn.clone(); + async fn get_upload_chunks( + &self, + upload_id: Uuid, + ) -> Result> { + let conn = self.conn.clone(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT upload_id, chunk_index, offset, size, hash, received_at + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT upload_id, chunk_index, offset, size, hash, received_at FROM upload_chunks WHERE upload_id = ?1 ORDER BY chunk_index", - )?; - let chunks = stmt - .query_map(params![upload_id.to_string()], |row| { - Ok(crate::sync::ChunkInfo { - upload_id: parse_uuid(&row.get::<_, String>(0)?)?, - chunk_index: row.get::<_, i64>(1)? as u64, - offset: row.get::<_, i64>(2)? as u64, - size: row.get::<_, i64>(3)? as u64, - hash: row.get(4)?, - received_at: parse_datetime(&row.get::<_, String>(5)?), - }) - })? - .collect::>>()?; - Ok::<_, rusqlite::Error>(chunks) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - .map_err(|e| PinakesError::Database(e.to_string())) - } + )?; + let chunks = stmt + .query_map(params![upload_id.to_string()], |row| { + Ok(crate::sync::ChunkInfo { + upload_id: parse_uuid(&row.get::<_, String>(0)?)?, + chunk_index: row.get::<_, i64>(1)? as u64, + offset: row.get::<_, i64>(2)? as u64, + size: row.get::<_, i64>(3)? as u64, + hash: row.get(4)?, + received_at: parse_datetime(&row.get::<_, String>(5)?), + }) + })? + .collect::>>()?; + Ok::<_, rusqlite::Error>(chunks) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + .map_err(|e| PinakesError::Database(e.to_string())) + } - async fn cleanup_expired_uploads(&self) -> Result { - let conn = self.conn.clone(); - let now = chrono::Utc::now().to_rfc3339(); + async fn cleanup_expired_uploads(&self) -> Result { + let conn = self.conn.clone(); + let now = chrono::Utc::now().to_rfc3339(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.execute( - "DELETE FROM upload_sessions WHERE expires_at < ?1", - params![&now], - ) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - .map(|n| n as u64) - .map_err(|e| PinakesError::Database(e.to_string())) - } + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.execute( + "DELETE FROM upload_sessions WHERE expires_at < ?1", + params![&now], + ) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + .map(|n| n as u64) + .map_err(|e| PinakesError::Database(e.to_string())) + } - // ===== Sync Conflicts ===== + // ===== Sync Conflicts ===== - async fn record_conflict(&self, conflict: &crate::sync::SyncConflict) -> Result<()> { - let conn = self.conn.clone(); - let conflict = conflict.clone(); + async fn record_conflict( + &self, + conflict: &crate::sync::SyncConflict, + ) -> Result<()> { + let conn = self.conn.clone(); + let conflict = conflict.clone(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.execute( - "INSERT INTO sync_conflicts (id, device_id, path, local_hash, local_mtime, + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.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)", - params![ - conflict.id.to_string(), - conflict.device_id.0.to_string(), - conflict.path, - conflict.local_hash, - conflict.local_mtime, - conflict.server_hash, - conflict.server_mtime, - conflict.detected_at.to_rfc3339(), - ], - )?; - Ok::<_, rusqlite::Error>(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; - Ok(()) - } + params![ + conflict.id.to_string(), + conflict.device_id.0.to_string(), + conflict.path, + conflict.local_hash, + conflict.local_mtime, + conflict.server_hash, + conflict.server_mtime, + conflict.detected_at.to_rfc3339(), + ], + )?; + Ok::<_, rusqlite::Error>(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; + Ok(()) + } - async fn get_unresolved_conflicts( - &self, - device_id: crate::sync::DeviceId, - ) -> Result> { - let conn = self.conn.clone(); + async fn get_unresolved_conflicts( + &self, + device_id: crate::sync::DeviceId, + ) -> Result> { + let conn = self.conn.clone(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT id, device_id, path, local_hash, local_mtime, server_hash, server_mtime, + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + let mut stmt = conn.prepare( + "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", - )?; - let conflicts = stmt - .query_map(params![device_id.0.to_string()], |row| { - Ok(crate::sync::SyncConflict { - id: parse_uuid(&row.get::<_, String>(0)?)?, - device_id: crate::sync::DeviceId(parse_uuid(&row.get::<_, String>(1)?)?), - path: row.get(2)?, - local_hash: row.get(3)?, - local_mtime: row.get(4)?, - server_hash: row.get(5)?, - server_mtime: row.get(6)?, - detected_at: parse_datetime(&row.get::<_, String>(7)?), - resolved_at: row.get::<_, Option>(8)?.map(|s| parse_datetime(&s)), - resolution: row.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::>>()?; - Ok::<_, rusqlite::Error>(conflicts) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - .map_err(|e| PinakesError::Database(e.to_string())) - } + FROM sync_conflicts WHERE device_id = ?1 AND resolved_at IS \ + NULL", + )?; + let conflicts = stmt + .query_map(params![device_id.0.to_string()], |row| { + Ok(crate::sync::SyncConflict { + id: parse_uuid(&row.get::<_, String>(0)?)?, + device_id: crate::sync::DeviceId(parse_uuid( + &row.get::<_, String>(1)?, + )?), + path: row.get(2)?, + local_hash: row.get(3)?, + local_mtime: row.get(4)?, + server_hash: row.get(5)?, + server_mtime: row.get(6)?, + detected_at: parse_datetime(&row.get::<_, String>(7)?), + resolved_at: row + .get::<_, Option>(8)? + .map(|s| parse_datetime(&s)), + resolution: row.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::>>()?; + Ok::<_, rusqlite::Error>(conflicts) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + .map_err(|e| PinakesError::Database(e.to_string())) + } - async fn resolve_conflict( - &self, - id: Uuid, - resolution: crate::config::ConflictResolution, - ) -> Result<()> { - let conn = self.conn.clone(); - let now = chrono::Utc::now().to_rfc3339(); - 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", + async fn resolve_conflict( + &self, + id: Uuid, + resolution: crate::config::ConflictResolution, + ) -> Result<()> { + let conn = self.conn.clone(); + let now = chrono::Utc::now().to_rfc3339(); + 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", + }; + + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.execute( + "UPDATE sync_conflicts SET resolved_at = ?1, resolution = ?2 WHERE id \ + = ?3", + params![&now, resolution_str, id.to_string()], + )?; + Ok::<_, rusqlite::Error>(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; + Ok(()) + } + + // ===== Enhanced Sharing ===== + + async fn create_share( + &self, + share: &crate::sharing::Share, + ) -> Result { + let conn = self.conn.clone(); + let share = share.clone(); + + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + + let (recipient_type, recipient_user_id, public_token, password_hash) = + 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.to_string()), None, None) + }, + crate::sharing::ShareRecipient::Group { .. } => { + ("group", None, None, None) + }, + crate::sharing::ShareRecipient::Federated { .. } => { + ("federated", None, None, None) + }, }; - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.execute( - "UPDATE sync_conflicts SET resolved_at = ?1, resolution = ?2 WHERE id = ?3", - params![&now, resolution_str, id.to_string()], - )?; - Ok::<_, rusqlite::Error>(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; - Ok(()) - } - - // ===== Enhanced Sharing ===== - - async fn create_share(&self, share: &crate::sharing::Share) -> Result { - let conn = self.conn.clone(); - let share = share.clone(); - - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - - let (recipient_type, recipient_user_id, public_token, password_hash) = 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.to_string()), None, None) - } - crate::sharing::ShareRecipient::Group { .. } => ("group", None, None, None), - crate::sharing::ShareRecipient::Federated { .. } => ("federated", None, None, None), - }; - - conn.execute( - "INSERT INTO shares (id, target_type, target_id, owner_id, recipient_type, + conn.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, + 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)", - params![ - share.id.0.to_string(), - share.target.target_type(), - share.target.target_id().to_string(), - share.owner_id.0.to_string(), - 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.map(|dt| dt.to_rfc3339()), - share.access_count as i64, - share.inherit_to_children, - share.parent_share_id.map(|s| s.0.to_string()), - share.created_at.to_rfc3339(), - share.updated_at.to_rfc3339(), - ], - )?; - Ok::<_, rusqlite::Error>(share) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - .map_err(|e| PinakesError::Database(e.to_string())) - } + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, \ + ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21)", + params![ + share.id.0.to_string(), + share.target.target_type(), + share.target.target_id().to_string(), + share.owner_id.0.to_string(), + 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.map(|dt| dt.to_rfc3339()), + share.access_count as i64, + share.inherit_to_children, + share.parent_share_id.map(|s| s.0.to_string()), + share.created_at.to_rfc3339(), + share.updated_at.to_rfc3339(), + ], + )?; + Ok::<_, rusqlite::Error>(share) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + .map_err(|e| PinakesError::Database(e.to_string())) + } - async fn get_share(&self, id: crate::sharing::ShareId) -> Result { - let conn = self.conn.clone(); + async fn get_share( + &self, + id: crate::sharing::ShareId, + ) -> Result { + let conn = self.conn.clone(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.query_row( - "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 + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.query_row( + "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", - params![id.0.to_string()], - |row| row_to_share(row), - ) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - .map_err(|e| PinakesError::Database(e.to_string())) - } + params![id.0.to_string()], + |row| row_to_share(row), + ) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + .map_err(|e| PinakesError::Database(e.to_string())) + } - async fn get_share_by_token(&self, token: &str) -> Result { - let conn = self.conn.clone(); - let token = token.to_string(); + async fn get_share_by_token( + &self, + token: &str, + ) -> Result { + let conn = self.conn.clone(); + let token = token.to_string(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.query_row( - "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 + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.query_row( + "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", - params![&token], - |row| row_to_share(row), - ) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - .map_err(|e| PinakesError::Database(e.to_string())) - } + params![&token], + |row| row_to_share(row), + ) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + .map_err(|e| PinakesError::Database(e.to_string())) + } - async fn list_shares_by_owner( - &self, - owner_id: crate::users::UserId, - pagination: &Pagination, - ) -> Result> { - let conn = self.conn.clone(); - let offset = pagination.offset; - let limit = pagination.limit; + async fn list_shares_by_owner( + &self, + owner_id: crate::users::UserId, + pagination: &Pagination, + ) -> Result> { + let conn = self.conn.clone(); + let offset = pagination.offset; + let limit = pagination.limit; - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - let mut stmt = conn.prepare( - "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", - )?; - let shares = stmt - .query_map( - params![owner_id.0.to_string(), limit as i64, offset as i64], - |row| row_to_share(row), - )? - .collect::>>()?; - Ok::<_, rusqlite::Error>(shares) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - .map_err(|e| PinakesError::Database(e.to_string())) - } + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + let mut stmt = conn.prepare( + "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", + )?; + let shares = stmt + .query_map( + params![owner_id.0.to_string(), limit as i64, offset as i64], + |row| row_to_share(row), + )? + .collect::>>()?; + Ok::<_, rusqlite::Error>(shares) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + .map_err(|e| PinakesError::Database(e.to_string())) + } - async fn list_shares_for_user( - &self, - user_id: crate::users::UserId, - pagination: &Pagination, - ) -> Result> { - let conn = self.conn.clone(); - let offset = pagination.offset; - let limit = pagination.limit; + async fn list_shares_for_user( + &self, + user_id: crate::users::UserId, + pagination: &Pagination, + ) -> Result> { + let conn = self.conn.clone(); + let offset = pagination.offset; + let limit = pagination.limit; - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - let mut stmt = conn.prepare( - "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", - )?; - let shares = stmt.query_map( - params![user_id.0.to_string(), limit as i64, offset as i64], - |row| row_to_share(row), - )?.collect::>>()?; - Ok::<_, rusqlite::Error>(shares) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - .map_err(|e| PinakesError::Database(e.to_string())) - } + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + let mut stmt = conn.prepare( + "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", + )?; + let shares = stmt + .query_map( + params![user_id.0.to_string(), limit as i64, offset as i64], + |row| row_to_share(row), + )? + .collect::>>()?; + Ok::<_, rusqlite::Error>(shares) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + .map_err(|e| PinakesError::Database(e.to_string())) + } - async fn list_shares_for_target( - &self, - target: &crate::sharing::ShareTarget, - ) -> Result> { - let conn = self.conn.clone(); - let target_type = target.target_type().to_string(); - let target_id = target.target_id().to_string(); + async fn list_shares_for_target( + &self, + target: &crate::sharing::ShareTarget, + ) -> Result> { + let conn = self.conn.clone(); + let target_type = target.target_type().to_string(); + let target_id = target.target_id().to_string(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - let mut stmt = conn.prepare( - "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 + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + let mut stmt = conn.prepare( + "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", - )?; - let shares = stmt - .query_map(params![&target_type, &target_id], |row| row_to_share(row))? - .collect::>>()?; - Ok::<_, rusqlite::Error>(shares) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - .map_err(|e| PinakesError::Database(e.to_string())) - } + )?; + let shares = stmt + .query_map(params![&target_type, &target_id], |row| row_to_share(row))? + .collect::>>()?; + Ok::<_, rusqlite::Error>(shares) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + .map_err(|e| PinakesError::Database(e.to_string())) + } - async fn update_share(&self, share: &crate::sharing::Share) -> Result { - let conn = self.conn.clone(); - let share = share.clone(); + async fn update_share( + &self, + share: &crate::sharing::Share, + ) -> Result { + let conn = self.conn.clone(); + let share = share.clone(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.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, + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.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", - params![ - 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.map(|dt| dt.to_rfc3339()), - share.inherit_to_children, - share.updated_at.to_rfc3339(), - share.id.0.to_string(), - ], - )?; - Ok::<_, rusqlite::Error>(share) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - .map_err(|e| PinakesError::Database(e.to_string())) - } + params![ + 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.map(|dt| dt.to_rfc3339()), + share.inherit_to_children, + share.updated_at.to_rfc3339(), + share.id.0.to_string(), + ], + )?; + Ok::<_, rusqlite::Error>(share) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + .map_err(|e| PinakesError::Database(e.to_string())) + } - async fn delete_share(&self, id: crate::sharing::ShareId) -> Result<()> { - let conn = self.conn.clone(); + async fn delete_share(&self, id: crate::sharing::ShareId) -> Result<()> { + let conn = self.conn.clone(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.execute( - "DELETE FROM shares WHERE id = ?1", - params![id.0.to_string()], - )?; - Ok::<_, rusqlite::Error>(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; - Ok(()) - } + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.execute("DELETE FROM shares WHERE id = ?1", params![ + id.0.to_string() + ])?; + Ok::<_, rusqlite::Error>(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; + Ok(()) + } - async fn record_share_access(&self, id: crate::sharing::ShareId) -> Result<()> { - let conn = self.conn.clone(); - let now = chrono::Utc::now().to_rfc3339(); + async fn record_share_access( + &self, + id: crate::sharing::ShareId, + ) -> Result<()> { + let conn = self.conn.clone(); + let now = chrono::Utc::now().to_rfc3339(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.execute( - "UPDATE shares SET access_count = access_count + 1, last_accessed = ?1 WHERE id = ?2", - params![&now, id.0.to_string()], - )?; - Ok::<_, rusqlite::Error>(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; - Ok(()) - } + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.execute( + "UPDATE shares SET access_count = access_count + 1, last_accessed = \ + ?1 WHERE id = ?2", + params![&now, id.0.to_string()], + )?; + Ok::<_, rusqlite::Error>(()) + }) + .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(); + 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, - } + for share in shares { + // Skip expired shares + if let Some(exp) = share.expires_at { + if exp < now { + continue; } + } - Ok(None) + 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, + } } - 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)); - } + Ok(None) + } - // Check collection shares (inheritance) - let conn = self.conn.clone(); - let media_id_str = media_id.0.to_string(); - - let collection_ids: Vec = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - let mut stmt = - conn.prepare("SELECT collection_id FROM collection_items WHERE media_id = ?1")?; - let ids = stmt - .query_map([&media_id_str], |row| { - let id_str: String = row.get(0)?; - Ok(Uuid::parse_str(&id_str).ok()) - })? - .filter_map(|r| r.ok().flatten()) - .collect::>(); - Ok::<_, rusqlite::Error>(ids) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - .map_err(|e| PinakesError::Database(e.to_string()))?; - - for collection_id in collection_ids { - 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 conn = self.conn.clone(); - let media_id_str = media_id.0.to_string(); - - let tag_ids: Vec = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - let mut stmt = conn.prepare("SELECT tag_id FROM media_tags WHERE media_id = ?1")?; - let ids = stmt - .query_map([&media_id_str], |row| { - let id_str: String = row.get(0)?; - Ok(Uuid::parse_str(&id_str).ok()) - })? - .filter_map(|r| r.ok().flatten()) - .collect::>(); - Ok::<_, rusqlite::Error>(ids) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - .map_err(|e| PinakesError::Database(e.to_string()))?; - - for tag_id in tag_ids { - 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 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)); } - async fn batch_delete_shares(&self, ids: &[crate::sharing::ShareId]) -> Result { - let conn = self.conn.clone(); - let id_strings: Vec = ids.iter().map(|id| id.0.to_string()).collect(); + // Check collection shares (inheritance) + let conn = self.conn.clone(); + let media_id_str = media_id.0.to_string(); - if id_strings.is_empty() { - return Ok(0); - } + let collection_ids: Vec = tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT collection_id FROM collection_items WHERE media_id = ?1", + )?; + let ids = stmt + .query_map([&media_id_str], |row| { + let id_str: String = row.get(0)?; + Ok(Uuid::parse_str(&id_str).ok()) + })? + .filter_map(|r| r.ok().flatten()) + .collect::>(); + Ok::<_, rusqlite::Error>(ids) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + .map_err(|e| PinakesError::Database(e.to_string()))?; - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - let placeholders: Vec = - (1..=id_strings.len()).map(|i| format!("?{}", i)).collect(); - let sql = format!( - "DELETE FROM shares WHERE id IN ({})", - placeholders.join(", ") - ); - let params: Vec<&dyn rusqlite::types::ToSql> = id_strings - .iter() - .map(|s| s as &dyn rusqlite::types::ToSql) - .collect(); - conn.execute(&sql, &*params) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - .map(|n| n as u64) - .map_err(|e| PinakesError::Database(e.to_string())) + for collection_id in collection_ids { + let target = crate::sharing::ShareTarget::Collection { collection_id }; + if let Some(perms) = self.check_share_access(user_id, &target).await? { + return Ok(Some(perms)); + } } - async fn cleanup_expired_shares(&self) -> Result { - let conn = self.conn.clone(); - let now = chrono::Utc::now().to_rfc3339(); + // Check tag shares (inheritance) + let conn = self.conn.clone(); + let media_id_str = media_id.0.to_string(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.execute( - "DELETE FROM shares WHERE expires_at IS NOT NULL AND expires_at < ?1", - params![&now], - ) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - .map(|n| n as u64) - .map_err(|e| PinakesError::Database(e.to_string())) + let tag_ids: Vec = tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + let mut stmt = + conn.prepare("SELECT tag_id FROM media_tags WHERE media_id = ?1")?; + let ids = stmt + .query_map([&media_id_str], |row| { + let id_str: String = row.get(0)?; + Ok(Uuid::parse_str(&id_str).ok()) + })? + .filter_map(|r| r.ok().flatten()) + .collect::>(); + Ok::<_, rusqlite::Error>(ids) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + .map_err(|e| PinakesError::Database(e.to_string()))?; + + for tag_id in tag_ids { + let target = crate::sharing::ShareTarget::Tag { tag_id }; + if let Some(perms) = self.check_share_access(user_id, &target).await? { + return Ok(Some(perms)); + } } - async fn record_share_activity(&self, activity: &crate::sharing::ShareActivity) -> Result<()> { - let conn = self.conn.clone(); - let activity = activity.clone(); + Ok(None) + } - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.execute( - "INSERT INTO share_activity (id, share_id, actor_id, actor_ip, action, details, timestamp) + async fn batch_delete_shares( + &self, + ids: &[crate::sharing::ShareId], + ) -> Result { + let conn = self.conn.clone(); + let id_strings: Vec = + ids.iter().map(|id| id.0.to_string()).collect(); + + if id_strings.is_empty() { + return Ok(0); + } + + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + let placeholders: Vec = + (1..=id_strings.len()).map(|i| format!("?{}", i)).collect(); + let sql = format!( + "DELETE FROM shares WHERE id IN ({})", + placeholders.join(", ") + ); + let params: Vec<&dyn rusqlite::types::ToSql> = id_strings + .iter() + .map(|s| s as &dyn rusqlite::types::ToSql) + .collect(); + conn.execute(&sql, &*params) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + .map(|n| n as u64) + .map_err(|e| PinakesError::Database(e.to_string())) + } + + async fn cleanup_expired_shares(&self) -> Result { + let conn = self.conn.clone(); + let now = chrono::Utc::now().to_rfc3339(); + + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.execute( + "DELETE FROM shares WHERE expires_at IS NOT NULL AND expires_at < ?1", + params![&now], + ) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + .map(|n| n as u64) + .map_err(|e| PinakesError::Database(e.to_string())) + } + + async fn record_share_activity( + &self, + activity: &crate::sharing::ShareActivity, + ) -> Result<()> { + let conn = self.conn.clone(); + let activity = activity.clone(); + + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.execute( + "INSERT INTO share_activity (id, share_id, actor_id, actor_ip, \ + action, details, timestamp) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - params![ - activity.id.to_string(), - activity.share_id.0.to_string(), - activity.actor_id.map(|u| u.0.to_string()), - activity.actor_ip, - activity.action.to_string(), - activity.details, - activity.timestamp.to_rfc3339(), - ], - )?; - Ok::<_, rusqlite::Error>(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; - Ok(()) - } + params![ + activity.id.to_string(), + activity.share_id.0.to_string(), + activity.actor_id.map(|u| u.0.to_string()), + activity.actor_ip, + activity.action.to_string(), + activity.details, + activity.timestamp.to_rfc3339(), + ], + )?; + Ok::<_, rusqlite::Error>(()) + }) + .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 conn = self.conn.clone(); - let offset = pagination.offset; - let limit = pagination.limit; + async fn get_share_activity( + &self, + share_id: crate::sharing::ShareId, + pagination: &Pagination, + ) -> Result> { + let conn = self.conn.clone(); + let offset = pagination.offset; + let limit = pagination.limit; - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT id, share_id, actor_id, actor_ip, action, details, timestamp - FROM share_activity WHERE share_id = ?1 ORDER BY timestamp DESC LIMIT ?2 OFFSET ?3", - )?; - let activities = stmt.query_map( - params![share_id.0.to_string(), limit as i64, offset as i64], - |row| { - Ok(crate::sharing::ShareActivity { - id: parse_uuid(&row.get::<_, String>(0)?)?, - share_id: crate::sharing::ShareId(parse_uuid(&row.get::<_, String>(1)?)?), - actor_id: row.get::<_, Option>(2)?.and_then(|s| Uuid::parse_str(&s).ok().map(crate::users::UserId)), - actor_ip: row.get(3)?, - action: row.get::<_, String>(4)?.parse().unwrap_or(crate::sharing::ShareActivityAction::Accessed), - details: row.get(5)?, - timestamp: parse_datetime(&row.get::<_, String>(6)?), - }) - }, - )?.collect::>>()?; - Ok::<_, rusqlite::Error>(activities) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? - .map_err(|e| PinakesError::Database(e.to_string())) - } + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, share_id, actor_id, actor_ip, action, details, timestamp + FROM share_activity WHERE share_id = ?1 ORDER BY timestamp \ + DESC LIMIT ?2 OFFSET ?3", + )?; + let activities = stmt + .query_map( + params![share_id.0.to_string(), limit as i64, offset as i64], + |row| { + Ok(crate::sharing::ShareActivity { + id: parse_uuid(&row.get::<_, String>(0)?)?, + share_id: crate::sharing::ShareId(parse_uuid( + &row.get::<_, String>(1)?, + )?), + actor_id: row.get::<_, Option>(2)?.and_then(|s| { + Uuid::parse_str(&s).ok().map(crate::users::UserId) + }), + actor_ip: row.get(3)?, + action: row + .get::<_, String>(4)? + .parse() + .unwrap_or(crate::sharing::ShareActivityAction::Accessed), + details: row.get(5)?, + timestamp: parse_datetime(&row.get::<_, String>(6)?), + }) + }, + )? + .collect::>>()?; + Ok::<_, rusqlite::Error>(activities) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + .map_err(|e| PinakesError::Database(e.to_string())) + } - async fn create_share_notification( - &self, - notification: &crate::sharing::ShareNotification, - ) -> Result<()> { - let conn = self.conn.clone(); - let notification = notification.clone(); + async fn create_share_notification( + &self, + notification: &crate::sharing::ShareNotification, + ) -> Result<()> { + let conn = self.conn.clone(); + let notification = notification.clone(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.execute( - "INSERT INTO share_notifications (id, user_id, share_id, notification_type, is_read, created_at) + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.execute( + "INSERT INTO share_notifications (id, user_id, share_id, \ + notification_type, is_read, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - params![ - notification.id.to_string(), - notification.user_id.0.to_string(), - notification.share_id.0.to_string(), - notification.notification_type.to_string(), - notification.is_read, - notification.created_at.to_rfc3339(), - ], - )?; - Ok::<_, rusqlite::Error>(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; - Ok(()) + params![ + notification.id.to_string(), + notification.user_id.0.to_string(), + notification.share_id.0.to_string(), + notification.notification_type.to_string(), + notification.is_read, + notification.created_at.to_rfc3339(), + ], + )?; + Ok::<_, rusqlite::Error>(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; + Ok(()) + } + + async fn get_unread_notifications( + &self, + user_id: crate::users::UserId, + ) -> Result> { + let conn = self.conn.clone(); + + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, user_id, share_id, notification_type, is_read, created_at + FROM share_notifications WHERE user_id = ?1 AND is_read = 0 \ + ORDER BY created_at DESC", + )?; + let notifications = stmt + .query_map(params![user_id.0.to_string()], |row| { + Ok(crate::sharing::ShareNotification { + id: parse_uuid(&row.get::<_, String>(0)?)?, + user_id: crate::users::UserId(parse_uuid( + &row.get::<_, String>(1)?, + )?), + share_id: crate::sharing::ShareId(parse_uuid( + &row.get::<_, String>(2)?, + )?), + notification_type: row + .get::<_, String>(3)? + .parse() + .unwrap_or(crate::sharing::ShareNotificationType::NewShare), + is_read: row.get(4)?, + created_at: parse_datetime(&row.get::<_, String>(5)?), + }) + })? + .collect::>>()?; + Ok::<_, rusqlite::Error>(notifications) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + .map_err(|e| PinakesError::Database(e.to_string())) + } + + async fn mark_notification_read(&self, id: Uuid) -> Result<()> { + let conn = self.conn.clone(); + + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.execute( + "UPDATE share_notifications SET is_read = 1 WHERE id = ?1", + params![id.to_string()], + )?; + Ok::<_, rusqlite::Error>(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; + Ok(()) + } + + async fn mark_all_notifications_read( + &self, + user_id: crate::users::UserId, + ) -> Result<()> { + let conn = self.conn.clone(); + + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.execute( + "UPDATE share_notifications SET is_read = 1 WHERE user_id = ?1", + params![user_id.0.to_string()], + )?; + Ok::<_, rusqlite::Error>(()) + }) + .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(), + )); } - async fn get_unread_notifications( - &self, - user_id: crate::users::UserId, - ) -> Result> { - let conn = self.conn.clone(); + let conn = self.conn.clone(); + let id_str = id.0.to_string(); + let new_name = new_name.to_string(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT id, user_id, share_id, notification_type, is_read, created_at - FROM share_notifications WHERE user_id = ?1 AND is_read = 0 ORDER BY created_at DESC", - )?; - let notifications = stmt.query_map(params![user_id.0.to_string()], |row| { - Ok(crate::sharing::ShareNotification { - id: parse_uuid(&row.get::<_, String>(0)?)?, - user_id: crate::users::UserId(parse_uuid(&row.get::<_, String>(1)?)?), - share_id: crate::sharing::ShareId(parse_uuid(&row.get::<_, String>(2)?)?), - notification_type: row.get::<_, String>(3)?.parse().unwrap_or(crate::sharing::ShareNotificationType::NewShare), - is_read: row.get(4)?, - created_at: parse_datetime(&row.get::<_, String>(5)?), - }) - })?.collect::>>()?; - Ok::<_, rusqlite::Error>(notifications) - }) + let (old_path, storage_mode) = tokio::task::spawn_blocking({ + let conn = conn.clone(); + let id_str = id_str.clone(); + move || { + let conn = conn.lock().unwrap(); + let row: (String, String) = conn.query_row( + "SELECT path, storage_mode FROM media_items WHERE id = ?1 AND \ + deleted_at IS NULL", + params![id_str], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + Ok::<_, rusqlite::Error>(row) + } + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; + + 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::Database(e.to_string()))? - .map_err(|e| PinakesError::Database(e.to_string())) + .map_err(|e| { + PinakesError::Io(std::io::Error::new( + e.kind(), + format!("Failed to rename file: {}", e), + )) + })?; } - async fn mark_notification_read(&self, id: Uuid) -> Result<()> { - let conn = self.conn.clone(); + // Update the database + let now = chrono::Utc::now().to_rfc3339(); + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.execute( + "UPDATE media_items SET file_name = ?1, path = ?2, updated_at = ?3 \ + WHERE id = ?4", + params![new_name, new_path_str, now, id_str], + )?; + Ok::<_, rusqlite::Error>(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.execute( - "UPDATE share_notifications SET is_read = 1 WHERE id = ?1", - params![id.to_string()], - )?; - Ok::<_, rusqlite::Error>(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; - Ok(()) + Ok(old_path) + } + + async fn move_media( + &self, + id: MediaId, + new_directory: &std::path::Path, + ) -> Result { + let conn = self.conn.clone(); + let id_str = id.0.to_string(); + let new_dir = new_directory.to_path_buf(); + + let (old_path, file_name, storage_mode) = tokio::task::spawn_blocking({ + let conn = conn.clone(); + let id_str = id_str.clone(); + move || { + let conn = conn.lock().unwrap(); + let row: (String, String, String) = conn.query_row( + "SELECT path, file_name, storage_mode FROM media_items WHERE id = \ + ?1 AND deleted_at IS NULL", + params![id_str], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + )?; + Ok::<_, rusqlite::Error>(row) + } + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; + + let old_path_buf = std::path::PathBuf::from(&old_path); + let new_path = new_dir.join(&file_name); + let new_path_str = new_path.to_string_lossy().to_string(); + + // Ensure the target directory exists + if !new_dir.exists() { + tokio::fs::create_dir_all(&new_dir).await?; } - async fn mark_all_notifications_read(&self, user_id: crate::users::UserId) -> Result<()> { - let conn = self.conn.clone(); - - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.execute( - "UPDATE share_notifications SET is_read = 1 WHERE user_id = ?1", - params![user_id.0.to_string()], - )?; - Ok::<_, rusqlite::Error>(()) - }) + // 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::Database(e.to_string()))??; - Ok(()) + .map_err(|e| { + PinakesError::Io(std::io::Error::new( + e.kind(), + format!("Failed to move file: {}", e), + )) + })?; } - // ===== File Management ===== + // Update the database + let now = chrono::Utc::now().to_rfc3339(); + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.execute( + "UPDATE media_items SET path = ?1, updated_at = ?2 WHERE id = ?3", + params![new_path_str, now, id_str], + )?; + Ok::<_, rusqlite::Error>(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; - 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(), - )); - } + Ok(old_path) + } - let conn = self.conn.clone(); - let id_str = id.0.to_string(); - let new_name = new_name.to_string(); + // ===== Trash / Soft Delete ===== - let (old_path, storage_mode) = tokio::task::spawn_blocking({ - let conn = conn.clone(); - let id_str = id_str.clone(); - move || { - let conn = conn.lock().unwrap(); - let row: (String, String) = conn.query_row( - "SELECT path, storage_mode FROM media_items WHERE id = ?1 AND deleted_at IS NULL", - params![id_str], - |row| Ok((row.get(0)?, row.get(1)?)), - )?; - Ok::<_, rusqlite::Error>(row) - } - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; + async fn soft_delete_media(&self, id: MediaId) -> Result<()> { + let conn = self.conn.clone(); + let id_str = id.0.to_string(); + let now = chrono::Utc::now().to_rfc3339(); - 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(); + let rows_affected = tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.execute( + "UPDATE media_items SET deleted_at = ?1, updated_at = ?1 WHERE id = \ + ?2 AND deleted_at IS NULL", + params![now, id_str], + ) + }) + .await + .map_err(|e| PinakesError::Database(e.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 - let now = chrono::Utc::now().to_rfc3339(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.execute( - "UPDATE media_items SET file_name = ?1, path = ?2, updated_at = ?3 WHERE id = ?4", - params![new_name, new_path_str, now, id_str], - )?; - Ok::<_, rusqlite::Error>(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; - - Ok(old_path) + if rows_affected == 0 { + return Err(PinakesError::NotFound(format!( + "Media item {} not found or already deleted", + id + ))); } - async fn move_media(&self, id: MediaId, new_directory: &std::path::Path) -> Result { - let conn = self.conn.clone(); - let id_str = id.0.to_string(); - let new_dir = new_directory.to_path_buf(); + Ok(()) + } - let (old_path, file_name, storage_mode) = tokio::task::spawn_blocking({ - let conn = conn.clone(); - let id_str = id_str.clone(); - move || { - let conn = conn.lock().unwrap(); - let row: (String, String, String) = conn.query_row( - "SELECT path, file_name, storage_mode FROM media_items WHERE id = ?1 AND deleted_at IS NULL", - params![id_str], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), - )?; - Ok::<_, rusqlite::Error>(row) - } - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; + async fn restore_media(&self, id: MediaId) -> Result<()> { + let conn = self.conn.clone(); + let id_str = id.0.to_string(); + let now = chrono::Utc::now().to_rfc3339(); - let old_path_buf = std::path::PathBuf::from(&old_path); - let new_path = new_dir.join(&file_name); - let new_path_str = new_path.to_string_lossy().to_string(); + let rows_affected = tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.execute( + "UPDATE media_items SET deleted_at = NULL, updated_at = ?1 WHERE id = \ + ?2 AND deleted_at IS NOT NULL", + params![now, id_str], + ) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; - // Ensure the target directory exists - if !new_dir.exists() { - tokio::fs::create_dir_all(&new_dir).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 - let now = chrono::Utc::now().to_rfc3339(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.execute( - "UPDATE media_items SET path = ?1, updated_at = ?2 WHERE id = ?3", - params![new_path_str, now, id_str], - )?; - Ok::<_, rusqlite::Error>(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; - - Ok(old_path) + if rows_affected == 0 { + return Err(PinakesError::NotFound(format!( + "Media item {} not found in trash", + id + ))); } - // ===== Trash / Soft Delete ===== + Ok(()) + } - async fn soft_delete_media(&self, id: MediaId) -> Result<()> { - let conn = self.conn.clone(); - let id_str = id.0.to_string(); - let now = chrono::Utc::now().to_rfc3339(); + async fn list_trash( + &self, + pagination: &Pagination, + ) -> Result> { + let conn = self.conn.clone(); + let offset = pagination.offset; + let limit = pagination.limit; - let rows_affected = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.execute( - "UPDATE media_items SET deleted_at = ?1, updated_at = ?1 WHERE id = ?2 AND deleted_at IS NULL", - params![now, id_str], - ) - }) - .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 conn = self.conn.clone(); - let id_str = id.0.to_string(); - let now = chrono::Utc::now().to_rfc3339(); - - let rows_affected = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.execute( - "UPDATE media_items SET deleted_at = NULL, updated_at = ?1 WHERE id = ?2 AND deleted_at IS NOT NULL", - params![now, id_str], - ) - }) - .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 conn = self.conn.clone(); - let offset = pagination.offset; - let limit = pagination.limit; - - let items = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT id, path, file_name, media_type, content_hash, file_size, - title, artist, album, genre, year, duration_secs, description, + let items = tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + let mut stmt = conn.prepare( + "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, + 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", - )?; - let rows = stmt.query_map(params![limit as i64, offset as i64], row_to_media_item)?; - let mut items = Vec::new(); - for row in rows { - items.push(row?); - } - Ok::<_, rusqlite::Error>(items) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; + )?; + let rows = stmt + .query_map(params![limit as i64, offset as i64], row_to_media_item)?; + let mut items = Vec::new(); + for row in rows { + items.push(row?); + } + Ok::<_, rusqlite::Error>(items) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; - Ok(items) - } + Ok(items) + } - async fn empty_trash(&self) -> Result { - let conn = self.conn.clone(); + async fn empty_trash(&self) -> Result { + let conn = self.conn.clone(); - let count = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let count = tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); - // First, get the IDs to clean up related data - let mut stmt = - conn.prepare("SELECT id FROM media_items WHERE deleted_at IS NOT NULL")?; - let ids: Vec = stmt - .query_map([], |row| row.get(0))? - .filter_map(|r| r.ok()) - .collect(); + // First, get the IDs to clean up related data + let mut stmt = conn + .prepare("SELECT id FROM media_items WHERE deleted_at IS NOT NULL")?; + let ids: Vec = stmt + .query_map([], |row| row.get(0))? + .filter_map(|r| r.ok()) + .collect(); - // Delete related data - for id in &ids { - conn.execute("DELETE FROM media_tags WHERE media_id = ?1", params![id])?; - conn.execute( - "DELETE FROM collection_items WHERE media_id = ?1", - params![id], - )?; - conn.execute("DELETE FROM custom_fields WHERE media_id = ?1", params![id])?; - } + // Delete related data + for id in &ids { + conn + .execute("DELETE FROM media_tags WHERE media_id = ?1", params![id])?; + conn.execute( + "DELETE FROM collection_items WHERE media_id = ?1", + params![id], + )?; + conn + .execute("DELETE FROM custom_fields WHERE media_id = ?1", params![ + id + ])?; + } - // Delete the media items - let count = conn.execute("DELETE FROM media_items WHERE deleted_at IS NOT NULL", [])?; - Ok::<_, rusqlite::Error>(count as u64) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; + // Delete the media items + let count = conn + .execute("DELETE FROM media_items WHERE deleted_at IS NOT NULL", [])?; + Ok::<_, rusqlite::Error>(count as u64) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; - Ok(count) - } + Ok(count) + } - async fn purge_old_trash(&self, before: chrono::DateTime) -> Result { - let conn = self.conn.clone(); - let before_str = before.to_rfc3339(); + async fn purge_old_trash( + &self, + before: chrono::DateTime, + ) -> Result { + let conn = self.conn.clone(); + let before_str = before.to_rfc3339(); - let count = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let count = tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); - // First, get the IDs to clean up related data - let mut stmt = conn.prepare( - "SELECT id FROM media_items WHERE deleted_at IS NOT NULL AND deleted_at < ?1", - )?; - let ids: Vec = stmt - .query_map(params![before_str], |row| row.get(0))? - .filter_map(|r| r.ok()) - .collect(); + // First, get the IDs to clean up related data + let mut stmt = conn.prepare( + "SELECT id FROM media_items WHERE deleted_at IS NOT NULL AND \ + deleted_at < ?1", + )?; + let ids: Vec = stmt + .query_map(params![before_str], |row| row.get(0))? + .filter_map(|r| r.ok()) + .collect(); - // Delete related data - for id in &ids { - conn.execute("DELETE FROM media_tags WHERE media_id = ?1", params![id])?; - conn.execute( - "DELETE FROM collection_items WHERE media_id = ?1", - params![id], - )?; - conn.execute("DELETE FROM custom_fields WHERE media_id = ?1", params![id])?; - } + // Delete related data + for id in &ids { + conn + .execute("DELETE FROM media_tags WHERE media_id = ?1", params![id])?; + conn.execute( + "DELETE FROM collection_items WHERE media_id = ?1", + params![id], + )?; + conn + .execute("DELETE FROM custom_fields WHERE media_id = ?1", params![ + id + ])?; + } - // Delete the media items - let count = conn.execute( - "DELETE FROM media_items WHERE deleted_at IS NOT NULL AND deleted_at < ?1", - params![before_str], - )?; - Ok::<_, rusqlite::Error>(count as u64) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; + // Delete the media items + let count = conn.execute( + "DELETE FROM media_items WHERE deleted_at IS NOT NULL AND deleted_at \ + < ?1", + params![before_str], + )?; + Ok::<_, rusqlite::Error>(count as u64) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; - Ok(count) - } + Ok(count) + } - async fn count_trash(&self) -> Result { - let conn = self.conn.clone(); + async fn count_trash(&self) -> Result { + let conn = self.conn.clone(); - let count = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - let count: i64 = conn.query_row( - "SELECT COUNT(*) FROM media_items WHERE deleted_at IS NOT NULL", - [], - |row| row.get(0), - )?; - Ok::<_, rusqlite::Error>(count as u64) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; + let count = tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM media_items WHERE deleted_at IS NOT NULL", + [], + |row| row.get(0), + )?; + Ok::<_, rusqlite::Error>(count as u64) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; - Ok(count) - } + Ok(count) + } - // ===== Markdown Links (Obsidian-style) ===== + // ===== Markdown Links (Obsidian-style) ===== - async fn save_markdown_links( - &self, - media_id: MediaId, - links: &[crate::model::MarkdownLink], - ) -> Result<()> { - let conn = self.conn.clone(); - let media_id_str = media_id.0.to_string(); - let links: Vec<_> = links.to_vec(); + async fn save_markdown_links( + &self, + media_id: MediaId, + links: &[crate::model::MarkdownLink], + ) -> Result<()> { + let conn = self.conn.clone(); + let media_id_str = media_id.0.to_string(); + let links: Vec<_> = links.to_vec(); - tokio::task::spawn_blocking(move || { - let mut conn = conn.lock().unwrap(); + tokio::task::spawn_blocking(move || { + let mut conn = conn.lock().unwrap(); - // Wrap DELETE + INSERT in transaction to ensure atomicity - let tx = conn.transaction()?; + // Wrap DELETE + INSERT in transaction to ensure atomicity + let tx = conn.transaction()?; - // Delete existing links for this source - tx.execute( - "DELETE FROM markdown_links WHERE source_media_id = ?1", - [&media_id_str], - )?; + // Delete existing links for this source + tx.execute("DELETE FROM markdown_links WHERE source_media_id = ?1", [ + &media_id_str, + ])?; - // Insert new links - let mut stmt = tx.prepare( - "INSERT INTO markdown_links ( + // Insert new links + let mut stmt = tx.prepare( + "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)", - )?; + )?; - for link in &links { - stmt.execute(params![ - link.id.to_string(), - media_id_str, - link.target_path, - link.target_media_id.map(|id| id.0.to_string()), - link.link_type.to_string(), - link.link_text, - link.line_number, - link.context, - link.created_at.to_rfc3339(), - ])?; - } + for link in &links { + stmt.execute(params![ + link.id.to_string(), + media_id_str, + link.target_path, + link.target_media_id.map(|id| id.0.to_string()), + link.link_type.to_string(), + link.link_text, + link.line_number, + link.context, + link.created_at.to_rfc3339(), + ])?; + } - // Commit transaction - if this fails, all changes are rolled back - drop(stmt); - tx.commit()?; + // Commit transaction - if this fails, all changes are rolled back + drop(stmt); + tx.commit()?; - Ok::<_, rusqlite::Error>(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; + Ok::<_, rusqlite::Error>(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; - Ok(()) - } + Ok(()) + } - async fn get_outgoing_links( - &self, - media_id: MediaId, - ) -> Result> { - let conn = self.conn.clone(); - let media_id_str = media_id.0.to_string(); + async fn get_outgoing_links( + &self, + media_id: MediaId, + ) -> Result> { + let conn = self.conn.clone(); + let media_id_str = media_id.0.to_string(); - let links = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT id, source_media_id, target_path, target_media_id, + let links = tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + let mut stmt = conn.prepare( + "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", - )?; + )?; - let rows = stmt.query_map([&media_id_str], |row| row_to_markdown_link(row))?; + let rows = + stmt.query_map([&media_id_str], |row| row_to_markdown_link(row))?; - let mut links = Vec::new(); - for row in rows { - links.push(row?); - } - Ok::<_, rusqlite::Error>(links) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; + let mut links = Vec::new(); + for row in rows { + links.push(row?); + } + Ok::<_, rusqlite::Error>(links) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; - Ok(links) - } + Ok(links) + } - async fn get_backlinks(&self, media_id: MediaId) -> Result> { - let conn = self.conn.clone(); - let media_id_str = media_id.0.to_string(); + async fn get_backlinks( + &self, + media_id: MediaId, + ) -> Result> { + let conn = self.conn.clone(); + let media_id_str = media_id.0.to_string(); - let backlinks = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT l.id, l.source_media_id, m.title, m.path, + let backlinks = tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT l.id, l.source_media_id, m.title, m.path, l.link_text, l.line_number, l.context, l.link_type 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", - )?; + )?; - let rows = stmt.query_map([&media_id_str], |row| { - 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)?; + let rows = stmt.query_map([&media_id_str], |row| { + 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)?; - Ok(crate::model::BacklinkInfo { - link_id: parse_uuid(&link_id_str)?, - source_id: MediaId(parse_uuid(&source_id_str)?), - source_title, - source_path, - link_text, - line_number, - context, - link_type: link_type_str - .parse() - .unwrap_or(crate::model::LinkType::Wikilink), - }) - })?; - - let mut backlinks = Vec::new(); - for row in rows { - backlinks.push(row?); - } - Ok::<_, rusqlite::Error>(backlinks) + Ok(crate::model::BacklinkInfo { + link_id: parse_uuid(&link_id_str)?, + source_id: MediaId(parse_uuid(&source_id_str)?), + source_title, + source_path, + link_text, + line_number, + context, + link_type: link_type_str + .parse() + .unwrap_or(crate::model::LinkType::Wikilink), }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; + })?; - Ok(backlinks) - } + let mut backlinks = Vec::new(); + for row in rows { + backlinks.push(row?); + } + Ok::<_, rusqlite::Error>(backlinks) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; - async fn clear_links_for_media(&self, media_id: MediaId) -> Result<()> { - let conn = self.conn.clone(); - let media_id_str = media_id.0.to_string(); + Ok(backlinks) + } - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.execute( - "DELETE FROM markdown_links WHERE source_media_id = ?1", - [&media_id_str], - )?; - Ok::<_, rusqlite::Error>(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; + async fn clear_links_for_media(&self, media_id: MediaId) -> Result<()> { + let conn = self.conn.clone(); + let media_id_str = media_id.0.to_string(); - Ok(()) - } + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn + .execute("DELETE FROM markdown_links WHERE source_media_id = ?1", [ + &media_id_str, + ])?; + Ok::<_, rusqlite::Error>(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; - async fn get_graph_data( - &self, - center_id: Option, - depth: u32, - ) -> Result { - let conn = self.conn.clone(); - let center_id_str = center_id.map(|id| id.0.to_string()); - let depth = depth.min(5); // Limit depth to prevent huge queries + Ok(()) + } - let graph_data = tokio::task::spawn_blocking(move || { + async fn get_graph_data( + &self, + center_id: Option, + depth: u32, + ) -> Result { + let conn = self.conn.clone(); + let center_id_str = center_id.map(|id| id.0.to_string()); + let depth = depth.min(5); // Limit depth to prevent huge queries + + let graph_data = tokio::task::spawn_blocking(move || { let conn = conn.lock().unwrap(); let mut nodes = Vec::new(); let mut edges = Vec::new(); @@ -6720,19 +7885,19 @@ impl StorageBackend for SqliteBackend { .await .map_err(|e| PinakesError::Database(e.to_string()))??; - Ok(graph_data) - } + Ok(graph_data) + } - async fn resolve_links(&self) -> Result { - let conn = self.conn.clone(); + async fn resolve_links(&self) -> Result { + let conn = self.conn.clone(); - let count = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); + let count = tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); - // Find unresolved links and try to resolve them - // Strategy 1: Exact path match - let updated1 = conn.execute( - "UPDATE markdown_links + // Find unresolved links and try to resolve them + // Strategy 1: Exact path match + let updated1 = conn.execute( + "UPDATE markdown_links SET target_media_id = ( SELECT id FROM media_items WHERE path = markdown_links.target_path @@ -6745,18 +7910,19 @@ impl StorageBackend for SqliteBackend { WHERE path = markdown_links.target_path AND deleted_at IS NULL )", - [], - )?; + [], + )?; - // Strategy 2: Filename match (Obsidian-style) - // Match target_path to file_name (with or without .md extension) - let updated2 = conn.execute( - "UPDATE markdown_links + // Strategy 2: Filename match (Obsidian-style) + // Match target_path to file_name (with or without .md extension) + let updated2 = conn.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) + OR REPLACE(file_name, '.md', '') = \ + markdown_links.target_path) AND deleted_at IS NULL LIMIT 1 ) @@ -6765,161 +7931,184 @@ impl StorageBackend for SqliteBackend { 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) + OR REPLACE(file_name, '.md', '') = \ + markdown_links.target_path) AND deleted_at IS NULL )", - [], - )?; + [], + )?; - Ok::<_, rusqlite::Error>((updated1 + updated2) as u64) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; + Ok::<_, rusqlite::Error>((updated1 + updated2) as u64) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; - Ok(count) - } + Ok(count) + } - async fn mark_links_extracted(&self, media_id: MediaId) -> Result<()> { - let conn = self.conn.clone(); - let media_id_str = media_id.0.to_string(); - let now = chrono::Utc::now().to_rfc3339(); + async fn mark_links_extracted(&self, media_id: MediaId) -> Result<()> { + let conn = self.conn.clone(); + let media_id_str = media_id.0.to_string(); + let now = chrono::Utc::now().to_rfc3339(); - tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - conn.execute( - "UPDATE media_items SET links_extracted_at = ?1 WHERE id = ?2", - params![now, media_id_str], - )?; - Ok::<_, rusqlite::Error>(()) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; + tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + conn.execute( + "UPDATE media_items SET links_extracted_at = ?1 WHERE id = ?2", + params![now, media_id_str], + )?; + Ok::<_, rusqlite::Error>(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; - Ok(()) - } + Ok(()) + } - async fn count_unresolved_links(&self) -> Result { - let conn = self.conn.clone(); + async fn count_unresolved_links(&self) -> Result { + let conn = self.conn.clone(); - let count = tokio::task::spawn_blocking(move || { - let conn = conn.lock().unwrap(); - let count: i64 = conn.query_row( - "SELECT COUNT(*) FROM markdown_links WHERE target_media_id IS NULL", - [], - |row| row.get(0), - )?; - Ok::<_, rusqlite::Error>(count as u64) - }) - .await - .map_err(|e| PinakesError::Database(e.to_string()))??; + let count = tokio::task::spawn_blocking(move || { + let conn = conn.lock().unwrap(); + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM markdown_links WHERE target_media_id IS NULL", + [], + |row| row.get(0), + )?; + Ok::<_, rusqlite::Error>(count as u64) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))??; - Ok(count) - } + Ok(count) + } } // Helper function to parse a markdown link row -fn row_to_markdown_link(row: &Row) -> rusqlite::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_str: String = row.get(8)?; +fn row_to_markdown_link( + row: &Row, +) -> rusqlite::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_str: String = row.get(8)?; - Ok(crate::model::MarkdownLink { - id: parse_uuid(&id_str)?, - source_media_id: MediaId(parse_uuid(&source_id_str)?), - 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: parse_datetime(&created_at_str), - }) + Ok(crate::model::MarkdownLink { + id: parse_uuid(&id_str)?, + source_media_id: MediaId(parse_uuid(&source_id_str)?), + 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: parse_datetime(&created_at_str), + }) } // Helper function to parse a share row fn row_to_share(row: &Row) -> rusqlite::Result { - let id_str: String = row.get(0)?; - let target_type: String = row.get(1)?; - let target_id_str: String = row.get(2)?; - let owner_id_str: String = 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 id_str: String = row.get(0)?; + let target_type: String = row.get(1)?; + let target_id_str: String = row.get(2)?; + let owner_id_str: String = 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(parse_uuid(&target_id_str)?), - }, - "collection" => crate::sharing::ShareTarget::Collection { - collection_id: parse_uuid(&target_id_str)?, - }, - "tag" => crate::sharing::ShareTarget::Tag { - tag_id: parse_uuid(&target_id_str)?, - }, - "saved_search" => crate::sharing::ShareTarget::SavedSearch { - search_id: parse_uuid(&target_id_str)?, - }, - _ => crate::sharing::ShareTarget::Media { - media_id: MediaId(parse_uuid(&target_id_str)?), - }, - }; + let target = match target_type.as_str() { + "media" => { + crate::sharing::ShareTarget::Media { + media_id: MediaId(parse_uuid(&target_id_str)?), + } + }, + "collection" => { + crate::sharing::ShareTarget::Collection { + collection_id: parse_uuid(&target_id_str)?, + } + }, + "tag" => { + crate::sharing::ShareTarget::Tag { + tag_id: parse_uuid(&target_id_str)?, + } + }, + "saved_search" => { + crate::sharing::ShareTarget::SavedSearch { + search_id: parse_uuid(&target_id_str)?, + } + }, + _ => { + crate::sharing::ShareTarget::Media { + media_id: MediaId(parse_uuid(&target_id_str)?), + } + }, + }; - let recipient = match recipient_type.as_str() { - "public_link" => crate::sharing::ShareRecipient::PublicLink { - token: public_token.unwrap_or_default(), - password_hash, - }, - "user" => crate::sharing::ShareRecipient::User { - user_id: crate::users::UserId(parse_uuid(&recipient_user_id.unwrap_or_default())?), - }, - "group" => crate::sharing::ShareRecipient::Group { - group_id: Uuid::nil(), - }, - _ => crate::sharing::ShareRecipient::PublicLink { - token: public_token.unwrap_or_default(), - password_hash, - }, - }; + let recipient = match recipient_type.as_str() { + "public_link" => { + crate::sharing::ShareRecipient::PublicLink { + token: public_token.unwrap_or_default(), + password_hash, + } + }, + "user" => { + crate::sharing::ShareRecipient::User { + user_id: crate::users::UserId(parse_uuid( + &recipient_user_id.unwrap_or_default(), + )?), + } + }, + "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(parse_uuid(&id_str)?), - target, - owner_id: crate::users::UserId(parse_uuid(&owner_id_str)?), - 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::<_, Option>(15)? - .map(|s| parse_datetime(&s)), - access_count: row.get::<_, i64>(16)? as u64, - last_accessed: row - .get::<_, Option>(17)? - .map(|s| parse_datetime(&s)), - inherit_to_children: row.get(18)?, - parent_share_id: row - .get::<_, Option>(19)? - .and_then(|s| Uuid::parse_str(&s).ok().map(crate::sharing::ShareId)), - created_at: parse_datetime(&row.get::<_, String>(20)?), - updated_at: parse_datetime(&row.get::<_, String>(21)?), - }) + Ok(crate::sharing::Share { + id: crate::sharing::ShareId(parse_uuid(&id_str)?), + target, + owner_id: crate::users::UserId(parse_uuid(&owner_id_str)?), + 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::<_, Option>(15)? + .map(|s| parse_datetime(&s)), + access_count: row.get::<_, i64>(16)? as u64, + last_accessed: row + .get::<_, Option>(17)? + .map(|s| parse_datetime(&s)), + inherit_to_children: row.get(18)?, + parent_share_id: row + .get::<_, Option>(19)? + .and_then(|s| Uuid::parse_str(&s).ok().map(crate::sharing::ShareId)), + created_at: parse_datetime(&row.get::<_, String>(20)?), + updated_at: parse_datetime(&row.get::<_, String>(21)?), + }) } // Needed for `query_row(...).optional()` diff --git a/crates/pinakes-core/src/subtitles.rs b/crates/pinakes-core/src/subtitles.rs index 4f41d7b..5927899 100644 --- a/crates/pinakes-core/src/subtitles.rs +++ b/crates/pinakes-core/src/subtitles.rs @@ -11,52 +11,52 @@ use crate::model::MediaId; /// A subtitle track associated with a media item. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Subtitle { - pub id: Uuid, - pub media_id: MediaId, - pub language: Option, - pub format: SubtitleFormat, - pub file_path: Option, - pub is_embedded: bool, - pub track_index: Option, - pub offset_ms: i64, - pub created_at: DateTime, + pub id: Uuid, + pub media_id: MediaId, + pub language: Option, + pub format: SubtitleFormat, + pub file_path: Option, + pub is_embedded: bool, + pub track_index: Option, + pub offset_ms: i64, + pub created_at: DateTime, } /// Supported subtitle formats. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum SubtitleFormat { - Srt, - Vtt, - Ass, - Ssa, - Pgs, + Srt, + Vtt, + Ass, + Ssa, + Pgs, } impl std::fmt::Display for SubtitleFormat { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let s = match self { - Self::Srt => "srt", - Self::Vtt => "vtt", - Self::Ass => "ass", - Self::Ssa => "ssa", - Self::Pgs => "pgs", - }; - write!(f, "{s}") - } + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + Self::Srt => "srt", + Self::Vtt => "vtt", + Self::Ass => "ass", + Self::Ssa => "ssa", + Self::Pgs => "pgs", + }; + write!(f, "{s}") + } } impl std::str::FromStr for SubtitleFormat { - type Err = String; + type Err = String; - fn from_str(s: &str) -> std::result::Result { - match s { - "srt" => Ok(Self::Srt), - "vtt" => Ok(Self::Vtt), - "ass" => Ok(Self::Ass), - "ssa" => Ok(Self::Ssa), - "pgs" => Ok(Self::Pgs), - _ => Err(format!("unknown subtitle format: {s}")), - } + fn from_str(s: &str) -> std::result::Result { + match s { + "srt" => Ok(Self::Srt), + "vtt" => Ok(Self::Vtt), + "ass" => Ok(Self::Ass), + "ssa" => Ok(Self::Ssa), + "pgs" => Ok(Self::Pgs), + _ => Err(format!("unknown subtitle format: {s}")), } + } } diff --git a/crates/pinakes-core/src/sync/chunked.rs b/crates/pinakes-core/src/sync/chunked.rs index 13a5404..43e569f 100644 --- a/crates/pinakes-core/src/sync/chunked.rs +++ b/crates/pinakes-core/src/sync/chunked.rs @@ -3,295 +3,297 @@ use std::path::{Path, PathBuf}; use chrono::Utc; -use tokio::fs; -use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; +use tokio::{ + fs, + io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}, +}; use tracing::{debug, info}; use uuid::Uuid; -use crate::error::{PinakesError, Result}; - use super::{ChunkInfo, UploadSession}; +use crate::error::{PinakesError, Result}; /// Manager for chunked uploads. #[derive(Debug, Clone)] pub struct ChunkedUploadManager { - temp_dir: PathBuf, + temp_dir: PathBuf, } impl ChunkedUploadManager { - /// Create a new chunked upload manager. - pub fn new(temp_dir: PathBuf) -> Self { - Self { temp_dir } + /// Create a new chunked upload manager. + pub fn new(temp_dir: PathBuf) -> Self { + Self { temp_dir } + } + + /// Initialize the temp directory. + pub async fn init(&self) -> Result<()> { + fs::create_dir_all(&self.temp_dir).await?; + Ok(()) + } + + /// Get the temp file path for an upload session. + pub fn temp_path(&self, session_id: Uuid) -> PathBuf { + self.temp_dir.join(format!("{}.upload", session_id)) + } + + /// Create the temp file for a new upload session. + pub async fn create_temp_file(&self, session: &UploadSession) -> Result<()> { + let path = self.temp_path(session.id); + + // Create a sparse file of the expected size + let file = fs::File::create(&path).await?; + file.set_len(session.expected_size).await?; + + debug!( + session_id = %session.id, + size = session.expected_size, + "created temp file for upload" + ); + + Ok(()) + } + + /// Write a chunk to the temp file. + pub async fn write_chunk( + &self, + session: &UploadSession, + chunk_index: u64, + data: &[u8], + ) -> Result { + let path = self.temp_path(session.id); + + if !path.exists() { + return Err(PinakesError::UploadSessionNotFound(session.id.to_string())); } - /// Initialize the temp directory. - pub async fn init(&self) -> Result<()> { - fs::create_dir_all(&self.temp_dir).await?; - Ok(()) + // Calculate offset + let offset = chunk_index * session.chunk_size; + + // Validate chunk + if offset >= session.expected_size { + return Err(PinakesError::ChunkOutOfOrder { + expected: session.chunk_count - 1, + actual: chunk_index, + }); } - /// Get the temp file path for an upload session. - pub fn temp_path(&self, session_id: Uuid) -> PathBuf { - self.temp_dir.join(format!("{}.upload", session_id)) + // Calculate expected chunk size + let expected_size = if chunk_index == session.chunk_count - 1 { + // Last chunk may be smaller + session.expected_size - offset + } else { + session.chunk_size + }; + + if data.len() as u64 != expected_size { + return Err(PinakesError::InvalidData(format!( + "chunk {} has wrong size: expected {}, got {}", + chunk_index, + expected_size, + data.len() + ))); } - /// Create the temp file for a new upload session. - pub async fn create_temp_file(&self, session: &UploadSession) -> Result<()> { - let path = self.temp_path(session.id); + // Write chunk to file at offset + let mut file = fs::OpenOptions::new().write(true).open(&path).await?; - // Create a sparse file of the expected size - let file = fs::File::create(&path).await?; - file.set_len(session.expected_size).await?; + file.seek(std::io::SeekFrom::Start(offset)).await?; + file.write_all(data).await?; + file.flush().await?; - debug!( - session_id = %session.id, - size = session.expected_size, - "created temp file for upload" - ); + // Compute chunk hash + let hash = blake3::hash(data).to_hex().to_string(); - Ok(()) + debug!( + session_id = %session.id, + chunk_index, + offset, + size = data.len(), + "wrote chunk" + ); + + Ok(ChunkInfo { + upload_id: session.id, + chunk_index, + offset, + size: data.len() as u64, + hash, + received_at: Utc::now(), + }) + } + + /// Verify and finalize the upload. + /// + /// Checks that: + /// 1. All chunks are received + /// 2. File size matches expected + /// 3. Content hash matches expected + pub async fn finalize( + &self, + session: &UploadSession, + received_chunks: &[ChunkInfo], + ) -> Result { + let path = self.temp_path(session.id); + + // Check all chunks received + if received_chunks.len() as u64 != session.chunk_count { + return Err(PinakesError::InvalidData(format!( + "missing chunks: expected {}, got {}", + session.chunk_count, + received_chunks.len() + ))); } - /// Write a chunk to the temp file. - pub async fn write_chunk( - &self, - session: &UploadSession, - chunk_index: u64, - data: &[u8], - ) -> Result { - let path = self.temp_path(session.id); - - if !path.exists() { - return Err(PinakesError::UploadSessionNotFound(session.id.to_string())); - } - - // Calculate offset - let offset = chunk_index * session.chunk_size; - - // Validate chunk - if offset >= session.expected_size { - return Err(PinakesError::ChunkOutOfOrder { - expected: session.chunk_count - 1, - actual: chunk_index, - }); - } - - // Calculate expected chunk size - let expected_size = if chunk_index == session.chunk_count - 1 { - // Last chunk may be smaller - session.expected_size - offset - } else { - session.chunk_size - }; - - if data.len() as u64 != expected_size { - return Err(PinakesError::InvalidData(format!( - "chunk {} has wrong size: expected {}, got {}", - chunk_index, - expected_size, - data.len() - ))); - } - - // Write chunk to file at offset - let mut file = fs::OpenOptions::new().write(true).open(&path).await?; - - file.seek(std::io::SeekFrom::Start(offset)).await?; - file.write_all(data).await?; - file.flush().await?; - - // Compute chunk hash - let hash = blake3::hash(data).to_hex().to_string(); - - debug!( - session_id = %session.id, - chunk_index, - offset, - size = data.len(), - "wrote chunk" - ); - - Ok(ChunkInfo { - upload_id: session.id, - chunk_index, - offset, - size: data.len() as u64, - hash, - received_at: Utc::now(), - }) + // Verify chunk indices + let mut indices: Vec = + received_chunks.iter().map(|c| c.chunk_index).collect(); + indices.sort(); + for (i, idx) in indices.iter().enumerate() { + if *idx != i as u64 { + return Err(PinakesError::InvalidData(format!( + "chunk {} missing or out of order", + i + ))); + } } - /// Verify and finalize the upload. - /// - /// Checks that: - /// 1. All chunks are received - /// 2. File size matches expected - /// 3. Content hash matches expected - pub async fn finalize( - &self, - session: &UploadSession, - received_chunks: &[ChunkInfo], - ) -> Result { - let path = self.temp_path(session.id); + // Verify file size + let metadata = fs::metadata(&path).await?; + if metadata.len() != session.expected_size { + return Err(PinakesError::InvalidData(format!( + "file size mismatch: expected {}, got {}", + session.expected_size, + metadata.len() + ))); + } - // Check all chunks received - if received_chunks.len() as u64 != session.chunk_count { - return Err(PinakesError::InvalidData(format!( - "missing chunks: expected {}, got {}", - session.chunk_count, - received_chunks.len() - ))); - } + // Verify content hash + let computed_hash = compute_file_hash(&path).await?; + if computed_hash != session.expected_hash.0 { + return Err(PinakesError::StorageIntegrity(format!( + "hash mismatch: expected {}, computed {}", + session.expected_hash, computed_hash + ))); + } - // Verify chunk indices - let mut indices: Vec = received_chunks.iter().map(|c| c.chunk_index).collect(); - indices.sort(); - for (i, idx) in indices.iter().enumerate() { - if *idx != i as u64 { - return Err(PinakesError::InvalidData(format!( - "chunk {} missing or out of order", - i - ))); + info!( + session_id = %session.id, + hash = %session.expected_hash, + size = session.expected_size, + "finalized chunked upload" + ); + + Ok(path) + } + + /// Cancel an upload and clean up temp file. + pub async fn cancel(&self, session_id: Uuid) -> Result<()> { + let path = self.temp_path(session_id); + if path.exists() { + fs::remove_file(&path).await?; + debug!(session_id = %session_id, "cancelled upload, removed temp file"); + } + Ok(()) + } + + /// Clean up expired temp files. + pub async fn cleanup_expired(&self, max_age_hours: u64) -> Result { + let mut count = 0u64; + let max_age = std::time::Duration::from_secs(max_age_hours * 3600); + + let mut entries = fs::read_dir(&self.temp_dir).await?; + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.extension().map(|e| e == "upload").unwrap_or(false) { + if let Ok(metadata) = fs::metadata(&path).await { + if let Ok(modified) = metadata.modified() { + let age = std::time::SystemTime::now() + .duration_since(modified) + .unwrap_or_default(); + if age > max_age { + let _ = fs::remove_file(&path).await; + count += 1; } + } } - - // Verify file size - let metadata = fs::metadata(&path).await?; - if metadata.len() != session.expected_size { - return Err(PinakesError::InvalidData(format!( - "file size mismatch: expected {}, got {}", - session.expected_size, - metadata.len() - ))); - } - - // Verify content hash - let computed_hash = compute_file_hash(&path).await?; - if computed_hash != session.expected_hash.0 { - return Err(PinakesError::StorageIntegrity(format!( - "hash mismatch: expected {}, computed {}", - session.expected_hash, computed_hash - ))); - } - - info!( - session_id = %session.id, - hash = %session.expected_hash, - size = session.expected_size, - "finalized chunked upload" - ); - - Ok(path) + } } - /// Cancel an upload and clean up temp file. - pub async fn cancel(&self, session_id: Uuid) -> Result<()> { - let path = self.temp_path(session_id); - if path.exists() { - fs::remove_file(&path).await?; - debug!(session_id = %session_id, "cancelled upload, removed temp file"); - } - Ok(()) - } - - /// Clean up expired temp files. - pub async fn cleanup_expired(&self, max_age_hours: u64) -> Result { - let mut count = 0u64; - let max_age = std::time::Duration::from_secs(max_age_hours * 3600); - - let mut entries = fs::read_dir(&self.temp_dir).await?; - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - if path.extension().map(|e| e == "upload").unwrap_or(false) { - if let Ok(metadata) = fs::metadata(&path).await { - if let Ok(modified) = metadata.modified() { - let age = std::time::SystemTime::now() - .duration_since(modified) - .unwrap_or_default(); - if age > max_age { - let _ = fs::remove_file(&path).await; - count += 1; - } - } - } - } - } - - if count > 0 { - info!(count, "cleaned up expired upload temp files"); - } - Ok(count) + if count > 0 { + info!(count, "cleaned up expired upload temp files"); } + Ok(count) + } } /// Compute the BLAKE3 hash of a file. async fn compute_file_hash(path: &Path) -> Result { - let mut file = fs::File::open(path).await?; - let mut hasher = blake3::Hasher::new(); - let mut buf = vec![0u8; 64 * 1024]; + let mut file = fs::File::open(path).await?; + let mut hasher = blake3::Hasher::new(); + let mut buf = vec![0u8; 64 * 1024]; - loop { - let n = file.read(&mut buf).await?; - if n == 0 { - break; - } - hasher.update(&buf[..n]); + loop { + let n = file.read(&mut buf).await?; + if n == 0 { + break; } + hasher.update(&buf[..n]); + } - Ok(hasher.finalize().to_hex().to_string()) + Ok(hasher.finalize().to_hex().to_string()) } #[cfg(test)] mod tests { - use super::*; - use crate::model::ContentHash; - use crate::sync::UploadStatus; - use tempfile::tempdir; + use tempfile::tempdir; - #[tokio::test] - async fn test_chunked_upload() { - let dir = tempdir().unwrap(); - let manager = ChunkedUploadManager::new(dir.path().to_path_buf()); - manager.init().await.unwrap(); + use super::*; + use crate::{model::ContentHash, sync::UploadStatus}; - // Create test data - let data = b"Hello, World! This is test data for chunked upload."; - let hash = blake3::hash(data).to_hex().to_string(); - let chunk_size = 20u64; + #[tokio::test] + async fn test_chunked_upload() { + let dir = tempdir().unwrap(); + let manager = ChunkedUploadManager::new(dir.path().to_path_buf()); + manager.init().await.unwrap(); - let session = UploadSession { - id: Uuid::now_v7(), - device_id: super::super::DeviceId::new(), - target_path: "/test/file.txt".to_string(), - expected_hash: ContentHash::new(hash.clone()), - expected_size: data.len() as u64, - chunk_size, - chunk_count: (data.len() as u64 + chunk_size - 1) / chunk_size, - status: UploadStatus::InProgress, - created_at: Utc::now(), - expires_at: Utc::now() + chrono::Duration::hours(24), - last_activity: Utc::now(), - }; + // Create test data + let data = b"Hello, World! This is test data for chunked upload."; + let hash = blake3::hash(data).to_hex().to_string(); + let chunk_size = 20u64; - manager.create_temp_file(&session).await.unwrap(); + let session = UploadSession { + id: Uuid::now_v7(), + device_id: super::super::DeviceId::new(), + target_path: "/test/file.txt".to_string(), + expected_hash: ContentHash::new(hash.clone()), + expected_size: data.len() as u64, + chunk_size, + chunk_count: (data.len() as u64 + chunk_size - 1) / chunk_size, + status: UploadStatus::InProgress, + created_at: Utc::now(), + expires_at: Utc::now() + chrono::Duration::hours(24), + last_activity: Utc::now(), + }; - // Write chunks - let mut chunks = Vec::new(); - for i in 0..session.chunk_count { - let start = (i * chunk_size) as usize; - let end = ((i + 1) * chunk_size).min(data.len() as u64) as usize; - let chunk_data = &data[start..end]; + manager.create_temp_file(&session).await.unwrap(); - let chunk = manager.write_chunk(&session, i, chunk_data).await.unwrap(); - chunks.push(chunk); - } + // Write chunks + let mut chunks = Vec::new(); + for i in 0..session.chunk_count { + let start = (i * chunk_size) as usize; + let end = ((i + 1) * chunk_size).min(data.len() as u64) as usize; + let chunk_data = &data[start..end]; - // Finalize - let final_path = manager.finalize(&session, &chunks).await.unwrap(); - assert!(final_path.exists()); - - // Verify content - let content = fs::read(&final_path).await.unwrap(); - assert_eq!(&content[..], data); + let chunk = manager.write_chunk(&session, i, chunk_data).await.unwrap(); + chunks.push(chunk); } + + // Finalize + let final_path = manager.finalize(&session, &chunks).await.unwrap(); + assert!(final_path.exists()); + + // Verify content + let content = fs::read(&final_path).await.unwrap(); + assert_eq!(&content[..], data); + } } diff --git a/crates/pinakes-core/src/sync/conflict.rs b/crates/pinakes-core/src/sync/conflict.rs index 70af9b4..7f25570 100644 --- a/crates/pinakes-core/src/sync/conflict.rs +++ b/crates/pinakes-core/src/sync/conflict.rs @@ -1,144 +1,144 @@ //! Conflict detection and resolution for sync. -use crate::config::ConflictResolution; - use super::DeviceSyncState; +use crate::config::ConflictResolution; /// Detect if there's a conflict between local and server state. pub fn detect_conflict(state: &DeviceSyncState) -> Option { - // If either side has no hash, no conflict possible - let local_hash = state.local_hash.as_ref()?; - let server_hash = state.server_hash.as_ref()?; + // If either side has no hash, no conflict possible + let local_hash = state.local_hash.as_ref()?; + let server_hash = state.server_hash.as_ref()?; - // Same hash = no conflict - if local_hash == server_hash { - return None; - } + // Same hash = no conflict + if local_hash == server_hash { + return None; + } - // Both have different hashes = conflict - Some(ConflictInfo { - path: state.path.clone(), - local_hash: local_hash.clone(), - server_hash: server_hash.clone(), - local_mtime: state.local_mtime, - server_mtime: state.server_mtime, - }) + // Both have different hashes = conflict + Some(ConflictInfo { + path: state.path.clone(), + local_hash: local_hash.clone(), + server_hash: server_hash.clone(), + local_mtime: state.local_mtime, + server_mtime: state.server_mtime, + }) } /// Information about a detected conflict. #[derive(Debug, Clone)] pub struct ConflictInfo { - pub path: String, - pub local_hash: String, - pub server_hash: String, - pub local_mtime: Option, - pub server_mtime: Option, + pub path: String, + pub local_hash: String, + pub server_hash: String, + pub local_mtime: Option, + pub server_mtime: Option, } /// Result of resolving a conflict. #[derive(Debug, Clone)] pub enum ConflictOutcome { - /// Use the server version - UseServer, - /// Use the local version (upload it) - UseLocal, - /// Keep both versions (rename one) - KeepBoth { new_local_path: String }, - /// Requires manual intervention - Manual, + /// Use the server version + UseServer, + /// Use the local version (upload it) + UseLocal, + /// Keep both versions (rename one) + KeepBoth { new_local_path: String }, + /// Requires manual intervention + Manual, } /// Resolve a conflict based on the configured strategy. pub fn resolve_conflict( - conflict: &ConflictInfo, - resolution: ConflictResolution, + conflict: &ConflictInfo, + resolution: ConflictResolution, ) -> ConflictOutcome { - match resolution { - ConflictResolution::ServerWins => ConflictOutcome::UseServer, - ConflictResolution::ClientWins => ConflictOutcome::UseLocal, - ConflictResolution::KeepBoth => { - let new_path = generate_conflict_path(&conflict.path, &conflict.local_hash); - ConflictOutcome::KeepBoth { - new_local_path: new_path, - } - } - ConflictResolution::Manual => ConflictOutcome::Manual, - } + match resolution { + ConflictResolution::ServerWins => ConflictOutcome::UseServer, + ConflictResolution::ClientWins => ConflictOutcome::UseLocal, + ConflictResolution::KeepBoth => { + let new_path = + generate_conflict_path(&conflict.path, &conflict.local_hash); + ConflictOutcome::KeepBoth { + new_local_path: new_path, + } + }, + ConflictResolution::Manual => ConflictOutcome::Manual, + } } /// Generate a new path for the conflicting local file. /// Format: filename.conflict-.ext fn generate_conflict_path(original_path: &str, local_hash: &str) -> String { - let short_hash = &local_hash[..8.min(local_hash.len())]; + let short_hash = &local_hash[..8.min(local_hash.len())]; - if let Some((base, ext)) = original_path.rsplit_once('.') { - format!("{}.conflict-{}.{}", base, short_hash, ext) - } else { - format!("{}.conflict-{}", original_path, short_hash) - } + if let Some((base, ext)) = original_path.rsplit_once('.') { + format!("{}.conflict-{}.{}", base, short_hash, ext) + } else { + format!("{}.conflict-{}", original_path, short_hash) + } } /// Automatic conflict resolution based on modification times. /// Useful when ConflictResolution is set to a time-based strategy. pub fn resolve_by_mtime(conflict: &ConflictInfo) -> ConflictOutcome { - match (conflict.local_mtime, conflict.server_mtime) { - (Some(local), Some(server)) => { - if local > server { - ConflictOutcome::UseLocal - } else { - ConflictOutcome::UseServer - } - } - (Some(_), None) => ConflictOutcome::UseLocal, - (None, Some(_)) => ConflictOutcome::UseServer, - (None, None) => ConflictOutcome::UseServer, // Default to server - } + match (conflict.local_mtime, conflict.server_mtime) { + (Some(local), Some(server)) => { + if local > server { + ConflictOutcome::UseLocal + } else { + ConflictOutcome::UseServer + } + }, + (Some(_), None) => ConflictOutcome::UseLocal, + (None, Some(_)) => ConflictOutcome::UseServer, + (None, None) => ConflictOutcome::UseServer, // Default to server + } } #[cfg(test)] mod tests { - use super::*; - use crate::sync::FileSyncStatus; + use super::*; + use crate::sync::FileSyncStatus; - #[test] - fn test_generate_conflict_path() { - assert_eq!( - generate_conflict_path("/path/to/file.txt", "abc12345"), - "/path/to/file.conflict-abc12345.txt" - ); + #[test] + fn test_generate_conflict_path() { + assert_eq!( + generate_conflict_path("/path/to/file.txt", "abc12345"), + "/path/to/file.conflict-abc12345.txt" + ); - assert_eq!( - generate_conflict_path("/path/to/file", "abc12345"), - "/path/to/file.conflict-abc12345" - ); - } + assert_eq!( + generate_conflict_path("/path/to/file", "abc12345"), + "/path/to/file.conflict-abc12345" + ); + } - #[test] - fn test_detect_conflict() { - let state_no_conflict = DeviceSyncState { - device_id: super::super::DeviceId::new(), - path: "/test".to_string(), - local_hash: Some("abc".to_string()), - server_hash: Some("abc".to_string()), - local_mtime: None, - server_mtime: None, - sync_status: FileSyncStatus::Synced, - last_synced_at: None, - conflict_info_json: None, - }; - assert!(detect_conflict(&state_no_conflict).is_none()); + #[test] + fn test_detect_conflict() { + let state_no_conflict = DeviceSyncState { + device_id: super::super::DeviceId::new(), + path: "/test".to_string(), + local_hash: Some("abc".to_string()), + server_hash: Some("abc".to_string()), + local_mtime: None, + server_mtime: None, + sync_status: FileSyncStatus::Synced, + last_synced_at: None, + conflict_info_json: None, + }; + assert!(detect_conflict(&state_no_conflict).is_none()); - let state_conflict = DeviceSyncState { - device_id: super::super::DeviceId::new(), - path: "/test".to_string(), - local_hash: Some("abc".to_string()), - server_hash: Some("def".to_string()), - local_mtime: None, - server_mtime: None, - sync_status: FileSyncStatus::Conflict, - last_synced_at: None, - conflict_info_json: None, - }; - assert!(detect_conflict(&state_conflict).is_some()); - } + let state_conflict = DeviceSyncState { + device_id: super::super::DeviceId::new(), + path: "/test".to_string(), + local_hash: Some("abc".to_string()), + server_hash: Some("def".to_string()), + local_mtime: None, + server_mtime: None, + sync_status: FileSyncStatus::Conflict, + last_synced_at: None, + conflict_info_json: None, + }; + assert!(detect_conflict(&state_conflict).is_some()); + } } diff --git a/crates/pinakes-core/src/sync/models.rs b/crates/pinakes-core/src/sync/models.rs index 7aff331..3240d71 100644 --- a/crates/pinakes-core/src/sync/models.rs +++ b/crates/pinakes-core/src/sync/models.rs @@ -6,375 +6,377 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::config::ConflictResolution; -use crate::model::{ContentHash, MediaId}; -use crate::users::UserId; +use crate::{ + config::ConflictResolution, + model::{ContentHash, MediaId}, + users::UserId, +}; /// Unique identifier for a sync device. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct DeviceId(pub Uuid); impl DeviceId { - pub fn new() -> Self { - Self(Uuid::now_v7()) - } + pub fn new() -> Self { + Self(Uuid::now_v7()) + } } impl Default for DeviceId { - fn default() -> Self { - Self::new() - } + fn default() -> Self { + Self::new() + } } impl fmt::Display for DeviceId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } } /// Type of sync device. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum DeviceType { - Desktop, - Mobile, - Tablet, - Server, - Other, + Desktop, + Mobile, + Tablet, + Server, + Other, } impl Default for DeviceType { - fn default() -> Self { - Self::Other - } + fn default() -> Self { + Self::Other + } } impl fmt::Display for DeviceType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Desktop => write!(f, "desktop"), - Self::Mobile => write!(f, "mobile"), - Self::Tablet => write!(f, "tablet"), - Self::Server => write!(f, "server"), - Self::Other => write!(f, "other"), - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Desktop => write!(f, "desktop"), + Self::Mobile => write!(f, "mobile"), + Self::Tablet => write!(f, "tablet"), + Self::Server => write!(f, "server"), + Self::Other => write!(f, "other"), } + } } impl std::str::FromStr for DeviceType { - type Err = String; + type Err = String; - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "desktop" => Ok(Self::Desktop), - "mobile" => Ok(Self::Mobile), - "tablet" => Ok(Self::Tablet), - "server" => Ok(Self::Server), - "other" => Ok(Self::Other), - _ => Err(format!("unknown device type: {}", s)), - } + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "desktop" => Ok(Self::Desktop), + "mobile" => Ok(Self::Mobile), + "tablet" => Ok(Self::Tablet), + "server" => Ok(Self::Server), + "other" => Ok(Self::Other), + _ => Err(format!("unknown device type: {}", s)), } + } } /// A registered sync device. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SyncDevice { - pub id: DeviceId, - pub user_id: UserId, - pub name: String, - pub device_type: DeviceType, - pub client_version: String, - pub os_info: Option, - pub last_sync_at: Option>, - pub last_seen_at: DateTime, - pub sync_cursor: Option, - pub enabled: bool, - pub created_at: DateTime, - pub updated_at: DateTime, + pub id: DeviceId, + pub user_id: UserId, + pub name: String, + pub device_type: DeviceType, + pub client_version: String, + pub os_info: Option, + pub last_sync_at: Option>, + pub last_seen_at: DateTime, + pub sync_cursor: Option, + pub enabled: bool, + pub created_at: DateTime, + pub updated_at: DateTime, } impl SyncDevice { - pub fn new( - user_id: UserId, - name: String, - device_type: DeviceType, - client_version: String, - ) -> Self { - let now = Utc::now(); - Self { - id: DeviceId::new(), - user_id, - name, - device_type, - client_version, - os_info: None, - last_sync_at: None, - last_seen_at: now, - sync_cursor: None, - enabled: true, - created_at: now, - updated_at: now, - } + pub fn new( + user_id: UserId, + name: String, + device_type: DeviceType, + client_version: String, + ) -> Self { + let now = Utc::now(); + Self { + id: DeviceId::new(), + user_id, + name, + device_type, + client_version, + os_info: None, + last_sync_at: None, + last_seen_at: now, + sync_cursor: None, + enabled: true, + created_at: now, + updated_at: now, } + } } /// Type of change recorded in the sync log. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum SyncChangeType { - Created, - Modified, - Deleted, - Moved, - MetadataUpdated, + Created, + Modified, + Deleted, + Moved, + MetadataUpdated, } impl fmt::Display for SyncChangeType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Created => write!(f, "created"), - Self::Modified => write!(f, "modified"), - Self::Deleted => write!(f, "deleted"), - Self::Moved => write!(f, "moved"), - Self::MetadataUpdated => write!(f, "metadata_updated"), - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Created => write!(f, "created"), + Self::Modified => write!(f, "modified"), + Self::Deleted => write!(f, "deleted"), + Self::Moved => write!(f, "moved"), + Self::MetadataUpdated => write!(f, "metadata_updated"), } + } } impl std::str::FromStr for SyncChangeType { - type Err = String; + type Err = String; - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "created" => Ok(Self::Created), - "modified" => Ok(Self::Modified), - "deleted" => Ok(Self::Deleted), - "moved" => Ok(Self::Moved), - "metadata_updated" => Ok(Self::MetadataUpdated), - _ => Err(format!("unknown sync change type: {}", s)), - } + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "created" => Ok(Self::Created), + "modified" => Ok(Self::Modified), + "deleted" => Ok(Self::Deleted), + "moved" => Ok(Self::Moved), + "metadata_updated" => Ok(Self::MetadataUpdated), + _ => Err(format!("unknown sync change type: {}", s)), } + } } /// An entry in the sync log tracking a change. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SyncLogEntry { - pub id: Uuid, - pub sequence: i64, - pub change_type: SyncChangeType, - pub media_id: Option, - pub path: String, - pub content_hash: Option, - pub file_size: Option, - pub metadata_json: Option, - pub changed_by_device: Option, - pub timestamp: DateTime, + pub id: Uuid, + pub sequence: i64, + pub change_type: SyncChangeType, + pub media_id: Option, + pub path: String, + pub content_hash: Option, + pub file_size: Option, + pub metadata_json: Option, + pub changed_by_device: Option, + pub timestamp: DateTime, } impl SyncLogEntry { - pub fn new( - change_type: SyncChangeType, - path: String, - media_id: Option, - content_hash: Option, - ) -> Self { - Self { - id: Uuid::now_v7(), - sequence: 0, // Will be assigned by database - change_type, - media_id, - path, - content_hash, - file_size: None, - metadata_json: None, - changed_by_device: None, - timestamp: Utc::now(), - } + pub fn new( + change_type: SyncChangeType, + path: String, + media_id: Option, + content_hash: Option, + ) -> Self { + Self { + id: Uuid::now_v7(), + sequence: 0, // Will be assigned by database + change_type, + media_id, + path, + content_hash, + file_size: None, + metadata_json: None, + changed_by_device: None, + timestamp: Utc::now(), } + } } /// Sync status for a file on a device. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum FileSyncStatus { - Synced, - PendingUpload, - PendingDownload, - Conflict, - Deleted, + Synced, + PendingUpload, + PendingDownload, + Conflict, + Deleted, } impl fmt::Display for FileSyncStatus { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Synced => write!(f, "synced"), - Self::PendingUpload => write!(f, "pending_upload"), - Self::PendingDownload => write!(f, "pending_download"), - Self::Conflict => write!(f, "conflict"), - Self::Deleted => write!(f, "deleted"), - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Synced => write!(f, "synced"), + Self::PendingUpload => write!(f, "pending_upload"), + Self::PendingDownload => write!(f, "pending_download"), + Self::Conflict => write!(f, "conflict"), + Self::Deleted => write!(f, "deleted"), } + } } impl std::str::FromStr for FileSyncStatus { - type Err = String; + type Err = String; - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "synced" => Ok(Self::Synced), - "pending_upload" => Ok(Self::PendingUpload), - "pending_download" => Ok(Self::PendingDownload), - "conflict" => Ok(Self::Conflict), - "deleted" => Ok(Self::Deleted), - _ => Err(format!("unknown file sync status: {}", s)), - } + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "synced" => Ok(Self::Synced), + "pending_upload" => Ok(Self::PendingUpload), + "pending_download" => Ok(Self::PendingDownload), + "conflict" => Ok(Self::Conflict), + "deleted" => Ok(Self::Deleted), + _ => Err(format!("unknown file sync status: {}", s)), } + } } /// Sync state for a specific file on a specific device. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DeviceSyncState { - pub device_id: DeviceId, - pub path: String, - pub local_hash: Option, - pub server_hash: Option, - pub local_mtime: Option, - pub server_mtime: Option, - pub sync_status: FileSyncStatus, - pub last_synced_at: Option>, - pub conflict_info_json: Option, + pub device_id: DeviceId, + pub path: String, + pub local_hash: Option, + pub server_hash: Option, + pub local_mtime: Option, + pub server_mtime: Option, + pub sync_status: FileSyncStatus, + pub last_synced_at: Option>, + pub conflict_info_json: Option, } /// A sync conflict that needs resolution. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SyncConflict { - pub id: Uuid, - pub device_id: DeviceId, - pub path: String, - pub local_hash: String, - pub local_mtime: i64, - pub server_hash: String, - pub server_mtime: i64, - pub detected_at: DateTime, - pub resolved_at: Option>, - pub resolution: Option, + pub id: Uuid, + pub device_id: DeviceId, + pub path: String, + pub local_hash: String, + pub local_mtime: i64, + pub server_hash: String, + pub server_mtime: i64, + pub detected_at: DateTime, + pub resolved_at: Option>, + pub resolution: Option, } impl SyncConflict { - pub fn new( - device_id: DeviceId, - path: String, - local_hash: String, - local_mtime: i64, - server_hash: String, - server_mtime: i64, - ) -> Self { - Self { - id: Uuid::now_v7(), - device_id, - path, - local_hash, - local_mtime, - server_hash, - server_mtime, - detected_at: Utc::now(), - resolved_at: None, - resolution: None, - } + pub fn new( + device_id: DeviceId, + path: String, + local_hash: String, + local_mtime: i64, + server_hash: String, + server_mtime: i64, + ) -> Self { + Self { + id: Uuid::now_v7(), + device_id, + path, + local_hash, + local_mtime, + server_hash, + server_mtime, + detected_at: Utc::now(), + resolved_at: None, + resolution: None, } + } } /// Status of an upload session. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum UploadStatus { - Pending, - InProgress, - Completed, - Failed, - Expired, - Cancelled, + Pending, + InProgress, + Completed, + Failed, + Expired, + Cancelled, } impl fmt::Display for UploadStatus { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Pending => write!(f, "pending"), - Self::InProgress => write!(f, "in_progress"), - Self::Completed => write!(f, "completed"), - Self::Failed => write!(f, "failed"), - Self::Expired => write!(f, "expired"), - Self::Cancelled => write!(f, "cancelled"), - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Pending => write!(f, "pending"), + Self::InProgress => write!(f, "in_progress"), + Self::Completed => write!(f, "completed"), + Self::Failed => write!(f, "failed"), + Self::Expired => write!(f, "expired"), + Self::Cancelled => write!(f, "cancelled"), } + } } impl std::str::FromStr for UploadStatus { - type Err = String; + type Err = String; - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "pending" => Ok(Self::Pending), - "in_progress" => Ok(Self::InProgress), - "completed" => Ok(Self::Completed), - "failed" => Ok(Self::Failed), - "expired" => Ok(Self::Expired), - "cancelled" => Ok(Self::Cancelled), - _ => Err(format!("unknown upload status: {}", s)), - } + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "pending" => Ok(Self::Pending), + "in_progress" => Ok(Self::InProgress), + "completed" => Ok(Self::Completed), + "failed" => Ok(Self::Failed), + "expired" => Ok(Self::Expired), + "cancelled" => Ok(Self::Cancelled), + _ => Err(format!("unknown upload status: {}", s)), } + } } /// A chunked upload session. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UploadSession { - pub id: Uuid, - pub device_id: DeviceId, - pub target_path: String, - pub expected_hash: ContentHash, - pub expected_size: u64, - pub chunk_size: u64, - pub chunk_count: u64, - pub status: UploadStatus, - pub created_at: DateTime, - pub expires_at: DateTime, - pub last_activity: DateTime, + pub id: Uuid, + pub device_id: DeviceId, + pub target_path: String, + pub expected_hash: ContentHash, + pub expected_size: u64, + pub chunk_size: u64, + pub chunk_count: u64, + pub status: UploadStatus, + pub created_at: DateTime, + pub expires_at: DateTime, + pub last_activity: DateTime, } impl UploadSession { - pub fn new( - device_id: DeviceId, - target_path: String, - expected_hash: ContentHash, - expected_size: u64, - chunk_size: u64, - timeout_hours: u64, - ) -> Self { - let now = Utc::now(); - let chunk_count = (expected_size + chunk_size - 1) / chunk_size; - Self { - id: Uuid::now_v7(), - device_id, - target_path, - expected_hash, - expected_size, - chunk_size, - chunk_count, - status: UploadStatus::Pending, - created_at: now, - expires_at: now + chrono::Duration::hours(timeout_hours as i64), - last_activity: now, - } + pub fn new( + device_id: DeviceId, + target_path: String, + expected_hash: ContentHash, + expected_size: u64, + chunk_size: u64, + timeout_hours: u64, + ) -> Self { + let now = Utc::now(); + let chunk_count = (expected_size + chunk_size - 1) / chunk_size; + Self { + id: Uuid::now_v7(), + device_id, + target_path, + expected_hash, + expected_size, + chunk_size, + chunk_count, + status: UploadStatus::Pending, + created_at: now, + expires_at: now + chrono::Duration::hours(timeout_hours as i64), + last_activity: now, } + } } /// Information about an uploaded chunk. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChunkInfo { - pub upload_id: Uuid, - pub chunk_index: u64, - pub offset: u64, - pub size: u64, - pub hash: String, - pub received_at: DateTime, + pub upload_id: Uuid, + pub chunk_index: u64, + pub offset: u64, + pub size: u64, + pub hash: String, + pub received_at: DateTime, } diff --git a/crates/pinakes-core/src/sync/protocol.rs b/crates/pinakes-core/src/sync/protocol.rs index 204dae3..be8f095 100644 --- a/crates/pinakes-core/src/sync/protocol.rs +++ b/crates/pinakes-core/src/sync/protocol.rs @@ -6,210 +6,219 @@ use chrono::Utc; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::error::Result; -use crate::model::{ContentHash, MediaId}; -use crate::storage::DynStorageBackend; - -use super::{DeviceId, DeviceSyncState, FileSyncStatus, SyncChangeType, SyncLogEntry}; +use super::{ + DeviceId, + DeviceSyncState, + FileSyncStatus, + SyncChangeType, + SyncLogEntry, +}; +use crate::{ + error::Result, + model::{ContentHash, MediaId}, + storage::DynStorageBackend, +}; /// Request from client to get changes since a cursor. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChangesRequest { - pub cursor: i64, - pub limit: Option, + pub cursor: i64, + pub limit: Option, } /// Response containing changes since the cursor. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChangesResponse { - pub changes: Vec, - pub cursor: i64, - pub has_more: bool, + pub changes: Vec, + pub cursor: i64, + pub has_more: bool, } /// A change reported by the client. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ClientChange { - pub path: String, - pub change_type: SyncChangeType, - pub content_hash: Option, - pub file_size: Option, - pub local_mtime: Option, - pub metadata: Option, + pub path: String, + pub change_type: SyncChangeType, + pub content_hash: Option, + pub file_size: Option, + pub local_mtime: Option, + pub metadata: Option, } /// Request from client to report local changes. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ReportChangesRequest { - pub device_id: String, - pub changes: Vec, + pub device_id: String, + pub changes: Vec, } /// Result of processing a client change. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "status", rename_all = "snake_case")] pub enum ChangeResult { - /// Change accepted, no action needed - Accepted { path: String }, - /// Conflict detected, needs resolution - Conflict { - path: String, - server_hash: String, - server_mtime: i64, - }, - /// Upload required for new/modified file - UploadRequired { - path: String, - upload_url: String, - session_id: String, - }, - /// Error processing change - Error { path: String, message: String }, + /// Change accepted, no action needed + Accepted { path: String }, + /// Conflict detected, needs resolution + Conflict { + path: String, + server_hash: String, + server_mtime: i64, + }, + /// Upload required for new/modified file + UploadRequired { + path: String, + upload_url: String, + session_id: String, + }, + /// Error processing change + Error { path: String, message: String }, } /// Response to a report changes request. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ReportChangesResponse { - pub results: Vec, - pub server_cursor: i64, + pub results: Vec, + pub server_cursor: i64, } /// Acknowledgment from client that changes have been processed. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AckRequest { - pub device_id: String, - pub cursor: i64, - pub processed_paths: Vec, + pub device_id: String, + pub cursor: i64, + pub processed_paths: Vec, } /// Get changes since a cursor position. pub async fn get_changes( - storage: &DynStorageBackend, - cursor: i64, - limit: u64, + storage: &DynStorageBackend, + cursor: i64, + limit: u64, ) -> Result { - let limit = limit.min(1000); // Cap at 1000 - let changes = storage.get_changes_since(cursor, limit + 1).await?; + let limit = limit.min(1000); // Cap at 1000 + let changes = storage.get_changes_since(cursor, limit + 1).await?; - let has_more = changes.len() > limit as usize; - let changes: Vec<_> = changes.into_iter().take(limit as usize).collect(); + let has_more = changes.len() > limit as usize; + let changes: Vec<_> = changes.into_iter().take(limit as usize).collect(); - let new_cursor = changes.last().map(|c| c.sequence).unwrap_or(cursor); + let new_cursor = changes.last().map(|c| c.sequence).unwrap_or(cursor); - Ok(ChangesResponse { - changes, - cursor: new_cursor, - has_more, - }) + Ok(ChangesResponse { + changes, + cursor: new_cursor, + has_more, + }) } /// Record a change in the sync log. pub async fn record_change( - storage: &DynStorageBackend, - change_type: SyncChangeType, - path: &str, - media_id: Option, - content_hash: Option<&ContentHash>, - file_size: Option, - changed_by_device: Option, + storage: &DynStorageBackend, + change_type: SyncChangeType, + path: &str, + media_id: Option, + content_hash: Option<&ContentHash>, + file_size: Option, + changed_by_device: Option, ) -> Result { - let entry = SyncLogEntry { - id: Uuid::now_v7(), - sequence: 0, // Will be assigned by database - change_type, - media_id, - path: path.to_string(), - content_hash: content_hash.cloned(), - file_size, - metadata_json: None, - changed_by_device, - timestamp: Utc::now(), - }; + let entry = SyncLogEntry { + id: Uuid::now_v7(), + sequence: 0, // Will be assigned by database + change_type, + media_id, + path: path.to_string(), + content_hash: content_hash.cloned(), + file_size, + metadata_json: None, + changed_by_device, + timestamp: Utc::now(), + }; - storage.record_sync_change(&entry).await?; - Ok(entry) + storage.record_sync_change(&entry).await?; + Ok(entry) } /// Update device cursor after processing changes. pub async fn update_device_cursor( - storage: &DynStorageBackend, - device_id: DeviceId, - cursor: i64, + storage: &DynStorageBackend, + device_id: DeviceId, + cursor: i64, ) -> Result<()> { - let mut device = storage.get_device(device_id).await?; - device.sync_cursor = Some(cursor); - device.last_sync_at = Some(Utc::now()); - device.updated_at = Utc::now(); - storage.update_device(&device).await?; - Ok(()) + let mut device = storage.get_device(device_id).await?; + device.sync_cursor = Some(cursor); + device.last_sync_at = Some(Utc::now()); + device.updated_at = Utc::now(); + storage.update_device(&device).await?; + Ok(()) } /// Mark a file as synced for a device. pub async fn mark_synced( - storage: &DynStorageBackend, - device_id: DeviceId, - path: &str, - hash: &str, - mtime: Option, + storage: &DynStorageBackend, + device_id: DeviceId, + path: &str, + hash: &str, + mtime: Option, ) -> Result<()> { - let state = DeviceSyncState { - device_id, - path: path.to_string(), - local_hash: Some(hash.to_string()), - server_hash: Some(hash.to_string()), - local_mtime: mtime, - server_mtime: mtime, - sync_status: FileSyncStatus::Synced, - last_synced_at: Some(Utc::now()), - conflict_info_json: None, - }; + let state = DeviceSyncState { + device_id, + path: path.to_string(), + local_hash: Some(hash.to_string()), + server_hash: Some(hash.to_string()), + local_mtime: mtime, + server_mtime: mtime, + sync_status: FileSyncStatus::Synced, + last_synced_at: Some(Utc::now()), + conflict_info_json: None, + }; - storage.upsert_device_sync_state(&state).await?; - Ok(()) + storage.upsert_device_sync_state(&state).await?; + Ok(()) } /// Mark a file as pending download for a device. pub async fn mark_pending_download( - storage: &DynStorageBackend, - device_id: DeviceId, - path: &str, - server_hash: &str, - server_mtime: Option, + storage: &DynStorageBackend, + device_id: DeviceId, + path: &str, + server_hash: &str, + server_mtime: Option, ) -> Result<()> { - // Get existing state or create new - let state = match storage.get_device_sync_state(device_id, path).await? { - Some(mut s) => { - s.server_hash = Some(server_hash.to_string()); - s.server_mtime = server_mtime; - s.sync_status = FileSyncStatus::PendingDownload; - s - } - None => DeviceSyncState { - device_id, - path: path.to_string(), - local_hash: None, - server_hash: Some(server_hash.to_string()), - local_mtime: None, - server_mtime, - sync_status: FileSyncStatus::PendingDownload, - last_synced_at: None, - conflict_info_json: None, - }, - }; + // Get existing state or create new + let state = match storage.get_device_sync_state(device_id, path).await? { + Some(mut s) => { + s.server_hash = Some(server_hash.to_string()); + s.server_mtime = server_mtime; + s.sync_status = FileSyncStatus::PendingDownload; + s + }, + None => { + DeviceSyncState { + device_id, + path: path.to_string(), + local_hash: None, + server_hash: Some(server_hash.to_string()), + local_mtime: None, + server_mtime, + sync_status: FileSyncStatus::PendingDownload, + last_synced_at: None, + conflict_info_json: None, + } + }, + }; - storage.upsert_device_sync_state(&state).await?; - Ok(()) + storage.upsert_device_sync_state(&state).await?; + Ok(()) } /// Generate a device token using UUIDs for randomness. pub fn generate_device_token() -> String { - // Concatenate two UUIDs for 256 bits of randomness - let uuid1 = uuid::Uuid::new_v4(); - let uuid2 = uuid::Uuid::new_v4(); - format!("{}{}", uuid1.simple(), uuid2.simple()) + // Concatenate two UUIDs for 256 bits of randomness + let uuid1 = uuid::Uuid::new_v4(); + let uuid2 = uuid::Uuid::new_v4(); + format!("{}{}", uuid1.simple(), uuid2.simple()) } /// Hash a device token for storage. pub fn hash_device_token(token: &str) -> String { - blake3::hash(token.as_bytes()).to_hex().to_string() + blake3::hash(token.as_bytes()).to_hex().to_string() } diff --git a/crates/pinakes-core/src/tags.rs b/crates/pinakes-core/src/tags.rs index 2c09ff6..290a748 100644 --- a/crates/pinakes-core/src/tags.rs +++ b/crates/pinakes-core/src/tags.rs @@ -1,43 +1,52 @@ use uuid::Uuid; -use crate::error::Result; -use crate::model::{AuditAction, MediaId, Tag}; -use crate::storage::DynStorageBackend; +use crate::{ + error::Result, + model::{AuditAction, MediaId, Tag}, + storage::DynStorageBackend, +}; pub async fn create_tag( - storage: &DynStorageBackend, - name: &str, - parent_id: Option, + storage: &DynStorageBackend, + name: &str, + parent_id: Option, ) -> Result { - storage.create_tag(name, parent_id).await + storage.create_tag(name, parent_id).await } -pub async fn tag_media(storage: &DynStorageBackend, media_id: MediaId, tag_id: Uuid) -> Result<()> { - storage.tag_media(media_id, tag_id).await?; - crate::audit::record_action( - storage, - Some(media_id), - AuditAction::Tagged, - Some(format!("tag_id={tag_id}")), - ) - .await +pub async fn tag_media( + storage: &DynStorageBackend, + media_id: MediaId, + tag_id: Uuid, +) -> Result<()> { + storage.tag_media(media_id, tag_id).await?; + crate::audit::record_action( + storage, + Some(media_id), + AuditAction::Tagged, + Some(format!("tag_id={tag_id}")), + ) + .await } pub async fn untag_media( - storage: &DynStorageBackend, - media_id: MediaId, - tag_id: Uuid, + storage: &DynStorageBackend, + media_id: MediaId, + tag_id: Uuid, ) -> Result<()> { - storage.untag_media(media_id, tag_id).await?; - crate::audit::record_action( - storage, - Some(media_id), - AuditAction::Untagged, - Some(format!("tag_id={tag_id}")), - ) - .await + storage.untag_media(media_id, tag_id).await?; + crate::audit::record_action( + storage, + Some(media_id), + AuditAction::Untagged, + Some(format!("tag_id={tag_id}")), + ) + .await } -pub async fn get_tag_tree(storage: &DynStorageBackend, tag_id: Uuid) -> Result> { - storage.get_tag_descendants(tag_id).await +pub async fn get_tag_tree( + storage: &DynStorageBackend, + tag_id: Uuid, +) -> Result> { + storage.get_tag_descendants(tag_id).await } diff --git a/crates/pinakes-core/src/thumbnail.rs b/crates/pinakes-core/src/thumbnail.rs index 9f9ce3a..a97221b 100644 --- a/crates/pinakes-core/src/thumbnail.rs +++ b/crates/pinakes-core/src/thumbnail.rs @@ -1,501 +1,565 @@ -use std::path::{Path, PathBuf}; -use std::process::Command; +use std::{ + path::{Path, PathBuf}, + process::Command, +}; use tracing::{info, warn}; -use crate::config::ThumbnailConfig; -use crate::error::{PinakesError, Result}; -use crate::media_type::{BuiltinMediaType, MediaCategory, MediaType}; -use crate::model::MediaId; +use crate::{ + config::ThumbnailConfig, + error::{PinakesError, Result}, + media_type::{BuiltinMediaType, MediaCategory, MediaType}, + model::MediaId, +}; /// Generate a thumbnail for a media file and return the path to the thumbnail. /// -/// Supports images (via `image` crate), videos (via ffmpeg), PDFs (via pdftoppm), -/// and EPUBs (via cover image extraction). +/// Supports images (via `image` crate), videos (via ffmpeg), PDFs (via +/// pdftoppm), and EPUBs (via cover image extraction). pub fn generate_thumbnail( - media_id: MediaId, - source_path: &Path, - media_type: MediaType, - thumbnail_dir: &Path, + media_id: MediaId, + source_path: &Path, + media_type: MediaType, + thumbnail_dir: &Path, ) -> Result> { - generate_thumbnail_with_config( - media_id, - source_path, - media_type, - thumbnail_dir, - &ThumbnailConfig::default(), - ) + generate_thumbnail_with_config( + media_id, + source_path, + media_type, + thumbnail_dir, + &ThumbnailConfig::default(), + ) } pub fn generate_thumbnail_with_config( - media_id: MediaId, - source_path: &Path, - media_type: MediaType, - thumbnail_dir: &Path, - config: &ThumbnailConfig, + media_id: MediaId, + source_path: &Path, + media_type: MediaType, + thumbnail_dir: &Path, + config: &ThumbnailConfig, ) -> Result> { - std::fs::create_dir_all(thumbnail_dir)?; - let thumb_path = thumbnail_dir.join(format!("{}.jpg", media_id)); + std::fs::create_dir_all(thumbnail_dir)?; + let thumb_path = thumbnail_dir.join(format!("{}.jpg", media_id)); - let result = match media_type.category() { - MediaCategory::Image => { - if media_type.is_raw() { - generate_raw_thumbnail(source_path, &thumb_path, config) - } else if media_type == MediaType::Builtin(BuiltinMediaType::Heic) { - generate_heic_thumbnail(source_path, &thumb_path, config) - } else { - generate_image_thumbnail(source_path, &thumb_path, config) - } - } - MediaCategory::Video => generate_video_thumbnail(source_path, &thumb_path, config), - MediaCategory::Document => match media_type { - MediaType::Builtin(BuiltinMediaType::Pdf) => { - generate_pdf_thumbnail(source_path, &thumb_path, config) - } - MediaType::Builtin(BuiltinMediaType::Epub) => { - generate_epub_thumbnail(source_path, &thumb_path, config) - } - _ => return Ok(None), + let result = match media_type.category() { + MediaCategory::Image => { + if media_type.is_raw() { + generate_raw_thumbnail(source_path, &thumb_path, config) + } else if media_type == MediaType::Builtin(BuiltinMediaType::Heic) { + generate_heic_thumbnail(source_path, &thumb_path, config) + } else { + generate_image_thumbnail(source_path, &thumb_path, config) + } + }, + MediaCategory::Video => { + generate_video_thumbnail(source_path, &thumb_path, config) + }, + MediaCategory::Document => { + match media_type { + MediaType::Builtin(BuiltinMediaType::Pdf) => { + generate_pdf_thumbnail(source_path, &thumb_path, config) + }, + MediaType::Builtin(BuiltinMediaType::Epub) => { + generate_epub_thumbnail(source_path, &thumb_path, config) }, _ => return Ok(None), - }; + } + }, + _ => return Ok(None), + }; - match result { - Ok(()) => { - info!(media_id = %media_id, category = ?media_type.category(), "generated thumbnail"); - Ok(Some(thumb_path)) - } - Err(e) => { - warn!(media_id = %media_id, error = %e, "failed to generate thumbnail"); - Ok(None) - } - } + match result { + Ok(()) => { + info!(media_id = %media_id, category = ?media_type.category(), "generated thumbnail"); + Ok(Some(thumb_path)) + }, + Err(e) => { + warn!(media_id = %media_id, error = %e, "failed to generate thumbnail"); + Ok(None) + }, + } } -fn generate_image_thumbnail(source: &Path, dest: &Path, config: &ThumbnailConfig) -> Result<()> { - let img = image::open(source) - .map_err(|e| PinakesError::MetadataExtraction(format!("image open: {e}")))?; +fn generate_image_thumbnail( + source: &Path, + dest: &Path, + config: &ThumbnailConfig, +) -> Result<()> { + let img = image::open(source).map_err(|e| { + PinakesError::MetadataExtraction(format!("image open: {e}")) + })?; + let thumb = img.thumbnail(config.size, config.size); + + let mut output = std::fs::File::create(dest)?; + let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality( + &mut output, + config.quality, + ); + thumb.write_with_encoder(encoder).map_err(|e| { + PinakesError::MetadataExtraction(format!("thumbnail encode: {e}")) + })?; + + Ok(()) +} + +fn generate_video_thumbnail( + source: &Path, + dest: &Path, + config: &ThumbnailConfig, +) -> Result<()> { + let ffmpeg = config.ffmpeg_path.as_deref().unwrap_or("ffmpeg"); + + let status = Command::new(ffmpeg) + .args(["-ss", &config.video_seek_secs.to_string(), "-i"]) + .arg(source) + .args([ + "-vframes", + "1", + "-vf", + &format!("scale={}:{}", config.size, config.size), + "-y", + ]) + .arg(dest) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map_err(|e| { + PinakesError::MetadataExtraction(format!( + "ffmpeg not found or failed to execute: {e}" + )) + })?; + + if !status.success() { + return Err(PinakesError::MetadataExtraction(format!( + "ffmpeg exited with status {}", + status + ))); + } + + Ok(()) +} + +fn generate_pdf_thumbnail( + source: &Path, + dest: &Path, + config: &ThumbnailConfig, +) -> Result<()> { + // Use pdftoppm to render first page, then resize with image crate + let temp_prefix = dest.with_extension("tmp"); + let status = Command::new("pdftoppm") + .args(["-jpeg", "-f", "1", "-l", "1", "-singlefile"]) + .arg(source) + .arg(&temp_prefix) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map_err(|e| { + PinakesError::MetadataExtraction(format!( + "pdftoppm not found or failed to execute: {e}" + )) + })?; + + if !status.success() { + return Err(PinakesError::MetadataExtraction(format!( + "pdftoppm exited with status {}", + status + ))); + } + + // pdftoppm outputs .jpg + let rendered = temp_prefix.with_extension("jpg"); + if rendered.exists() { + // Resize to thumbnail size + let img = image::open(&rendered).map_err(|e| { + PinakesError::MetadataExtraction(format!("pdf thumbnail open: {e}")) + })?; let thumb = img.thumbnail(config.size, config.size); - let mut output = std::fs::File::create(dest)?; - let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, config.quality); - thumb - .write_with_encoder(encoder) - .map_err(|e| PinakesError::MetadataExtraction(format!("thumbnail encode: {e}")))?; - + let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality( + &mut output, + config.quality, + ); + thumb.write_with_encoder(encoder).map_err(|e| { + PinakesError::MetadataExtraction(format!("pdf thumbnail encode: {e}")) + })?; + let _ = std::fs::remove_file(&rendered); Ok(()) + } else { + Err(PinakesError::MetadataExtraction( + "pdftoppm did not produce output".to_string(), + )) + } } -fn generate_video_thumbnail(source: &Path, dest: &Path, config: &ThumbnailConfig) -> Result<()> { - let ffmpeg = config.ffmpeg_path.as_deref().unwrap_or("ffmpeg"); +fn generate_epub_thumbnail( + source: &Path, + dest: &Path, + config: &ThumbnailConfig, +) -> Result<()> { + // Try to extract cover image from EPUB + let mut doc = epub::doc::EpubDoc::new(source) + .map_err(|e| PinakesError::MetadataExtraction(format!("epub open: {e}")))?; - let status = Command::new(ffmpeg) - .args(["-ss", &config.video_seek_secs.to_string(), "-i"]) - .arg(source) - .args([ - "-vframes", - "1", - "-vf", - &format!("scale={}:{}", config.size, config.size), - "-y", - ]) - .arg(dest) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map_err(|e| { - PinakesError::MetadataExtraction(format!("ffmpeg not found or failed to execute: {e}")) - })?; - - if !status.success() { - return Err(PinakesError::MetadataExtraction(format!( - "ffmpeg exited with status {}", - status - ))); - } + let cover_data = doc.get_cover().map(|(data, _mime)| data).or_else(|| { + // Fallback: try to find a cover image in the resources + doc + .get_resource("cover-image") + .map(|(data, _)| data) + .or_else(|| doc.get_resource("cover").map(|(data, _)| data)) + }); + if let Some(data) = cover_data { + let img = image::load_from_memory(&data).map_err(|e| { + PinakesError::MetadataExtraction(format!("epub cover decode: {e}")) + })?; + let thumb = img.thumbnail(config.size, config.size); + let mut output = std::fs::File::create(dest)?; + let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality( + &mut output, + config.quality, + ); + thumb.write_with_encoder(encoder).map_err(|e| { + PinakesError::MetadataExtraction(format!("epub thumbnail encode: {e}")) + })?; Ok(()) + } else { + Err(PinakesError::MetadataExtraction( + "no cover image found in epub".to_string(), + )) + } } -fn generate_pdf_thumbnail(source: &Path, dest: &Path, config: &ThumbnailConfig) -> Result<()> { - // Use pdftoppm to render first page, then resize with image crate - let temp_prefix = dest.with_extension("tmp"); - let status = Command::new("pdftoppm") - .args(["-jpeg", "-f", "1", "-l", "1", "-singlefile"]) - .arg(source) - .arg(&temp_prefix) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map_err(|e| { - PinakesError::MetadataExtraction(format!( - "pdftoppm not found or failed to execute: {e}" - )) - })?; +fn generate_raw_thumbnail( + source: &Path, + dest: &Path, + config: &ThumbnailConfig, +) -> Result<()> { + // Try dcraw to extract embedded JPEG preview, then resize + let temp_ppm = dest.with_extension("ppm"); + let status = Command::new("dcraw") + .args(["-e", "-c"]) + .arg(source) + .stdout(std::fs::File::create(&temp_ppm).map_err(|e| { + PinakesError::MetadataExtraction(format!( + "failed to create temp file: {e}" + )) + })?) + .stderr(std::process::Stdio::null()) + .status() + .map_err(|e| { + PinakesError::MetadataExtraction(format!( + "dcraw not found or failed: {e}" + )) + })?; - if !status.success() { - return Err(PinakesError::MetadataExtraction(format!( - "pdftoppm exited with status {}", - status - ))); - } + if !status.success() { + let _ = std::fs::remove_file(&temp_ppm); + return Err(PinakesError::MetadataExtraction(format!( + "dcraw exited with status {}", + status + ))); + } - // pdftoppm outputs .jpg - let rendered = temp_prefix.with_extension("jpg"); - if rendered.exists() { - // Resize to thumbnail size - let img = image::open(&rendered) - .map_err(|e| PinakesError::MetadataExtraction(format!("pdf thumbnail open: {e}")))?; - let thumb = img.thumbnail(config.size, config.size); - let mut output = std::fs::File::create(dest)?; - let encoder = - image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, config.quality); - thumb - .write_with_encoder(encoder) - .map_err(|e| PinakesError::MetadataExtraction(format!("pdf thumbnail encode: {e}")))?; - let _ = std::fs::remove_file(&rendered); - Ok(()) - } else { - Err(PinakesError::MetadataExtraction( - "pdftoppm did not produce output".to_string(), - )) - } + // The extracted preview is typically a JPEG — try loading it + if temp_ppm.exists() { + let result = image::open(&temp_ppm); + let _ = std::fs::remove_file(&temp_ppm); + let img = result.map_err(|e| { + PinakesError::MetadataExtraction(format!("raw preview decode: {e}")) + })?; + let thumb = img.thumbnail(config.size, config.size); + let mut output = std::fs::File::create(dest)?; + let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality( + &mut output, + config.quality, + ); + thumb.write_with_encoder(encoder).map_err(|e| { + PinakesError::MetadataExtraction(format!("raw thumbnail encode: {e}")) + })?; + Ok(()) + } else { + Err(PinakesError::MetadataExtraction( + "dcraw did not produce output".to_string(), + )) + } } -fn generate_epub_thumbnail(source: &Path, dest: &Path, config: &ThumbnailConfig) -> Result<()> { - // Try to extract cover image from EPUB - let mut doc = epub::doc::EpubDoc::new(source) - .map_err(|e| PinakesError::MetadataExtraction(format!("epub open: {e}")))?; +fn generate_heic_thumbnail( + source: &Path, + dest: &Path, + config: &ThumbnailConfig, +) -> Result<()> { + // Use heif-convert to convert to JPEG, then resize + let temp_jpg = dest.with_extension("tmp.jpg"); + let status = Command::new("heif-convert") + .arg(source) + .arg(&temp_jpg) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map_err(|e| { + PinakesError::MetadataExtraction(format!( + "heif-convert not found or failed: {e}" + )) + })?; - let cover_data = doc.get_cover().map(|(data, _mime)| data).or_else(|| { - // Fallback: try to find a cover image in the resources - doc.get_resource("cover-image") - .map(|(data, _)| data) - .or_else(|| doc.get_resource("cover").map(|(data, _)| data)) - }); + if !status.success() { + let _ = std::fs::remove_file(&temp_jpg); + return Err(PinakesError::MetadataExtraction(format!( + "heif-convert exited with status {}", + status + ))); + } - if let Some(data) = cover_data { - let img = image::load_from_memory(&data) - .map_err(|e| PinakesError::MetadataExtraction(format!("epub cover decode: {e}")))?; - let thumb = img.thumbnail(config.size, config.size); - let mut output = std::fs::File::create(dest)?; - let encoder = - image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, config.quality); - thumb - .write_with_encoder(encoder) - .map_err(|e| PinakesError::MetadataExtraction(format!("epub thumbnail encode: {e}")))?; - Ok(()) - } else { - Err(PinakesError::MetadataExtraction( - "no cover image found in epub".to_string(), - )) - } -} - -fn generate_raw_thumbnail(source: &Path, dest: &Path, config: &ThumbnailConfig) -> Result<()> { - // Try dcraw to extract embedded JPEG preview, then resize - let temp_ppm = dest.with_extension("ppm"); - let status = Command::new("dcraw") - .args(["-e", "-c"]) - .arg(source) - .stdout(std::fs::File::create(&temp_ppm).map_err(|e| { - PinakesError::MetadataExtraction(format!("failed to create temp file: {e}")) - })?) - .stderr(std::process::Stdio::null()) - .status() - .map_err(|e| PinakesError::MetadataExtraction(format!("dcraw not found or failed: {e}")))?; - - if !status.success() { - let _ = std::fs::remove_file(&temp_ppm); - return Err(PinakesError::MetadataExtraction(format!( - "dcraw exited with status {}", - status - ))); - } - - // The extracted preview is typically a JPEG — try loading it - if temp_ppm.exists() { - let result = image::open(&temp_ppm); - let _ = std::fs::remove_file(&temp_ppm); - let img = result - .map_err(|e| PinakesError::MetadataExtraction(format!("raw preview decode: {e}")))?; - let thumb = img.thumbnail(config.size, config.size); - let mut output = std::fs::File::create(dest)?; - let encoder = - image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, config.quality); - thumb - .write_with_encoder(encoder) - .map_err(|e| PinakesError::MetadataExtraction(format!("raw thumbnail encode: {e}")))?; - Ok(()) - } else { - Err(PinakesError::MetadataExtraction( - "dcraw did not produce output".to_string(), - )) - } -} - -fn generate_heic_thumbnail(source: &Path, dest: &Path, config: &ThumbnailConfig) -> Result<()> { - // Use heif-convert to convert to JPEG, then resize - let temp_jpg = dest.with_extension("tmp.jpg"); - let status = Command::new("heif-convert") - .arg(source) - .arg(&temp_jpg) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map_err(|e| { - PinakesError::MetadataExtraction(format!("heif-convert not found or failed: {e}")) - })?; - - if !status.success() { - let _ = std::fs::remove_file(&temp_jpg); - return Err(PinakesError::MetadataExtraction(format!( - "heif-convert exited with status {}", - status - ))); - } - - if temp_jpg.exists() { - let result = image::open(&temp_jpg); - let _ = std::fs::remove_file(&temp_jpg); - let img = - result.map_err(|e| PinakesError::MetadataExtraction(format!("heic decode: {e}")))?; - let thumb = img.thumbnail(config.size, config.size); - let mut output = std::fs::File::create(dest)?; - let encoder = - image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, config.quality); - thumb - .write_with_encoder(encoder) - .map_err(|e| PinakesError::MetadataExtraction(format!("heic thumbnail encode: {e}")))?; - Ok(()) - } else { - Err(PinakesError::MetadataExtraction( - "heif-convert did not produce output".to_string(), - )) - } + if temp_jpg.exists() { + let result = image::open(&temp_jpg); + let _ = std::fs::remove_file(&temp_jpg); + let img = result.map_err(|e| { + PinakesError::MetadataExtraction(format!("heic decode: {e}")) + })?; + let thumb = img.thumbnail(config.size, config.size); + let mut output = std::fs::File::create(dest)?; + let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality( + &mut output, + config.quality, + ); + thumb.write_with_encoder(encoder).map_err(|e| { + PinakesError::MetadataExtraction(format!("heic thumbnail encode: {e}")) + })?; + Ok(()) + } else { + Err(PinakesError::MetadataExtraction( + "heif-convert did not produce output".to_string(), + )) + } } /// Cover size variants for book covers #[derive(Debug, Clone, Copy)] pub enum CoverSize { - Tiny, // 64x64 - for map markers, timeline - Grid, // 320x320 - for library grid view - Preview, // 1024x1024 - for quick fullscreen preview - Original, // Full size - original cover + Tiny, // 64x64 - for map markers, timeline + Grid, // 320x320 - for library grid view + Preview, // 1024x1024 - for quick fullscreen preview + Original, // Full size - original cover } impl CoverSize { - pub fn dimensions(&self) -> Option<(u32, u32)> { - match self { - CoverSize::Tiny => Some((64, 64)), - CoverSize::Grid => Some((320, 320)), - CoverSize::Preview => Some((1024, 1024)), - CoverSize::Original => None, // No resizing - } + pub fn dimensions(&self) -> Option<(u32, u32)> { + match self { + CoverSize::Tiny => Some((64, 64)), + CoverSize::Grid => Some((320, 320)), + CoverSize::Preview => Some((1024, 1024)), + CoverSize::Original => None, // No resizing } + } - pub fn filename(&self) -> &'static str { - match self { - CoverSize::Tiny => "tiny.jpg", - CoverSize::Grid => "grid.jpg", - CoverSize::Preview => "preview.jpg", - CoverSize::Original => "original.jpg", - } + pub fn filename(&self) -> &'static str { + match self { + CoverSize::Tiny => "tiny.jpg", + CoverSize::Grid => "grid.jpg", + CoverSize::Preview => "preview.jpg", + CoverSize::Original => "original.jpg", } + } } /// Generate multi-resolution covers for a book pub fn generate_book_covers( - media_id: MediaId, - source_image: &[u8], - covers_dir: &Path, + media_id: MediaId, + source_image: &[u8], + covers_dir: &Path, ) -> Result> { - // Create cover directory for this media item - let media_cover_dir = covers_dir.join(media_id.to_string()); - std::fs::create_dir_all(&media_cover_dir)?; + // Create cover directory for this media item + let media_cover_dir = covers_dir.join(media_id.to_string()); + std::fs::create_dir_all(&media_cover_dir)?; - let img = image::load_from_memory(source_image) - .map_err(|e| PinakesError::MetadataExtraction(format!("cover image load: {e}")))?; + let img = image::load_from_memory(source_image).map_err(|e| { + PinakesError::MetadataExtraction(format!("cover image load: {e}")) + })?; - let mut results = Vec::new(); + let mut results = Vec::new(); - // Generate each size variant - for size in [ - CoverSize::Tiny, - CoverSize::Grid, - CoverSize::Preview, - CoverSize::Original, - ] { - let cover_path = media_cover_dir.join(size.filename()); + // Generate each size variant + for size in [ + CoverSize::Tiny, + CoverSize::Grid, + CoverSize::Preview, + CoverSize::Original, + ] { + let cover_path = media_cover_dir.join(size.filename()); - match size.dimensions() { - Some((width, height)) => { - // Generate thumbnail - let thumb = img.thumbnail(width, height); - let mut output = std::fs::File::create(&cover_path)?; - let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 90); - thumb - .write_with_encoder(encoder) - .map_err(|e| PinakesError::MetadataExtraction(format!("cover encode: {e}")))?; - } - None => { - // Save original - let mut output = std::fs::File::create(&cover_path)?; - let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 95); - img.write_with_encoder(encoder) - .map_err(|e| PinakesError::MetadataExtraction(format!("cover encode: {e}")))?; - } - } - - results.push((size, cover_path)); + match size.dimensions() { + Some((width, height)) => { + // Generate thumbnail + let thumb = img.thumbnail(width, height); + let mut output = std::fs::File::create(&cover_path)?; + let encoder = + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 90); + thumb.write_with_encoder(encoder).map_err(|e| { + PinakesError::MetadataExtraction(format!("cover encode: {e}")) + })?; + }, + None => { + // Save original + let mut output = std::fs::File::create(&cover_path)?; + let encoder = + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 95); + img.write_with_encoder(encoder).map_err(|e| { + PinakesError::MetadataExtraction(format!("cover encode: {e}")) + })?; + }, } - Ok(results) + results.push((size, cover_path)); + } + + Ok(results) } /// Extract full-size cover from an EPUB file pub fn extract_epub_cover(epub_path: &Path) -> Result>> { - let mut doc = epub::doc::EpubDoc::new(epub_path) - .map_err(|e| PinakesError::MetadataExtraction(format!("EPUB open: {e}")))?; + let mut doc = epub::doc::EpubDoc::new(epub_path) + .map_err(|e| PinakesError::MetadataExtraction(format!("EPUB open: {e}")))?; - // Try to get the cover image - if let Some(cover_id) = doc.get_cover_id() - && let Some((cover_data, _mime)) = doc.get_resource(&cover_id) - { - return Ok(Some(cover_data)); + // Try to get the cover image + if let Some(cover_id) = doc.get_cover_id() + && let Some((cover_data, _mime)) = doc.get_resource(&cover_id) + { + return Ok(Some(cover_data)); + } + + // Fallback: look for common cover image filenames + let cover_names = [ + "cover.jpg", + "cover.jpeg", + "cover.png", + "Cover.jpg", + "Cover.jpeg", + "Cover.png", + ]; + for name in &cover_names { + if let Some(data) = doc.get_resource_by_path(name) { + return Ok(Some(data)); } + } - // Fallback: look for common cover image filenames - let cover_names = [ - "cover.jpg", - "cover.jpeg", - "cover.png", - "Cover.jpg", - "Cover.jpeg", - "Cover.png", - ]; - for name in &cover_names { - if let Some(data) = doc.get_resource_by_path(name) { - return Ok(Some(data)); - } - } - - Ok(None) + Ok(None) } /// Extract full-size cover from a PDF file (first page) pub fn extract_pdf_cover(pdf_path: &Path) -> Result>> { - // Use pdftoppm to extract the first page at high resolution - let pdftoppm = "pdftoppm"; + // Use pdftoppm to extract the first page at high resolution + let pdftoppm = "pdftoppm"; - let temp_dir = std::env::temp_dir(); - let temp_prefix = temp_dir.join(format!("pdf_cover_{}", uuid::Uuid::new_v4())); + let temp_dir = std::env::temp_dir(); + let temp_prefix = + temp_dir.join(format!("pdf_cover_{}", uuid::Uuid::new_v4())); - let status = Command::new(pdftoppm) - .args(["-jpeg", "-f", "1", "-l", "1", "-scale-to", "1200"]) - .arg(pdf_path) - .arg(&temp_prefix) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map_err(|e| PinakesError::MetadataExtraction(format!("pdftoppm: {e}")))?; + let status = Command::new(pdftoppm) + .args(["-jpeg", "-f", "1", "-l", "1", "-scale-to", "1200"]) + .arg(pdf_path) + .arg(&temp_prefix) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map_err(|e| PinakesError::MetadataExtraction(format!("pdftoppm: {e}")))?; - if !status.success() { - return Err(PinakesError::MetadataExtraction(format!( - "pdftoppm exited with status {}", - status - ))); - } + if !status.success() { + return Err(PinakesError::MetadataExtraction(format!( + "pdftoppm exited with status {}", + status + ))); + } - // pdftoppm outputs files like prefix-1.jpg - let output_path = format!("{}-1.jpg", temp_prefix.display()); - let output_pathbuf = PathBuf::from(&output_path); + // pdftoppm outputs files like prefix-1.jpg + let output_path = format!("{}-1.jpg", temp_prefix.display()); + let output_pathbuf = PathBuf::from(&output_path); - if output_pathbuf.exists() { - let data = std::fs::read(&output_pathbuf)?; - let _ = std::fs::remove_file(&output_pathbuf); - Ok(Some(data)) - } else { - Ok(None) - } + if output_pathbuf.exists() { + let data = std::fs::read(&output_pathbuf)?; + let _ = std::fs::remove_file(&output_pathbuf); + Ok(Some(data)) + } else { + Ok(None) + } } /// Returns the default covers directory under the data dir pub fn default_covers_dir() -> PathBuf { - crate::config::Config::default_data_dir().join("covers") + crate::config::Config::default_data_dir().join("covers") } /// Returns the default thumbnail directory under the data dir. pub fn default_thumbnail_dir() -> PathBuf { - crate::config::Config::default_data_dir().join("thumbnails") + crate::config::Config::default_data_dir().join("thumbnails") } /// Thumbnail size variant for multi-resolution support #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ThumbnailSize { - /// Tiny thumbnail for map markers and icons (64x64) - Tiny, - /// Grid thumbnail for library grid view (320x320) - Grid, - /// Preview thumbnail for quick fullscreen preview (1024x1024) - Preview, + /// Tiny thumbnail for map markers and icons (64x64) + Tiny, + /// Grid thumbnail for library grid view (320x320) + Grid, + /// Preview thumbnail for quick fullscreen preview (1024x1024) + Preview, } impl ThumbnailSize { - /// Get the pixel size for this thumbnail variant - pub fn pixels(&self) -> u32 { - match self { - ThumbnailSize::Tiny => 64, - ThumbnailSize::Grid => 320, - ThumbnailSize::Preview => 1024, - } + /// Get the pixel size for this thumbnail variant + pub fn pixels(&self) -> u32 { + match self { + ThumbnailSize::Tiny => 64, + ThumbnailSize::Grid => 320, + ThumbnailSize::Preview => 1024, } + } - /// Get the subdirectory name for this size - pub fn subdir_name(&self) -> &'static str { - match self { - ThumbnailSize::Tiny => "tiny", - ThumbnailSize::Grid => "grid", - ThumbnailSize::Preview => "preview", - } + /// Get the subdirectory name for this size + pub fn subdir_name(&self) -> &'static str { + match self { + ThumbnailSize::Tiny => "tiny", + ThumbnailSize::Grid => "grid", + ThumbnailSize::Preview => "preview", } + } } /// Generate all thumbnail sizes for a media file /// Returns paths to the generated thumbnails (tiny, grid, preview) pub fn generate_all_thumbnail_sizes( - media_id: MediaId, - source_path: &Path, - media_type: MediaType, - thumbnail_base_dir: &Path, + media_id: MediaId, + source_path: &Path, + media_type: MediaType, + thumbnail_base_dir: &Path, ) -> Result<(Option, Option, Option)> { - let sizes = [ - ThumbnailSize::Tiny, - ThumbnailSize::Grid, - ThumbnailSize::Preview, - ]; - let mut results = Vec::new(); + let sizes = [ + ThumbnailSize::Tiny, + ThumbnailSize::Grid, + ThumbnailSize::Preview, + ]; + let mut results = Vec::new(); - for size in &sizes { - let size_dir = thumbnail_base_dir.join(size.subdir_name()); - std::fs::create_dir_all(&size_dir)?; + for size in &sizes { + let size_dir = thumbnail_base_dir.join(size.subdir_name()); + std::fs::create_dir_all(&size_dir)?; - let config = ThumbnailConfig { - size: size.pixels(), - ..ThumbnailConfig::default() - }; + let config = ThumbnailConfig { + size: size.pixels(), + ..ThumbnailConfig::default() + }; - let result = generate_thumbnail_with_config( - media_id, - source_path, - media_type.clone(), - &size_dir, - &config, - )?; + let result = generate_thumbnail_with_config( + media_id, + source_path, + media_type.clone(), + &size_dir, + &config, + )?; - results.push(result); - } + results.push(result); + } - Ok((results[0].clone(), results[1].clone(), results[2].clone())) + Ok((results[0].clone(), results[1].clone(), results[2].clone())) } diff --git a/crates/pinakes-core/src/transcode.rs b/crates/pinakes-core/src/transcode.rs index f41baaa..fe00c7b 100644 --- a/crates/pinakes-core/src/transcode.rs +++ b/crates/pinakes-core/src/transcode.rs @@ -1,548 +1,578 @@ //! Transcoding service for media files using FFmpeg. -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + sync::Arc, +}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use tokio::sync::{RwLock, Semaphore}; use uuid::Uuid; -use crate::config::{TranscodeProfile, TranscodingConfig}; -use crate::model::MediaId; -use crate::storage::DynStorageBackend; -use crate::users::UserId; +use crate::{ + config::{TranscodeProfile, TranscodingConfig}, + model::MediaId, + storage::DynStorageBackend, + users::UserId, +}; /// A transcoding session for a media item. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TranscodeSession { - pub id: Uuid, - pub media_id: MediaId, - pub user_id: Option, - pub profile: String, - pub cache_path: PathBuf, - pub status: TranscodeStatus, - pub progress: f32, - pub created_at: DateTime, - pub expires_at: Option>, - /// Duration of the source media in seconds, used for progress calculation. - #[serde(default)] - pub duration_secs: Option, - /// Handle to cancel the child FFmpeg process. - #[serde(skip)] - pub child_cancel: Option>, + pub id: Uuid, + pub media_id: MediaId, + pub user_id: Option, + pub profile: String, + pub cache_path: PathBuf, + pub status: TranscodeStatus, + pub progress: f32, + pub created_at: DateTime, + pub expires_at: Option>, + /// Duration of the source media in seconds, used for progress calculation. + #[serde(default)] + pub duration_secs: Option, + /// Handle to cancel the child FFmpeg process. + #[serde(skip)] + pub child_cancel: Option>, } /// Status of a transcode session. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case", tag = "state")] pub enum TranscodeStatus { - Pending, - Transcoding, - Complete, - Failed { error: String }, - Cancelled, + Pending, + Transcoding, + Complete, + Failed { error: String }, + Cancelled, } impl TranscodeStatus { - pub fn as_str(&self) -> &str { - match self { - Self::Pending => "pending", - Self::Transcoding => "transcoding", - Self::Complete => "complete", - Self::Failed { .. } => "failed", - Self::Cancelled => "cancelled", - } + pub fn as_str(&self) -> &str { + match self { + Self::Pending => "pending", + Self::Transcoding => "transcoding", + Self::Complete => "complete", + Self::Failed { .. } => "failed", + Self::Cancelled => "cancelled", } + } - pub fn from_db(status: &str, error_message: Option<&str>) -> Self { - match status { - "pending" => Self::Pending, - "transcoding" => Self::Transcoding, - "complete" => Self::Complete, - "failed" => Self::Failed { - error: error_message.unwrap_or("unknown error").to_string(), - }, - "cancelled" => Self::Cancelled, - other => { - tracing::warn!( - "unknown transcode status '{}', defaulting to Pending", - other - ); - Self::Pending - } + pub fn from_db(status: &str, error_message: Option<&str>) -> Self { + match status { + "pending" => Self::Pending, + "transcoding" => Self::Transcoding, + "complete" => Self::Complete, + "failed" => { + Self::Failed { + error: error_message.unwrap_or("unknown error").to_string(), } + }, + "cancelled" => Self::Cancelled, + other => { + tracing::warn!( + "unknown transcode status '{}', defaulting to Pending", + other + ); + Self::Pending + }, } + } - pub fn error_message(&self) -> Option<&str> { - match self { - Self::Failed { error } => Some(error), - _ => None, - } + pub fn error_message(&self) -> Option<&str> { + match self { + Self::Failed { error } => Some(error), + _ => None, } + } } /// Service managing transcoding sessions and FFmpeg invocations. pub struct TranscodeService { - pub config: TranscodingConfig, - pub sessions: Arc>>, - semaphore: Arc, + pub config: TranscodingConfig, + pub sessions: Arc>>, + semaphore: Arc, } impl TranscodeService { - pub fn new(config: TranscodingConfig) -> Self { - let max_concurrent = config.max_concurrent.max(1); - Self { - sessions: Arc::new(RwLock::new(HashMap::new())), - semaphore: Arc::new(Semaphore::new(max_concurrent)), - config, - } + pub fn new(config: TranscodingConfig) -> Self { + let max_concurrent = config.max_concurrent.max(1); + Self { + sessions: Arc::new(RwLock::new(HashMap::new())), + semaphore: Arc::new(Semaphore::new(max_concurrent)), + config, + } + } + + pub fn is_enabled(&self) -> bool { + self.config.enabled + } + + pub fn cache_dir(&self) -> PathBuf { + self + .config + .cache_dir + .clone() + .unwrap_or_else(|| PathBuf::from("/tmp/pinakes-transcode")) + } + + /// Start a transcode job for a media item. + pub async fn start_transcode( + &self, + media_id: MediaId, + source_path: &Path, + profile_name: &str, + duration_secs: Option, + storage: &DynStorageBackend, + ) -> crate::error::Result { + let profile = self + .config + .profiles + .iter() + .find(|p| p.name == profile_name) + .cloned() + .ok_or_else(|| { + crate::error::PinakesError::InvalidOperation(format!( + "unknown transcode profile: {}", + profile_name + )) + })?; + + let session_id = Uuid::now_v7(); + let session_dir = self.cache_dir().join(session_id.to_string()); + tokio::fs::create_dir_all(&session_dir).await.map_err(|e| { + crate::error::PinakesError::InvalidOperation(format!( + "failed to create session directory: {}", + e + )) + })?; + + let expires_at = Some( + Utc::now() + chrono::Duration::hours(self.config.cache_ttl_hours as i64), + ); + + let cancel_notify = Arc::new(tokio::sync::Notify::new()); + + let session = TranscodeSession { + id: session_id, + media_id, + user_id: None, + profile: profile_name.to_string(), + cache_path: session_dir.clone(), + status: TranscodeStatus::Pending, + progress: 0.0, + created_at: Utc::now(), + expires_at, + duration_secs, + child_cancel: Some(cancel_notify.clone()), + }; + + // Store session in DB + storage.create_transcode_session(&session).await?; + + // Store in memory + { + let mut sessions = self.sessions.write().await; + sessions.insert(session_id, session); } - pub fn is_enabled(&self) -> bool { - self.config.enabled - } + // Spawn the FFmpeg task + let sessions = self.sessions.clone(); + let semaphore = self.semaphore.clone(); + let source = source_path.to_path_buf(); + let hw_accel = self.config.hardware_acceleration.clone(); + let storage = storage.clone(); + let cancel = cancel_notify.clone(); - pub fn cache_dir(&self) -> PathBuf { - self.config - .cache_dir - .clone() - .unwrap_or_else(|| PathBuf::from("/tmp/pinakes-transcode")) - } - - /// Start a transcode job for a media item. - pub async fn start_transcode( - &self, - media_id: MediaId, - source_path: &Path, - profile_name: &str, - duration_secs: Option, - storage: &DynStorageBackend, - ) -> crate::error::Result { - let profile = self - .config - .profiles - .iter() - .find(|p| p.name == profile_name) - .cloned() - .ok_or_else(|| { - crate::error::PinakesError::InvalidOperation(format!( - "unknown transcode profile: {}", - profile_name - )) - })?; - - let session_id = Uuid::now_v7(); - let session_dir = self.cache_dir().join(session_id.to_string()); - tokio::fs::create_dir_all(&session_dir).await.map_err(|e| { - crate::error::PinakesError::InvalidOperation(format!( - "failed to create session directory: {}", - e - )) - })?; - - let expires_at = - Some(Utc::now() + chrono::Duration::hours(self.config.cache_ttl_hours as i64)); - - let cancel_notify = Arc::new(tokio::sync::Notify::new()); - - let session = TranscodeSession { - id: session_id, - media_id, - user_id: None, - profile: profile_name.to_string(), - cache_path: session_dir.clone(), - status: TranscodeStatus::Pending, - progress: 0.0, - created_at: Utc::now(), - expires_at, - duration_secs, - child_cancel: Some(cancel_notify.clone()), - }; - - // Store session in DB - storage.create_transcode_session(&session).await?; - - // Store in memory - { - let mut sessions = self.sessions.write().await; - sessions.insert(session_id, session); - } - - // Spawn the FFmpeg task - let sessions = self.sessions.clone(); - let semaphore = self.semaphore.clone(); - let source = source_path.to_path_buf(); - let hw_accel = self.config.hardware_acceleration.clone(); - let storage = storage.clone(); - let cancel = cancel_notify.clone(); - - tokio::spawn(async move { - // Acquire semaphore permit to limit concurrency - let _permit = match semaphore.acquire().await { - Ok(permit) => permit, - Err(e) => { - tracing::error!("failed to acquire transcode semaphore: {}", e); - let error_msg = format!("semaphore closed: {}", e); - let mut s = sessions.write().await; - if let Some(sess) = s.get_mut(&session_id) { - sess.status = TranscodeStatus::Failed { - error: error_msg.clone(), - }; - } - if let Err(e) = storage - .update_transcode_status( - session_id, - TranscodeStatus::Failed { error: error_msg }, - 0.0, - ) - .await - { - tracing::error!("failed to update transcode status: {}", e); - } - return; - } + tokio::spawn(async move { + // Acquire semaphore permit to limit concurrency + let _permit = match semaphore.acquire().await { + Ok(permit) => permit, + Err(e) => { + tracing::error!("failed to acquire transcode semaphore: {}", e); + let error_msg = format!("semaphore closed: {}", e); + let mut s = sessions.write().await; + if let Some(sess) = s.get_mut(&session_id) { + sess.status = TranscodeStatus::Failed { + error: error_msg.clone(), }; + } + if let Err(e) = storage + .update_transcode_status( + session_id, + TranscodeStatus::Failed { error: error_msg }, + 0.0, + ) + .await + { + tracing::error!("failed to update transcode status: {}", e); + } + return; + }, + }; - // Mark as transcoding - { - let mut s = sessions.write().await; - if let Some(sess) = s.get_mut(&session_id) { - sess.status = TranscodeStatus::Transcoding; - } - } - if let Err(e) = storage - .update_transcode_status(session_id, TranscodeStatus::Transcoding, 0.0) - .await - { - tracing::error!("failed to update transcode status: {}", e); - } - - // Build FFmpeg args and run - let args = get_ffmpeg_args(&source, &session_dir, &profile, hw_accel.as_deref()); - match run_ffmpeg(&args, &sessions, session_id, duration_secs, cancel).await { - Ok(()) => { - let mut s = sessions.write().await; - if let Some(sess) = s.get_mut(&session_id) { - sess.status = TranscodeStatus::Complete; - sess.progress = 1.0; - } - if let Err(e) = storage - .update_transcode_status(session_id, TranscodeStatus::Complete, 1.0) - .await - { - tracing::error!("failed to update transcode status: {}", e); - } - } - Err(e) => { - let error_msg = e.to_string(); - let mut s = sessions.write().await; - if let Some(sess) = s.get_mut(&session_id) { - // Don't overwrite Cancelled status - if matches!(sess.status, TranscodeStatus::Cancelled) { - return; - } - sess.status = TranscodeStatus::Failed { - error: error_msg.clone(), - }; - } - drop(s); - if let Err(e) = storage - .update_transcode_status( - session_id, - TranscodeStatus::Failed { error: error_msg }, - 0.0, - ) - .await - { - tracing::error!("failed to update transcode status: {}", e); - } - } - } - }); - - Ok(session_id) - } - - /// Cancel a transcode session and clean up cache files. - pub async fn cancel_transcode( - &self, - session_id: Uuid, - storage: &DynStorageBackend, - ) -> crate::error::Result<()> { - let (cache_path, cancel_notify) = { - let mut sessions = self.sessions.write().await; - if let Some(sess) = sessions.get_mut(&session_id) { - sess.status = TranscodeStatus::Cancelled; - let cancel = sess.child_cancel.take(); - (Some(sess.cache_path.clone()), cancel) - } else { - (None, None) - } - }; - - // Signal the child process to be killed - if let Some(notify) = cancel_notify { - notify.notify_one(); + // Mark as transcoding + { + let mut s = sessions.write().await; + if let Some(sess) = s.get_mut(&session_id) { + sess.status = TranscodeStatus::Transcoding; } + } + if let Err(e) = storage + .update_transcode_status(session_id, TranscodeStatus::Transcoding, 0.0) + .await + { + tracing::error!("failed to update transcode status: {}", e); + } - storage - .update_transcode_status(session_id, TranscodeStatus::Cancelled, 0.0) - .await?; - - // Clean up cache directory - if let Some(path) = cache_path - && let Err(e) = tokio::fs::remove_dir_all(&path).await - { - tracing::error!("failed to remove transcode cache directory: {}", e); - } - - Ok(()) - } - - /// Remove expired transcode sessions and their cache directories. - pub async fn cleanup_expired(&self) { - let now = Utc::now(); - - // Collect expired entries and remove them from the map under the lock. - let expired: Vec<(Uuid, PathBuf)> = { - let mut sessions = self.sessions.write().await; - let expired: Vec<(Uuid, PathBuf)> = sessions - .iter() - .filter_map(|(id, sess)| { - if let Some(expires) = sess.expires_at - && now > expires - { - return Some((*id, sess.cache_path.clone())); - } - None - }) - .collect(); - - for (id, _) in &expired { - sessions.remove(id); + // Build FFmpeg args and run + let args = + get_ffmpeg_args(&source, &session_dir, &profile, hw_accel.as_deref()); + match run_ffmpeg(&args, &sessions, session_id, duration_secs, cancel) + .await + { + Ok(()) => { + let mut s = sessions.write().await; + if let Some(sess) = s.get_mut(&session_id) { + sess.status = TranscodeStatus::Complete; + sess.progress = 1.0; + } + if let Err(e) = storage + .update_transcode_status(session_id, TranscodeStatus::Complete, 1.0) + .await + { + tracing::error!("failed to update transcode status: {}", e); + } + }, + Err(e) => { + let error_msg = e.to_string(); + let mut s = sessions.write().await; + if let Some(sess) = s.get_mut(&session_id) { + // Don't overwrite Cancelled status + if matches!(sess.status, TranscodeStatus::Cancelled) { + return; } + sess.status = TranscodeStatus::Failed { + error: error_msg.clone(), + }; + } + drop(s); + if let Err(e) = storage + .update_transcode_status( + session_id, + TranscodeStatus::Failed { error: error_msg }, + 0.0, + ) + .await + { + tracing::error!("failed to update transcode status: {}", e); + } + }, + } + }); - expired - }; - // Lock is dropped here; perform filesystem cleanup outside the lock. + Ok(session_id) + } - for (_id, path) in expired { - if let Err(e) = tokio::fs::remove_dir_all(&path).await { - tracing::error!("failed to remove expired transcode cache directory: {}", e); - } - } + /// Cancel a transcode session and clean up cache files. + pub async fn cancel_transcode( + &self, + session_id: Uuid, + storage: &DynStorageBackend, + ) -> crate::error::Result<()> { + let (cache_path, cancel_notify) = { + let mut sessions = self.sessions.write().await; + if let Some(sess) = sessions.get_mut(&session_id) { + sess.status = TranscodeStatus::Cancelled; + let cancel = sess.child_cancel.take(); + (Some(sess.cache_path.clone()), cancel) + } else { + (None, None) + } + }; + + // Signal the child process to be killed + if let Some(notify) = cancel_notify { + notify.notify_one(); } - /// Get a session by ID from the in-memory store. - pub async fn get_session(&self, session_id: Uuid) -> Option { - let sessions = self.sessions.read().await; - sessions.get(&session_id).cloned() + storage + .update_transcode_status(session_id, TranscodeStatus::Cancelled, 0.0) + .await?; + + // Clean up cache directory + if let Some(path) = cache_path + && let Err(e) = tokio::fs::remove_dir_all(&path).await + { + tracing::error!("failed to remove transcode cache directory: {}", e); } - /// Resolve the path to a specific segment file on disk. - pub fn segment_path(&self, session_id: Uuid, segment_name: &str) -> PathBuf { - // Sanitize segment_name to prevent path traversal - let safe_name = std::path::Path::new(segment_name) - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_default(); - if safe_name.is_empty() || safe_name.contains('\0') || safe_name.starts_with('.') { - // Return a non-existent path that will fail safely - return self - .cache_dir() - .join(session_id.to_string()) - .join("__invalid__"); - } - self.cache_dir() - .join(session_id.to_string()) - .join(safe_name) - } + Ok(()) + } - /// Find a session for a given media_id and profile. - pub async fn find_session(&self, media_id: MediaId, profile: &str) -> Option { - let sessions = self.sessions.read().await; - sessions - .values() - .find(|s| s.media_id == media_id && s.profile == profile) - .cloned() + /// Remove expired transcode sessions and their cache directories. + pub async fn cleanup_expired(&self) { + let now = Utc::now(); + + // Collect expired entries and remove them from the map under the lock. + let expired: Vec<(Uuid, PathBuf)> = { + let mut sessions = self.sessions.write().await; + let expired: Vec<(Uuid, PathBuf)> = sessions + .iter() + .filter_map(|(id, sess)| { + if let Some(expires) = sess.expires_at + && now > expires + { + return Some((*id, sess.cache_path.clone())); + } + None + }) + .collect(); + + for (id, _) in &expired { + sessions.remove(id); + } + + expired + }; + // Lock is dropped here; perform filesystem cleanup outside the lock. + + for (_id, path) in expired { + if let Err(e) = tokio::fs::remove_dir_all(&path).await { + tracing::error!( + "failed to remove expired transcode cache directory: {}", + e + ); + } } + } + + /// Get a session by ID from the in-memory store. + pub async fn get_session( + &self, + session_id: Uuid, + ) -> Option { + let sessions = self.sessions.read().await; + sessions.get(&session_id).cloned() + } + + /// Resolve the path to a specific segment file on disk. + pub fn segment_path(&self, session_id: Uuid, segment_name: &str) -> PathBuf { + // Sanitize segment_name to prevent path traversal + let safe_name = std::path::Path::new(segment_name) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + if safe_name.is_empty() + || safe_name.contains('\0') + || safe_name.starts_with('.') + { + // Return a non-existent path that will fail safely + return self + .cache_dir() + .join(session_id.to_string()) + .join("__invalid__"); + } + self + .cache_dir() + .join(session_id.to_string()) + .join(safe_name) + } + + /// Find a session for a given media_id and profile. + pub async fn find_session( + &self, + media_id: MediaId, + profile: &str, + ) -> Option { + let sessions = self.sessions.read().await; + sessions + .values() + .find(|s| s.media_id == media_id && s.profile == profile) + .cloned() + } } /// Parse a resolution string like "360p", "720p", "1080p" into (width, height). pub fn parse_resolution(res: &str) -> (u32, u32) { - match res.trim_end_matches('p') { - "360" => (640, 360), - "480" => (854, 480), - "720" => (1280, 720), - "1080" => (1920, 1080), - "1440" => (2560, 1440), - "2160" | "4k" => (3840, 2160), - _ => (1280, 720), // default to 720p - } + match res.trim_end_matches('p') { + "360" => (640, 360), + "480" => (854, 480), + "720" => (1280, 720), + "1080" => (1920, 1080), + "1440" => (2560, 1440), + "2160" | "4k" => (3840, 2160), + _ => (1280, 720), // default to 720p + } } /// Estimate bandwidth (bits/sec) from a profile's max_bitrate_kbps. pub fn estimate_bandwidth(profile: &TranscodeProfile) -> u32 { - profile.max_bitrate_kbps * 1000 + profile.max_bitrate_kbps * 1000 } /// Build FFmpeg CLI arguments for transcoding. fn get_ffmpeg_args( - source: &Path, - output_dir: &Path, - profile: &TranscodeProfile, - hw_accel: Option<&str>, + source: &Path, + output_dir: &Path, + profile: &TranscodeProfile, + hw_accel: Option<&str>, ) -> Vec { - let (w, h) = parse_resolution(&profile.max_resolution); - let playlist = output_dir.join("playlist.m3u8"); - let segment_pattern = output_dir.join("segment%d.ts"); + let (w, h) = parse_resolution(&profile.max_resolution); + let playlist = output_dir.join("playlist.m3u8"); + let segment_pattern = output_dir.join("segment%d.ts"); - let mut args = Vec::new(); + let mut args = Vec::new(); - // Hardware acceleration - if let Some(accel) = hw_accel { - args.extend_from_slice(&["-hwaccel".to_string(), accel.to_string()]); - } + // Hardware acceleration + if let Some(accel) = hw_accel { + args.extend_from_slice(&["-hwaccel".to_string(), accel.to_string()]); + } - args.extend_from_slice(&[ - "-i".to_string(), - source.to_string_lossy().to_string(), - "-c:v".to_string(), - profile.video_codec.clone(), - "-c:a".to_string(), - profile.audio_codec.clone(), - "-b:v".to_string(), - format!("{}k", profile.max_bitrate_kbps), - "-vf".to_string(), - format!("scale={}:{}", w, h), - "-f".to_string(), - "hls".to_string(), - "-hls_time".to_string(), - "10".to_string(), - "-hls_segment_filename".to_string(), - segment_pattern.to_string_lossy().to_string(), - "-progress".to_string(), - "pipe:1".to_string(), - "-y".to_string(), - playlist.to_string_lossy().to_string(), - ]); + args.extend_from_slice(&[ + "-i".to_string(), + source.to_string_lossy().to_string(), + "-c:v".to_string(), + profile.video_codec.clone(), + "-c:a".to_string(), + profile.audio_codec.clone(), + "-b:v".to_string(), + format!("{}k", profile.max_bitrate_kbps), + "-vf".to_string(), + format!("scale={}:{}", w, h), + "-f".to_string(), + "hls".to_string(), + "-hls_time".to_string(), + "10".to_string(), + "-hls_segment_filename".to_string(), + segment_pattern.to_string_lossy().to_string(), + "-progress".to_string(), + "pipe:1".to_string(), + "-y".to_string(), + playlist.to_string_lossy().to_string(), + ]); - args + args } /// Run FFmpeg as a child process, parsing progress from stdout. async fn run_ffmpeg( - args: &[String], - sessions: &Arc>>, - session_id: Uuid, - duration_secs: Option, - cancel: Arc, + args: &[String], + sessions: &Arc>>, + session_id: Uuid, + duration_secs: Option, + cancel: Arc, ) -> Result<(), crate::error::PinakesError> { - use tokio::io::{AsyncBufReadExt, BufReader}; - use tokio::process::Command; + use tokio::{ + io::{AsyncBufReadExt, BufReader}, + process::Command, + }; - let mut child = Command::new("ffmpeg") - .args(args) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn() - .map_err(|e| { - crate::error::PinakesError::InvalidOperation(format!("failed to spawn ffmpeg: {}", e)) - })?; + let mut child = Command::new("ffmpeg") + .args(args) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| { + crate::error::PinakesError::InvalidOperation(format!( + "failed to spawn ffmpeg: {}", + e + )) + })?; - // Capture stderr in a spawned task for error reporting - let stderr_handle = if let Some(stderr) = child.stderr.take() { - let reader = BufReader::new(stderr); - Some(tokio::spawn(async move { - let mut lines = reader.lines(); - let mut collected = Vec::new(); - while let Ok(Some(line)) = lines.next_line().await { - collected.push(line); - } - collected - })) - } else { - None - }; + // Capture stderr in a spawned task for error reporting + let stderr_handle = if let Some(stderr) = child.stderr.take() { + let reader = BufReader::new(stderr); + Some(tokio::spawn(async move { + let mut lines = reader.lines(); + let mut collected = Vec::new(); + while let Ok(Some(line)) = lines.next_line().await { + collected.push(line); + } + collected + })) + } else { + None + }; - // Parse progress from stdout - let stdout_handle = if let Some(stdout) = child.stdout.take() { - let reader = BufReader::new(stdout); - let mut lines = reader.lines(); - let sessions = sessions.clone(); + // Parse progress from stdout + let stdout_handle = if let Some(stdout) = child.stdout.take() { + let reader = BufReader::new(stdout); + let mut lines = reader.lines(); + let sessions = sessions.clone(); - Some(tokio::spawn(async move { - while let Ok(Some(line)) = lines.next_line().await { - // FFmpeg progress output: "out_time_us=12345678" - if let Some(time_str) = line.strip_prefix("out_time_us=") - && let Ok(us) = time_str.trim().parse::() - { - let secs = us / 1_000_000.0; - // Calculate progress based on known duration - let progress = match duration_secs { - Some(dur) if dur > 0.0 => (secs / dur).min(0.99) as f32, - _ => { - // Duration unknown; don't update progress - continue; - } - }; - let mut s = sessions.write().await; - if let Some(sess) = s.get_mut(&session_id) { - sess.progress = progress; - } - } - } - })) - } else { - None - }; - - // Wait for child, but also listen for cancellation - let status = tokio::select! { - result = child.wait() => { - result.map_err(|e| { - crate::error::PinakesError::InvalidOperation(format!("ffmpeg process error: {}", e)) - })? + Some(tokio::spawn(async move { + while let Ok(Some(line)) = lines.next_line().await { + // FFmpeg progress output: "out_time_us=12345678" + if let Some(time_str) = line.strip_prefix("out_time_us=") + && let Ok(us) = time_str.trim().parse::() + { + let secs = us / 1_000_000.0; + // Calculate progress based on known duration + let progress = match duration_secs { + Some(dur) if dur > 0.0 => (secs / dur).min(0.99) as f32, + _ => { + // Duration unknown; don't update progress + continue; + }, + }; + let mut s = sessions.write().await; + if let Some(sess) = s.get_mut(&session_id) { + sess.progress = progress; + } } - _ = cancel.notified() => { - // Kill the child process on cancel - if let Err(e) = child.kill().await { - tracing::error!("failed to kill ffmpeg process: {}", e); - } - return Err(crate::error::PinakesError::InvalidOperation( - "cancelled by user".to_string(), - )); - } - }; + } + })) + } else { + None + }; - // Await the stdout reader task - if let Some(handle) = stdout_handle { - let _ = handle.await; - } + // Wait for child, but also listen for cancellation + let status = tokio::select! { + result = child.wait() => { + result.map_err(|e| { + crate::error::PinakesError::InvalidOperation(format!("ffmpeg process error: {}", e)) + })? + } + _ = cancel.notified() => { + // Kill the child process on cancel + if let Err(e) = child.kill().await { + tracing::error!("failed to kill ffmpeg process: {}", e); + } + return Err(crate::error::PinakesError::InvalidOperation( + "cancelled by user".to_string(), + )); + } + }; - // Collect stderr output for error reporting - let stderr_output = if let Some(handle) = stderr_handle { - handle.await.unwrap_or_default() - } else { - Vec::new() - }; + // Await the stdout reader task + if let Some(handle) = stdout_handle { + let _ = handle.await; + } - if !status.success() { - let last_stderr = stderr_output - .iter() - .rev() - .take(10) - .rev() - .cloned() - .collect::>() - .join("\n"); - return Err(crate::error::PinakesError::InvalidOperation(format!( - "ffmpeg exited with status: {}\nstderr:\n{}", - status, last_stderr - ))); - } + // Collect stderr output for error reporting + let stderr_output = if let Some(handle) = stderr_handle { + handle.await.unwrap_or_default() + } else { + Vec::new() + }; - Ok(()) + if !status.success() { + let last_stderr = stderr_output + .iter() + .rev() + .take(10) + .rev() + .cloned() + .collect::>() + .join("\n"); + return Err(crate::error::PinakesError::InvalidOperation(format!( + "ffmpeg exited with status: {}\nstderr:\n{}", + status, last_stderr + ))); + } + + Ok(()) } diff --git a/crates/pinakes-core/src/upload.rs b/crates/pinakes-core/src/upload.rs index dd21d0c..7f88e5d 100644 --- a/crates/pinakes-core/src/upload.rs +++ b/crates/pinakes-core/src/upload.rs @@ -3,19 +3,20 @@ //! Handles file uploads, metadata extraction, and MediaItem creation //! for files stored in managed content-addressable storage. -use std::collections::HashMap; -use std::path::Path; +use std::{collections::HashMap, path::Path}; use chrono::Utc; use tokio::io::AsyncRead; use tracing::{debug, info}; -use crate::error::{PinakesError, Result}; -use crate::managed_storage::ManagedStorageService; -use crate::media_type::MediaType; -use crate::metadata; -use crate::model::{MediaId, MediaItem, StorageMode, UploadResult}; -use crate::storage::DynStorageBackend; +use crate::{ + error::{PinakesError, Result}, + managed_storage::ManagedStorageService, + media_type::MediaType, + metadata, + model::{MediaId, MediaItem, StorageMode, UploadResult}, + storage::DynStorageBackend, +}; /// Process an upload from an async reader. /// @@ -25,196 +26,198 @@ use crate::storage::DynStorageBackend; /// 3. Extracts metadata from the file /// 4. Creates or updates the MediaItem pub async fn process_upload( - storage: &DynStorageBackend, - managed: &ManagedStorageService, - reader: R, - original_filename: &str, - mime_type: Option<&str>, + storage: &DynStorageBackend, + managed: &ManagedStorageService, + reader: R, + original_filename: &str, + mime_type: Option<&str>, ) -> Result { - // Store the file - let (content_hash, file_size) = managed.store_stream(reader).await?; + // Store the file + let (content_hash, file_size) = managed.store_stream(reader).await?; - // Check if we already have a media item with this hash - if let Some(existing) = storage.get_media_by_hash(&content_hash).await? { - debug!(hash = %content_hash, media_id = %existing.id, "upload matched existing media item"); - return Ok(UploadResult { - media_id: existing.id, - content_hash, - was_duplicate: true, - file_size, - }); - } + // Check if we already have a media item with this hash + if let Some(existing) = storage.get_media_by_hash(&content_hash).await? { + debug!(hash = %content_hash, media_id = %existing.id, "upload matched existing media item"); + return Ok(UploadResult { + media_id: existing.id, + content_hash, + was_duplicate: true, + file_size, + }); + } - // Determine media type from filename - let media_type = MediaType::from_path(Path::new(original_filename)) - .unwrap_or_else(|| MediaType::custom("unknown")); + // Determine media type from filename + let media_type = MediaType::from_path(Path::new(original_filename)) + .unwrap_or_else(|| MediaType::custom("unknown")); - // Get the actual file path in managed storage for metadata extraction - let blob_path = managed.path(&content_hash); + // Get the actual file path in managed storage for metadata extraction + let blob_path = managed.path(&content_hash); - // Extract metadata - let extracted = metadata::extract_metadata(&blob_path, media_type.clone()).ok(); + // Extract metadata + let extracted = + metadata::extract_metadata(&blob_path, media_type.clone()).ok(); - // Create or get blob record - let mime = mime_type - .map(String::from) - .unwrap_or_else(|| media_type.mime_type().to_string()); - let _blob = storage - .get_or_create_blob(&content_hash, file_size, &mime) - .await?; + // Create or get blob record + let mime = mime_type + .map(String::from) + .unwrap_or_else(|| media_type.mime_type().to_string()); + let _blob = storage + .get_or_create_blob(&content_hash, file_size, &mime) + .await?; - // Create the media item - let now = Utc::now(); - let media_id = MediaId::new(); + // Create the media item + let now = Utc::now(); + let media_id = MediaId::new(); - let item = MediaItem { - id: media_id, - path: blob_path, - file_name: sanitize_filename(original_filename), - media_type, - content_hash: content_hash.clone(), - file_size, - title: extracted.as_ref().and_then(|m| m.title.clone()), - artist: extracted.as_ref().and_then(|m| m.artist.clone()), - album: extracted.as_ref().and_then(|m| m.album.clone()), - genre: extracted.as_ref().and_then(|m| m.genre.clone()), - year: extracted.as_ref().and_then(|m| m.year), - duration_secs: extracted.as_ref().and_then(|m| m.duration_secs), - description: extracted.as_ref().and_then(|m| m.description.clone()), - thumbnail_path: None, - custom_fields: HashMap::new(), - file_mtime: None, - date_taken: extracted.as_ref().and_then(|m| m.date_taken), - latitude: extracted.as_ref().and_then(|m| m.latitude), - longitude: extracted.as_ref().and_then(|m| m.longitude), - camera_make: extracted.as_ref().and_then(|m| m.camera_make.clone()), - camera_model: extracted.as_ref().and_then(|m| m.camera_model.clone()), - rating: None, - perceptual_hash: None, - storage_mode: StorageMode::Managed, - original_filename: Some(original_filename.to_string()), - uploaded_at: Some(now), - storage_key: Some(content_hash.0.clone()), - created_at: now, - updated_at: now, - deleted_at: None, - links_extracted_at: None, - }; + let item = MediaItem { + id: media_id, + path: blob_path, + file_name: sanitize_filename(original_filename), + media_type, + content_hash: content_hash.clone(), + file_size, + title: extracted.as_ref().and_then(|m| m.title.clone()), + artist: extracted.as_ref().and_then(|m| m.artist.clone()), + album: extracted.as_ref().and_then(|m| m.album.clone()), + genre: extracted.as_ref().and_then(|m| m.genre.clone()), + year: extracted.as_ref().and_then(|m| m.year), + duration_secs: extracted.as_ref().and_then(|m| m.duration_secs), + description: extracted.as_ref().and_then(|m| m.description.clone()), + thumbnail_path: None, + custom_fields: HashMap::new(), + file_mtime: None, + date_taken: extracted.as_ref().and_then(|m| m.date_taken), + latitude: extracted.as_ref().and_then(|m| m.latitude), + longitude: extracted.as_ref().and_then(|m| m.longitude), + camera_make: extracted.as_ref().and_then(|m| m.camera_make.clone()), + camera_model: extracted.as_ref().and_then(|m| m.camera_model.clone()), + rating: None, + perceptual_hash: None, + storage_mode: StorageMode::Managed, + original_filename: Some(original_filename.to_string()), + uploaded_at: Some(now), + storage_key: Some(content_hash.0.clone()), + created_at: now, + updated_at: now, + deleted_at: None, + links_extracted_at: None, + }; - // Store the media item - storage.insert_managed_media(&item).await?; + // Store the media item + storage.insert_managed_media(&item).await?; - info!( - media_id = %media_id, - hash = %content_hash, - filename = %original_filename, - size = file_size, - "processed upload" - ); + info!( + media_id = %media_id, + hash = %content_hash, + filename = %original_filename, + size = file_size, + "processed upload" + ); - Ok(UploadResult { - media_id, - content_hash, - was_duplicate: false, - file_size, - }) + Ok(UploadResult { + media_id, + content_hash, + was_duplicate: false, + file_size, + }) } /// Process an upload from bytes. pub async fn process_upload_bytes( - storage: &DynStorageBackend, - managed: &ManagedStorageService, - data: &[u8], - original_filename: &str, - mime_type: Option<&str>, + storage: &DynStorageBackend, + managed: &ManagedStorageService, + data: &[u8], + original_filename: &str, + mime_type: Option<&str>, ) -> Result { - use std::io::Cursor; - let cursor = Cursor::new(data); - process_upload(storage, managed, cursor, original_filename, mime_type).await + use std::io::Cursor; + let cursor = Cursor::new(data); + process_upload(storage, managed, cursor, original_filename, mime_type).await } /// Process an upload from a local file path. /// /// This is useful for migrating existing external files to managed storage. pub async fn process_upload_file( - storage: &DynStorageBackend, - managed: &ManagedStorageService, - path: &Path, - original_filename: Option<&str>, + storage: &DynStorageBackend, + managed: &ManagedStorageService, + path: &Path, + original_filename: Option<&str>, ) -> Result { - let file = tokio::fs::File::open(path).await?; - let reader = tokio::io::BufReader::new(file); + let file = tokio::fs::File::open(path).await?; + let reader = tokio::io::BufReader::new(file); - let filename = original_filename.unwrap_or_else(|| { - path.file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown") - }); + let filename = original_filename.unwrap_or_else(|| { + path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + }); - let mime = mime_guess::from_path(path).first().map(|m| m.to_string()); + let mime = mime_guess::from_path(path).first().map(|m| m.to_string()); - process_upload(storage, managed, reader, filename, mime.as_deref()).await + process_upload(storage, managed, reader, filename, mime.as_deref()).await } /// Migrate an existing external media item to managed storage. pub async fn migrate_to_managed( - storage: &DynStorageBackend, - managed: &ManagedStorageService, - media_id: MediaId, + storage: &DynStorageBackend, + managed: &ManagedStorageService, + media_id: MediaId, ) -> Result<()> { - let item = storage.get_media(media_id).await?; + let item = storage.get_media(media_id).await?; - if item.storage_mode == StorageMode::Managed { - return Err(PinakesError::InvalidOperation( - "media item is already in managed storage".into(), - )); - } + if item.storage_mode == StorageMode::Managed { + return Err(PinakesError::InvalidOperation( + "media item is already in managed storage".into(), + )); + } - // Check if the external file exists - if !item.path.exists() { - return Err(PinakesError::FileNotFound(item.path.clone())); - } + // Check if the external file exists + if !item.path.exists() { + return Err(PinakesError::FileNotFound(item.path.clone())); + } - // Store the file in managed storage - let (new_hash, new_size) = managed.store_file(&item.path).await?; + // Store the file in managed storage + let (new_hash, new_size) = managed.store_file(&item.path).await?; - // Verify the hash matches (it should, unless the file changed) - if new_hash.0 != item.content_hash.0 { - return Err(PinakesError::StorageIntegrity(format!( - "hash changed during migration: {} -> {}", - item.content_hash, new_hash - ))); - } + // Verify the hash matches (it should, unless the file changed) + if new_hash.0 != item.content_hash.0 { + return Err(PinakesError::StorageIntegrity(format!( + "hash changed during migration: {} -> {}", + item.content_hash, new_hash + ))); + } - // Get or create blob record - let mime = item.media_type.mime_type().to_string(); - let _blob = storage - .get_or_create_blob(&new_hash, new_size, &mime) - .await?; + // Get or create blob record + let mime = item.media_type.mime_type().to_string(); + let _blob = storage + .get_or_create_blob(&new_hash, new_size, &mime) + .await?; - // Update the media item - let mut updated = item.clone(); - updated.storage_mode = StorageMode::Managed; - updated.storage_key = Some(new_hash.0.clone()); - updated.uploaded_at = Some(Utc::now()); - updated.path = managed.path(&new_hash); - updated.updated_at = Utc::now(); + // Update the media item + let mut updated = item.clone(); + updated.storage_mode = StorageMode::Managed; + updated.storage_key = Some(new_hash.0.clone()); + updated.uploaded_at = Some(Utc::now()); + updated.path = managed.path(&new_hash); + updated.updated_at = Utc::now(); - storage.update_media(&updated).await?; + storage.update_media(&updated).await?; - info!( - media_id = %media_id, - hash = %new_hash, - "migrated media item to managed storage" - ); + info!( + media_id = %media_id, + hash = %new_hash, + "migrated media item to managed storage" + ); - Ok(()) + Ok(()) } /// Sanitize a filename for storage. fn sanitize_filename(name: &str) -> String { - // Remove path separators and null bytes - name.replace(['/', '\\', '\0'], "_") + // Remove path separators and null bytes + name.replace(['/', '\\', '\0'], "_") // Trim whitespace .trim() // Truncate to reasonable length @@ -225,43 +228,43 @@ fn sanitize_filename(name: &str) -> String { /// Delete a managed media item and clean up the blob if orphaned. pub async fn delete_managed_media( - storage: &DynStorageBackend, - managed: &ManagedStorageService, - media_id: MediaId, + storage: &DynStorageBackend, + managed: &ManagedStorageService, + media_id: MediaId, ) -> Result<()> { - let item = storage.get_media(media_id).await?; + let item = storage.get_media(media_id).await?; - if item.storage_mode != StorageMode::Managed { - return Err(PinakesError::InvalidOperation( - "media item is not in managed storage".into(), - )); - } + if item.storage_mode != StorageMode::Managed { + return Err(PinakesError::InvalidOperation( + "media item is not in managed storage".into(), + )); + } - // Decrement blob reference count - let should_delete = storage.decrement_blob_ref(&item.content_hash).await?; + // Decrement blob reference count + let should_delete = storage.decrement_blob_ref(&item.content_hash).await?; - // Delete the media item - storage.delete_media(media_id).await?; + // Delete the media item + storage.delete_media(media_id).await?; - // If blob is orphaned, delete it from storage - if should_delete { - managed.delete(&item.content_hash).await?; - storage.delete_blob(&item.content_hash).await?; - info!(hash = %item.content_hash, "deleted orphaned blob"); - } + // If blob is orphaned, delete it from storage + if should_delete { + managed.delete(&item.content_hash).await?; + storage.delete_blob(&item.content_hash).await?; + info!(hash = %item.content_hash, "deleted orphaned blob"); + } - Ok(()) + Ok(()) } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn test_sanitize_filename() { - assert_eq!(sanitize_filename("test.txt"), "test.txt"); - assert_eq!(sanitize_filename("path/to/file.txt"), "path_to_file.txt"); - assert_eq!(sanitize_filename(" spaces "), "spaces"); - assert_eq!(sanitize_filename("a".repeat(300).as_str()), "a".repeat(255)); - } + #[test] + fn test_sanitize_filename() { + assert_eq!(sanitize_filename("test.txt"), "test.txt"); + assert_eq!(sanitize_filename("path/to/file.txt"), "path_to_file.txt"); + assert_eq!(sanitize_filename(" spaces "), "spaces"); + assert_eq!(sanitize_filename("a".repeat(300).as_str()), "a".repeat(255)); + } } diff --git a/crates/pinakes-core/src/users.rs b/crates/pinakes-core/src/users.rs index 394250a..5f58aee 100644 --- a/crates/pinakes-core/src/users.rs +++ b/crates/pinakes-core/src/users.rs @@ -1,210 +1,218 @@ //! User management and authentication +use std::collections::HashMap; + use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; use uuid::Uuid; -use crate::config::UserRole; -use crate::error::{PinakesError, Result}; +use crate::{ + config::UserRole, + error::{PinakesError, Result}, +}; /// User ID #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct UserId(pub Uuid); impl UserId { - pub fn new() -> Self { - Self(Uuid::now_v7()) - } + pub fn new() -> Self { + Self(Uuid::now_v7()) + } } impl Default for UserId { - fn default() -> Self { - Self::new() - } + fn default() -> Self { + Self::new() + } } impl std::fmt::Display for UserId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } } impl From for UserId { - fn from(id: Uuid) -> Self { - Self(id) - } + fn from(id: Uuid) -> Self { + Self(id) + } } /// User account with profile information #[derive(Debug, Clone, Serialize, Deserialize)] pub struct User { - pub id: UserId, - pub username: String, - #[serde(skip_serializing)] - pub password_hash: String, - pub role: UserRole, - pub profile: UserProfile, - pub created_at: DateTime, - pub updated_at: DateTime, + pub id: UserId, + pub username: String, + #[serde(skip_serializing)] + pub password_hash: String, + pub role: UserRole, + pub profile: UserProfile, + pub created_at: DateTime, + pub updated_at: DateTime, } /// User profile information #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct UserProfile { - pub avatar_path: Option, - pub bio: Option, - pub preferences: UserPreferences, + pub avatar_path: Option, + pub bio: Option, + pub preferences: UserPreferences, } /// User-specific preferences #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct UserPreferences { - /// UI theme preference - pub theme: Option, + /// UI theme preference + pub theme: Option, - /// Language preference - pub language: Option, + /// Language preference + pub language: Option, - /// Default video quality preference for transcoding - pub default_video_quality: Option, + /// Default video quality preference for transcoding + pub default_video_quality: Option, - /// Whether to auto-play media - pub auto_play: bool, + /// Whether to auto-play media + pub auto_play: bool, - /// Custom preferences (extensible) - pub custom: HashMap, + /// Custom preferences (extensible) + pub custom: HashMap, } /// Library access permission #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum LibraryPermission { - /// Can only read/view media - Read, + /// Can only read/view media + Read, - /// Can read and modify media metadata - Write, + /// Can read and modify media metadata + Write, - /// Full control including deletion and sharing - Admin, + /// Full control including deletion and sharing + Admin, } impl LibraryPermission { - pub fn can_read(&self) -> bool { - true - } + pub fn can_read(&self) -> bool { + true + } - pub fn can_write(&self) -> bool { - matches!(self, Self::Write | Self::Admin) - } + pub fn can_write(&self) -> bool { + matches!(self, Self::Write | Self::Admin) + } - pub fn can_admin(&self) -> bool { - matches!(self, Self::Admin) - } + pub fn can_admin(&self) -> bool { + matches!(self, Self::Admin) + } } /// User's access to a specific library root #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UserLibraryAccess { - pub user_id: UserId, - pub root_path: String, - pub permission: LibraryPermission, - pub granted_at: DateTime, + pub user_id: UserId, + pub root_path: String, + pub permission: LibraryPermission, + pub granted_at: DateTime, } /// User creation request #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateUserRequest { - pub username: String, - #[serde(skip_serializing)] - pub password: String, - pub role: UserRole, - pub profile: Option, + pub username: String, + #[serde(skip_serializing)] + pub password: String, + pub role: UserRole, + pub profile: Option, } /// User update request #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdateUserRequest { - #[serde(skip_serializing)] - pub password: Option, - pub role: Option, - pub profile: Option, + #[serde(skip_serializing)] + pub password: Option, + pub role: Option, + pub profile: Option, } /// User authentication pub mod auth { - use super::*; + use super::*; - /// Hash a password using Argon2 - pub fn hash_password(password: &str) -> Result { - use argon2::{ - Argon2, - password_hash::{PasswordHasher, SaltString, rand_core::OsRng}, - }; + /// Hash a password using Argon2 + pub fn hash_password(password: &str) -> Result { + use argon2::{ + Argon2, + password_hash::{PasswordHasher, SaltString, rand_core::OsRng}, + }; - let salt = SaltString::generate(&mut OsRng); - let argon2 = Argon2::default(); + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); - argon2 - .hash_password(password.as_bytes(), &salt) - .map(|hash| hash.to_string()) - .map_err(|e| PinakesError::Authentication(format!("failed to hash password: {e}"))) - } + argon2 + .hash_password(password.as_bytes(), &salt) + .map(|hash| hash.to_string()) + .map_err(|e| { + PinakesError::Authentication(format!("failed to hash password: {e}")) + }) + } - /// Verify a password against a hash - pub fn verify_password(password: &str, hash: &str) -> Result { - use argon2::{ - Argon2, - password_hash::{PasswordHash, PasswordVerifier}, - }; + /// Verify a password against a hash + pub fn verify_password(password: &str, hash: &str) -> Result { + use argon2::{ + Argon2, + password_hash::{PasswordHash, PasswordVerifier}, + }; - let parsed_hash = PasswordHash::new(hash) - .map_err(|e| PinakesError::Authentication(format!("invalid password hash: {e}")))?; + let parsed_hash = PasswordHash::new(hash).map_err(|e| { + PinakesError::Authentication(format!("invalid password hash: {e}")) + })?; - Ok(Argon2::default() - .verify_password(password.as_bytes(), &parsed_hash) - .is_ok()) - } + Ok( + Argon2::default() + .verify_password(password.as_bytes(), &parsed_hash) + .is_ok(), + ) + } } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn test_hash_and_verify_password() { - let password = "test_password_123"; - let hash = auth::hash_password(password).unwrap(); + #[test] + fn test_hash_and_verify_password() { + let password = "test_password_123"; + let hash = auth::hash_password(password).unwrap(); - assert!(auth::verify_password(password, &hash).unwrap()); - assert!(!auth::verify_password("wrong_password", &hash).unwrap()); - } + assert!(auth::verify_password(password, &hash).unwrap()); + assert!(!auth::verify_password("wrong_password", &hash).unwrap()); + } - #[test] - fn test_user_preferences_default() { - let prefs = UserPreferences::default(); - assert_eq!(prefs.theme, None); - assert_eq!(prefs.language, None); - assert!(!prefs.auto_play); - assert!(prefs.custom.is_empty()); - } + #[test] + fn test_user_preferences_default() { + let prefs = UserPreferences::default(); + assert_eq!(prefs.theme, None); + assert_eq!(prefs.language, None); + assert!(!prefs.auto_play); + assert!(prefs.custom.is_empty()); + } - #[test] - fn test_library_permission_levels() { - let read = LibraryPermission::Read; - assert!(read.can_read()); - assert!(!read.can_write()); - assert!(!read.can_admin()); + #[test] + fn test_library_permission_levels() { + let read = LibraryPermission::Read; + assert!(read.can_read()); + assert!(!read.can_write()); + assert!(!read.can_admin()); - let write = LibraryPermission::Write; - assert!(write.can_read()); - assert!(write.can_write()); - assert!(!write.can_admin()); + let write = LibraryPermission::Write; + assert!(write.can_read()); + assert!(write.can_write()); + assert!(!write.can_admin()); - let admin = LibraryPermission::Admin; - assert!(admin.can_read()); - assert!(admin.can_write()); - assert!(admin.can_admin()); - } + let admin = LibraryPermission::Admin; + assert!(admin.can_read()); + assert!(admin.can_write()); + assert!(admin.can_admin()); + } } diff --git a/crates/pinakes-core/tests/book_metadata.rs b/crates/pinakes-core/tests/book_metadata.rs index 5cd7199..763b750 100644 --- a/crates/pinakes-core/tests/book_metadata.rs +++ b/crates/pinakes-core/tests/book_metadata.rs @@ -1,109 +1,113 @@ -use pinakes_core::books::{extract_isbn_from_text, normalize_isbn, parse_author_file_as}; -use pinakes_core::enrichment::books::BookEnricher; -use pinakes_core::enrichment::googlebooks::GoogleBooksClient; -use pinakes_core::enrichment::openlibrary::OpenLibraryClient; -use pinakes_core::thumbnail::{CoverSize, extract_epub_cover, generate_book_covers}; +use pinakes_core::{ + books::{extract_isbn_from_text, normalize_isbn, parse_author_file_as}, + enrichment::{ + books::BookEnricher, + googlebooks::GoogleBooksClient, + openlibrary::OpenLibraryClient, + }, + thumbnail::{CoverSize, extract_epub_cover, generate_book_covers}, +}; #[test] fn test_isbn_normalization() { - // Valid ISBN-10 to ISBN-13 conversion (The Hobbit) - let result = normalize_isbn("0547928220"); - assert!(result.is_ok()); - let isbn13 = result.unwrap(); - assert_eq!(isbn13.len(), 13); - assert!(isbn13.starts_with("978")); + // Valid ISBN-10 to ISBN-13 conversion (The Hobbit) + let result = normalize_isbn("0547928220"); + assert!(result.is_ok()); + let isbn13 = result.unwrap(); + assert_eq!(isbn13.len(), 13); + assert!(isbn13.starts_with("978")); - // Valid ISBN-13 should return itself - let result = normalize_isbn("9780547928227"); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), "9780547928227"); + // Valid ISBN-13 should return itself + let result = normalize_isbn("9780547928227"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "9780547928227"); - // ISBN with hyphens should be normalized - let result = normalize_isbn("978-0-547-92822-7"); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), "9780547928227"); + // ISBN with hyphens should be normalized + let result = normalize_isbn("978-0-547-92822-7"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "9780547928227"); - // Invalid ISBN - let result = normalize_isbn("invalid"); - assert!(result.is_err()); + // Invalid ISBN + let result = normalize_isbn("invalid"); + assert!(result.is_err()); } #[test] fn test_isbn_extraction_from_text() { - let text = "This book has ISBN-13: 978-0-123-45678-9 in the middle."; - let result = extract_isbn_from_text(text); - assert!(result.is_some()); - let isbn = result.unwrap(); - assert!(isbn.contains("978")); + let text = "This book has ISBN-13: 978-0-123-45678-9 in the middle."; + let result = extract_isbn_from_text(text); + assert!(result.is_some()); + let isbn = result.unwrap(); + assert!(isbn.contains("978")); - let text_isbn10 = "Old format ISBN: 0-123-45678-9"; - let result = extract_isbn_from_text(text_isbn10); - assert!(result.is_some()); + let text_isbn10 = "Old format ISBN: 0-123-45678-9"; + let result = extract_isbn_from_text(text_isbn10); + assert!(result.is_some()); - let text_no_isbn = "This text has no ISBN at all."; - let result = extract_isbn_from_text(text_no_isbn); - assert!(result.is_none()); + let text_no_isbn = "This text has no ISBN at all."; + let result = extract_isbn_from_text(text_no_isbn); + assert!(result.is_none()); } #[test] fn test_author_file_as_parsing() { - // Standard name: "First Last" -> "Last, First" - let result = parse_author_file_as("John Smith"); - assert_eq!(result, "Smith, John"); + // Standard name: "First Last" -> "Last, First" + let result = parse_author_file_as("John Smith"); + assert_eq!(result, "Smith, John"); - // Single name - let result = parse_author_file_as("Shakespeare"); - assert_eq!(result, "Shakespeare"); + // Single name + let result = parse_author_file_as("Shakespeare"); + assert_eq!(result, "Shakespeare"); - // Multiple middle names - let result = parse_author_file_as("John Ronald Reuel Tolkien"); - assert_eq!(result, "Tolkien, John Ronald Reuel"); + // Multiple middle names + let result = parse_author_file_as("John Ronald Reuel Tolkien"); + assert_eq!(result, "Tolkien, John Ronald Reuel"); - // Already in "Last, First" format - let result = parse_author_file_as("Tolkien, J.R.R."); - assert_eq!(result, "Tolkien, J.R.R."); + // Already in "Last, First" format + let result = parse_author_file_as("Tolkien, J.R.R."); + assert_eq!(result, "Tolkien, J.R.R."); } #[test] fn test_book_enricher_creation() { - let enricher = BookEnricher::new(None); - // Just verify it can be created - drop(enricher); + let enricher = BookEnricher::new(None); + // Just verify it can be created + drop(enricher); - let enricher_with_key = BookEnricher::new(Some("test-api-key".to_string())); - drop(enricher_with_key); + let enricher_with_key = BookEnricher::new(Some("test-api-key".to_string())); + drop(enricher_with_key); } #[test] fn test_openlibrary_client_creation() { - let client = OpenLibraryClient::new(); - // Verify client is created successfully - drop(client); + let client = OpenLibraryClient::new(); + // Verify client is created successfully + drop(client); } #[test] fn test_googlebooks_client_creation() { - let client = GoogleBooksClient::new(None); - drop(client); + let client = GoogleBooksClient::new(None); + drop(client); - let client_with_key = GoogleBooksClient::new(Some("test-key".to_string())); - drop(client_with_key); + let client_with_key = GoogleBooksClient::new(Some("test-key".to_string())); + drop(client_with_key); } #[test] fn test_cover_size_dimensions() { - assert_eq!(CoverSize::Tiny.dimensions(), Some((64, 64))); - assert_eq!(CoverSize::Grid.dimensions(), Some((320, 320))); - assert_eq!(CoverSize::Preview.dimensions(), Some((1024, 1024))); - assert_eq!(CoverSize::Original.dimensions(), None); + assert_eq!(CoverSize::Tiny.dimensions(), Some((64, 64))); + assert_eq!(CoverSize::Grid.dimensions(), Some((320, 320))); + assert_eq!(CoverSize::Preview.dimensions(), Some((1024, 1024))); + assert_eq!(CoverSize::Original.dimensions(), None); } #[test] fn test_cover_size_filenames() { - assert_eq!(CoverSize::Tiny.filename(), "tiny.jpg"); - assert_eq!(CoverSize::Grid.filename(), "grid.jpg"); - assert_eq!(CoverSize::Preview.filename(), "preview.jpg"); - assert_eq!(CoverSize::Original.filename(), "original.jpg"); + assert_eq!(CoverSize::Tiny.filename(), "tiny.jpg"); + assert_eq!(CoverSize::Grid.filename(), "grid.jpg"); + assert_eq!(CoverSize::Preview.filename(), "preview.jpg"); + assert_eq!(CoverSize::Original.filename(), "original.jpg"); } // Note: The following tests would require actual EPUB files and network access, @@ -112,92 +116,93 @@ fn test_cover_size_filenames() { #[test] #[ignore] fn test_epub_cover_extraction() { - // This test requires a real EPUB file - // Create a test EPUB file path - let epub_path = std::path::PathBuf::from("test_fixtures/sample.epub"); + // This test requires a real EPUB file + // Create a test EPUB file path + let epub_path = std::path::PathBuf::from("test_fixtures/sample.epub"); - if !epub_path.exists() { - // Skip if test fixture doesn't exist - return; - } + if !epub_path.exists() { + // Skip if test fixture doesn't exist + return; + } - let result = extract_epub_cover(&epub_path); - // Should either succeed with Some(data) or None if no cover found - assert!(result.is_ok()); + let result = extract_epub_cover(&epub_path); + // Should either succeed with Some(data) or None if no cover found + assert!(result.is_ok()); } #[test] #[ignore] fn test_book_cover_generation() { - // This test requires a sample image - use tempfile::tempdir; + // This test requires a sample image + use tempfile::tempdir; - // Create a minimal 100x100 red PNG in memory - let mut img_data = Vec::new(); - { - use image::{ImageBuffer, Rgb}; - let img: ImageBuffer, Vec> = - ImageBuffer::from_fn(100, 100, |_, _| Rgb([255u8, 0u8, 0u8])); - img.write_to( - &mut std::io::Cursor::new(&mut img_data), - image::ImageFormat::Png, - ) - .unwrap(); - } + // Create a minimal 100x100 red PNG in memory + let mut img_data = Vec::new(); + { + use image::{ImageBuffer, Rgb}; + let img: ImageBuffer, Vec> = + ImageBuffer::from_fn(100, 100, |_, _| Rgb([255u8, 0u8, 0u8])); + img + .write_to( + &mut std::io::Cursor::new(&mut img_data), + image::ImageFormat::Png, + ) + .unwrap(); + } - let temp_dir = tempdir().unwrap(); - let media_id = pinakes_core::model::MediaId::new(); + let temp_dir = tempdir().unwrap(); + let media_id = pinakes_core::model::MediaId::new(); - let result = generate_book_covers(media_id, &img_data, temp_dir.path()); - assert!(result.is_ok()); + let result = generate_book_covers(media_id, &img_data, temp_dir.path()); + assert!(result.is_ok()); - let covers = result.unwrap(); - assert_eq!(covers.len(), 4); // tiny, grid, preview, original + let covers = result.unwrap(); + assert_eq!(covers.len(), 4); // tiny, grid, preview, original - // Verify all cover files exist - for (size, path) in &covers { - assert!(path.exists(), "Cover {:?} should exist at {:?}", size, path); - } + // Verify all cover files exist + for (size, path) in &covers { + assert!(path.exists(), "Cover {:?} should exist at {:?}", size, path); + } } #[tokio::test] #[ignore] async fn test_openlibrary_isbn_fetch() { - // This test requires network access - let client = OpenLibraryClient::new(); + // This test requires network access + let client = OpenLibraryClient::new(); - // Use a known ISBN for "The Hobbit" - let result = client.fetch_by_isbn("9780547928227").await; + // Use a known ISBN for "The Hobbit" + let result = client.fetch_by_isbn("9780547928227").await; - // Should either succeed or fail gracefully - // We don't assert success because network might not be available - match result { - Ok(book) => { - assert!(book.title.is_some()); - } - Err(_) => { - // Network error or book not found - acceptable in tests - } - } + // Should either succeed or fail gracefully + // We don't assert success because network might not be available + match result { + Ok(book) => { + assert!(book.title.is_some()); + }, + Err(_) => { + // Network error or book not found - acceptable in tests + }, + } } #[tokio::test] #[ignore] async fn test_googlebooks_isbn_fetch() { - // This test requires network access - let client = GoogleBooksClient::new(None); + // This test requires network access + let client = GoogleBooksClient::new(None); - // Use a known ISBN - let result = client.fetch_by_isbn("9780547928227").await; + // Use a known ISBN + let result = client.fetch_by_isbn("9780547928227").await; - match result { - Ok(books) => { - if !books.is_empty() { - assert!(books[0].volume_info.title.is_some()); - } - } - Err(_) => { - // Network error - acceptable in tests - } - } + match result { + Ok(books) => { + if !books.is_empty() { + assert!(books[0].volume_info.title.is_some()); + } + }, + Err(_) => { + // Network error - acceptable in tests + }, + } } diff --git a/crates/pinakes-core/tests/common/mod.rs b/crates/pinakes-core/tests/common/mod.rs index be07c26..47cf0c7 100644 --- a/crates/pinakes-core/tests/common/mod.rs +++ b/crates/pinakes-core/tests/common/mod.rs @@ -1,137 +1,137 @@ -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Arc; +use std::{collections::HashMap, path::PathBuf, sync::Arc}; -use pinakes_core::media_type::{BuiltinMediaType, MediaType}; -use pinakes_core::model::{ContentHash, MediaId, MediaItem, StorageMode}; -use pinakes_core::storage::{DynStorageBackend, StorageBackend, sqlite::SqliteBackend}; +use pinakes_core::{ + media_type::{BuiltinMediaType, MediaType}, + model::{ContentHash, MediaId, MediaItem, StorageMode}, + storage::{DynStorageBackend, StorageBackend, sqlite::SqliteBackend}, +}; use tempfile::TempDir; use uuid::Uuid; pub async fn setup() -> Arc { - let backend = SqliteBackend::in_memory().expect("in-memory SQLite"); - backend.run_migrations().await.expect("migrations"); - Arc::new(backend) + let backend = SqliteBackend::in_memory().expect("in-memory SQLite"); + backend.run_migrations().await.expect("migrations"); + Arc::new(backend) } pub async fn setup_test_storage() -> (DynStorageBackend, TempDir) { - let temp_dir = TempDir::new().unwrap(); - let db_path = temp_dir.path().join(format!("test_{}.db", Uuid::now_v7())); + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join(format!("test_{}.db", Uuid::now_v7())); - let storage = SqliteBackend::new(&db_path).unwrap(); - storage.run_migrations().await.unwrap(); + let storage = SqliteBackend::new(&db_path).unwrap(); + storage.run_migrations().await.unwrap(); - (Arc::new(storage), temp_dir) + (Arc::new(storage), temp_dir) } pub fn make_test_media(hash: &str) -> MediaItem { - let now = chrono::Utc::now(); - MediaItem { - id: MediaId::new(), - path: format!("/tmp/test_{hash}.mp4").into(), - file_name: format!("test_{hash}.mp4"), - media_type: MediaType::Builtin(BuiltinMediaType::Mp4), - content_hash: ContentHash::new(hash.to_string()), - file_size: 1000, - title: Some(format!("Test {hash}")), - artist: Some("Test Artist".to_string()), - album: None, - genre: None, - year: Some(2024), - duration_secs: Some(120.0), - description: None, - thumbnail_path: None, - custom_fields: HashMap::new(), - file_mtime: None, - date_taken: None, - latitude: None, - longitude: None, - camera_make: None, - camera_model: None, - rating: None, - perceptual_hash: None, - storage_mode: StorageMode::External, - original_filename: None, - uploaded_at: None, - storage_key: None, - created_at: now, - updated_at: now, - deleted_at: None, - links_extracted_at: None, - } + let now = chrono::Utc::now(); + MediaItem { + id: MediaId::new(), + path: format!("/tmp/test_{hash}.mp4").into(), + file_name: format!("test_{hash}.mp4"), + media_type: MediaType::Builtin(BuiltinMediaType::Mp4), + content_hash: ContentHash::new(hash.to_string()), + file_size: 1000, + title: Some(format!("Test {hash}")), + artist: Some("Test Artist".to_string()), + album: None, + genre: None, + year: Some(2024), + duration_secs: Some(120.0), + description: None, + thumbnail_path: None, + custom_fields: HashMap::new(), + file_mtime: None, + date_taken: None, + latitude: None, + longitude: None, + camera_make: None, + camera_model: None, + rating: None, + perceptual_hash: None, + storage_mode: StorageMode::External, + original_filename: None, + uploaded_at: None, + storage_key: None, + created_at: now, + updated_at: now, + deleted_at: None, + links_extracted_at: None, + } } pub fn create_test_media_item(path: PathBuf, hash: &str) -> MediaItem { - let now = chrono::Utc::now(); - MediaItem { - id: MediaId(Uuid::now_v7()), - path, - file_name: "test.mp3".to_string(), - media_type: MediaType::Builtin(BuiltinMediaType::Mp3), - content_hash: ContentHash(hash.to_string()), - file_size: 1000, - title: None, - artist: None, - album: None, - genre: None, - year: None, - duration_secs: None, - description: None, - thumbnail_path: None, - custom_fields: HashMap::new(), - file_mtime: None, - date_taken: None, - latitude: None, - longitude: None, - camera_make: None, - camera_model: None, - rating: None, - perceptual_hash: None, - storage_mode: StorageMode::External, - original_filename: None, - uploaded_at: None, - storage_key: None, - created_at: now, - updated_at: now, - deleted_at: None, - links_extracted_at: None, - } + let now = chrono::Utc::now(); + MediaItem { + id: MediaId(Uuid::now_v7()), + path, + file_name: "test.mp3".to_string(), + media_type: MediaType::Builtin(BuiltinMediaType::Mp3), + content_hash: ContentHash(hash.to_string()), + file_size: 1000, + title: None, + artist: None, + album: None, + genre: None, + year: None, + duration_secs: None, + description: None, + thumbnail_path: None, + custom_fields: HashMap::new(), + file_mtime: None, + date_taken: None, + latitude: None, + longitude: None, + camera_make: None, + camera_model: None, + rating: None, + perceptual_hash: None, + storage_mode: StorageMode::External, + original_filename: None, + uploaded_at: None, + storage_key: None, + created_at: now, + updated_at: now, + deleted_at: None, + links_extracted_at: None, + } } /// Create a test markdown media item with a given ID pub fn make_test_markdown_item(id: MediaId) -> MediaItem { - let now = chrono::Utc::now(); - MediaItem { - id, - path: format!("/tmp/test_{}.md", id.0).into(), - file_name: format!("test_{}.md", id.0), - media_type: MediaType::Builtin(BuiltinMediaType::Markdown), - content_hash: ContentHash::new(format!("hash_{}", id.0)), - file_size: 1024, - title: Some("Test Note".to_string()), - artist: None, - album: None, - genre: None, - year: None, - duration_secs: None, - description: Some("Test markdown note".to_string()), - thumbnail_path: None, - custom_fields: HashMap::new(), - file_mtime: None, - date_taken: None, - latitude: None, - longitude: None, - camera_make: None, - camera_model: None, - rating: None, - perceptual_hash: None, - storage_mode: StorageMode::External, - original_filename: None, - uploaded_at: None, - storage_key: None, - created_at: now, - updated_at: now, - deleted_at: None, - links_extracted_at: None, - } + let now = chrono::Utc::now(); + MediaItem { + id, + path: format!("/tmp/test_{}.md", id.0).into(), + file_name: format!("test_{}.md", id.0), + media_type: MediaType::Builtin(BuiltinMediaType::Markdown), + content_hash: ContentHash::new(format!("hash_{}", id.0)), + file_size: 1024, + title: Some("Test Note".to_string()), + artist: None, + album: None, + genre: None, + year: None, + duration_secs: None, + description: Some("Test markdown note".to_string()), + thumbnail_path: None, + custom_fields: HashMap::new(), + file_mtime: None, + date_taken: None, + latitude: None, + longitude: None, + camera_make: None, + camera_model: None, + rating: None, + perceptual_hash: None, + storage_mode: StorageMode::External, + original_filename: None, + uploaded_at: None, + storage_key: None, + created_at: now, + updated_at: now, + deleted_at: None, + links_extracted_at: None, + } } diff --git a/crates/pinakes-core/tests/integration.rs b/crates/pinakes-core/tests/integration.rs index d24b935..ee9632a 100644 --- a/crates/pinakes-core/tests/integration.rs +++ b/crates/pinakes-core/tests/integration.rs @@ -1,926 +1,932 @@ -use std::collections::HashMap; -use std::sync::Arc; +use std::{collections::HashMap, sync::Arc}; -use pinakes_core::model::*; -use pinakes_core::storage::StorageBackend; -use pinakes_core::storage::sqlite::SqliteBackend; +use pinakes_core::{ + model::*, + storage::{StorageBackend, sqlite::SqliteBackend}, +}; mod common; use common::{make_test_media, setup}; #[tokio::test] async fn test_media_crud() { - let storage = setup().await; + let storage = setup().await; - let now = chrono::Utc::now(); - let id = MediaId::new(); - let item = MediaItem { - id, - path: "/tmp/test.txt".into(), - file_name: "test.txt".to_string(), - media_type: pinakes_core::media_type::MediaType::Builtin( - pinakes_core::media_type::BuiltinMediaType::PlainText, - ), - content_hash: ContentHash::new("abc123".to_string()), - file_size: 100, - title: Some("Test Title".to_string()), - artist: None, - album: None, - genre: None, - year: Some(2024), - duration_secs: None, - description: Some("A test file".to_string()), - thumbnail_path: None, - custom_fields: HashMap::new(), - file_mtime: None, - date_taken: None, - latitude: None, - longitude: None, - camera_make: None, - camera_model: None, - rating: None, - perceptual_hash: None, - storage_mode: StorageMode::External, - original_filename: None, - uploaded_at: None, - storage_key: None, - created_at: now, - updated_at: now, - deleted_at: None, - links_extracted_at: None, - }; + let now = chrono::Utc::now(); + let id = MediaId::new(); + let item = MediaItem { + id, + path: "/tmp/test.txt".into(), + file_name: "test.txt".to_string(), + media_type: pinakes_core::media_type::MediaType::Builtin( + pinakes_core::media_type::BuiltinMediaType::PlainText, + ), + content_hash: ContentHash::new("abc123".to_string()), + file_size: 100, + title: Some("Test Title".to_string()), + artist: None, + album: None, + genre: None, + year: Some(2024), + duration_secs: None, + description: Some("A test file".to_string()), + thumbnail_path: None, + custom_fields: HashMap::new(), + file_mtime: None, + date_taken: None, + latitude: None, + longitude: None, + camera_make: None, + camera_model: None, + rating: None, + perceptual_hash: None, + storage_mode: StorageMode::External, + original_filename: None, + uploaded_at: None, + storage_key: None, + created_at: now, + updated_at: now, + deleted_at: None, + links_extracted_at: None, + }; - // Insert - storage.insert_media(&item).await.unwrap(); + // Insert + storage.insert_media(&item).await.unwrap(); - // Get - let fetched = storage.get_media(id).await.unwrap(); - assert_eq!(fetched.id, id); - assert_eq!(fetched.title.as_deref(), Some("Test Title")); - assert_eq!(fetched.file_size, 100); + // Get + let fetched = storage.get_media(id).await.unwrap(); + assert_eq!(fetched.id, id); + assert_eq!(fetched.title.as_deref(), Some("Test Title")); + assert_eq!(fetched.file_size, 100); - // Get by hash - let by_hash = storage - .get_media_by_hash(&ContentHash::new("abc123".into())) - .await - .unwrap(); - assert!(by_hash.is_some()); - assert_eq!(by_hash.unwrap().id, id); + // Get by hash + let by_hash = storage + .get_media_by_hash(&ContentHash::new("abc123".into())) + .await + .unwrap(); + assert!(by_hash.is_some()); + assert_eq!(by_hash.unwrap().id, id); - // Update - let mut updated = fetched; - updated.title = Some("Updated Title".to_string()); - storage.update_media(&updated).await.unwrap(); - let re_fetched = storage.get_media(id).await.unwrap(); - assert_eq!(re_fetched.title.as_deref(), Some("Updated Title")); + // Update + let mut updated = fetched; + updated.title = Some("Updated Title".to_string()); + storage.update_media(&updated).await.unwrap(); + let re_fetched = storage.get_media(id).await.unwrap(); + assert_eq!(re_fetched.title.as_deref(), Some("Updated Title")); - // List - let list = storage.list_media(&Pagination::default()).await.unwrap(); - assert_eq!(list.len(), 1); + // List + let list = storage.list_media(&Pagination::default()).await.unwrap(); + assert_eq!(list.len(), 1); - // Delete - storage.delete_media(id).await.unwrap(); - let result = storage.get_media(id).await; - assert!(result.is_err()); + // Delete + storage.delete_media(id).await.unwrap(); + let result = storage.get_media(id).await; + assert!(result.is_err()); } #[tokio::test] async fn test_tags() { - let storage = setup().await; + let storage = setup().await; - // Create tags - let parent = storage.create_tag("Music", None).await.unwrap(); - let child = storage.create_tag("Rock", Some(parent.id)).await.unwrap(); + // Create tags + let parent = storage.create_tag("Music", None).await.unwrap(); + let child = storage.create_tag("Rock", Some(parent.id)).await.unwrap(); - assert_eq!(parent.name, "Music"); - assert_eq!(child.parent_id, Some(parent.id)); + assert_eq!(parent.name, "Music"); + assert_eq!(child.parent_id, Some(parent.id)); - // List tags - let tags = storage.list_tags().await.unwrap(); - assert_eq!(tags.len(), 2); + // List tags + let tags = storage.list_tags().await.unwrap(); + assert_eq!(tags.len(), 2); - // Get descendants - let descendants = storage.get_tag_descendants(parent.id).await.unwrap(); - assert!(descendants.iter().any(|t| t.name == "Rock")); + // Get descendants + let descendants = storage.get_tag_descendants(parent.id).await.unwrap(); + assert!(descendants.iter().any(|t| t.name == "Rock")); - // Tag media - let now = chrono::Utc::now(); - let id = MediaId::new(); - let item = MediaItem { - id, - path: "/tmp/song.mp3".into(), - file_name: "song.mp3".to_string(), - media_type: pinakes_core::media_type::MediaType::Builtin( - pinakes_core::media_type::BuiltinMediaType::Mp3, - ), - content_hash: ContentHash::new("hash1".to_string()), - file_size: 5000, - title: Some("Test Song".to_string()), - artist: Some("Test Artist".to_string()), - album: None, - genre: None, - year: None, - duration_secs: Some(180.0), - description: None, - thumbnail_path: None, - custom_fields: HashMap::new(), - file_mtime: None, - date_taken: None, - latitude: None, - longitude: None, - camera_make: None, - camera_model: None, - rating: None, - perceptual_hash: None, - storage_mode: StorageMode::External, - original_filename: None, - uploaded_at: None, - storage_key: None, - created_at: now, - updated_at: now, - deleted_at: None, - links_extracted_at: None, - }; - storage.insert_media(&item).await.unwrap(); - storage.tag_media(id, parent.id).await.unwrap(); + // Tag media + let now = chrono::Utc::now(); + let id = MediaId::new(); + let item = MediaItem { + id, + path: "/tmp/song.mp3".into(), + file_name: "song.mp3".to_string(), + media_type: pinakes_core::media_type::MediaType::Builtin( + pinakes_core::media_type::BuiltinMediaType::Mp3, + ), + content_hash: ContentHash::new("hash1".to_string()), + file_size: 5000, + title: Some("Test Song".to_string()), + artist: Some("Test Artist".to_string()), + album: None, + genre: None, + year: None, + duration_secs: Some(180.0), + description: None, + thumbnail_path: None, + custom_fields: HashMap::new(), + file_mtime: None, + date_taken: None, + latitude: None, + longitude: None, + camera_make: None, + camera_model: None, + rating: None, + perceptual_hash: None, + storage_mode: StorageMode::External, + original_filename: None, + uploaded_at: None, + storage_key: None, + created_at: now, + updated_at: now, + deleted_at: None, + links_extracted_at: None, + }; + storage.insert_media(&item).await.unwrap(); + storage.tag_media(id, parent.id).await.unwrap(); - let media_tags = storage.get_media_tags(id).await.unwrap(); - assert_eq!(media_tags.len(), 1); - assert_eq!(media_tags[0].name, "Music"); + let media_tags = storage.get_media_tags(id).await.unwrap(); + assert_eq!(media_tags.len(), 1); + assert_eq!(media_tags[0].name, "Music"); - // Untag - storage.untag_media(id, parent.id).await.unwrap(); - let media_tags = storage.get_media_tags(id).await.unwrap(); - assert_eq!(media_tags.len(), 0); + // Untag + storage.untag_media(id, parent.id).await.unwrap(); + let media_tags = storage.get_media_tags(id).await.unwrap(); + assert_eq!(media_tags.len(), 0); - // Delete tag - storage.delete_tag(child.id).await.unwrap(); - let tags = storage.list_tags().await.unwrap(); - assert_eq!(tags.len(), 1); + // Delete tag + storage.delete_tag(child.id).await.unwrap(); + let tags = storage.list_tags().await.unwrap(); + assert_eq!(tags.len(), 1); } #[tokio::test] async fn test_collections() { - let storage = setup().await; + let storage = setup().await; - let col = storage - .create_collection("Favorites", CollectionKind::Manual, Some("My faves"), None) - .await - .unwrap(); - assert_eq!(col.name, "Favorites"); - assert_eq!(col.kind, CollectionKind::Manual); + let col = storage + .create_collection( + "Favorites", + CollectionKind::Manual, + Some("My faves"), + None, + ) + .await + .unwrap(); + assert_eq!(col.name, "Favorites"); + assert_eq!(col.kind, CollectionKind::Manual); - let now = chrono::Utc::now(); - let id = MediaId::new(); - let item = MediaItem { - id, - path: "/tmp/doc.pdf".into(), - file_name: "doc.pdf".to_string(), - media_type: pinakes_core::media_type::MediaType::Builtin( - pinakes_core::media_type::BuiltinMediaType::Pdf, - ), - content_hash: ContentHash::new("pdfhash".to_string()), - file_size: 10000, - title: None, - artist: None, - album: None, - genre: None, - year: None, - duration_secs: None, - description: None, - thumbnail_path: None, - custom_fields: HashMap::new(), - file_mtime: None, - date_taken: None, - latitude: None, - longitude: None, - camera_make: None, - camera_model: None, - rating: None, - perceptual_hash: None, - storage_mode: StorageMode::External, - original_filename: None, - uploaded_at: None, - storage_key: None, - created_at: now, - updated_at: now, - deleted_at: None, - links_extracted_at: None, - }; - storage.insert_media(&item).await.unwrap(); + let now = chrono::Utc::now(); + let id = MediaId::new(); + let item = MediaItem { + id, + path: "/tmp/doc.pdf".into(), + file_name: "doc.pdf".to_string(), + media_type: pinakes_core::media_type::MediaType::Builtin( + pinakes_core::media_type::BuiltinMediaType::Pdf, + ), + content_hash: ContentHash::new("pdfhash".to_string()), + file_size: 10000, + title: None, + artist: None, + album: None, + genre: None, + year: None, + duration_secs: None, + description: None, + thumbnail_path: None, + custom_fields: HashMap::new(), + file_mtime: None, + date_taken: None, + latitude: None, + longitude: None, + camera_make: None, + camera_model: None, + rating: None, + perceptual_hash: None, + storage_mode: StorageMode::External, + original_filename: None, + uploaded_at: None, + storage_key: None, + created_at: now, + updated_at: now, + deleted_at: None, + links_extracted_at: None, + }; + storage.insert_media(&item).await.unwrap(); - storage.add_to_collection(col.id, id, 0).await.unwrap(); - let members = storage.get_collection_members(col.id).await.unwrap(); - assert_eq!(members.len(), 1); - assert_eq!(members[0].id, id); + storage.add_to_collection(col.id, id, 0).await.unwrap(); + let members = storage.get_collection_members(col.id).await.unwrap(); + assert_eq!(members.len(), 1); + assert_eq!(members[0].id, id); - storage.remove_from_collection(col.id, id).await.unwrap(); - let members = storage.get_collection_members(col.id).await.unwrap(); - assert_eq!(members.len(), 0); + storage.remove_from_collection(col.id, id).await.unwrap(); + let members = storage.get_collection_members(col.id).await.unwrap(); + assert_eq!(members.len(), 0); - // List collections - let cols = storage.list_collections().await.unwrap(); - assert_eq!(cols.len(), 1); + // List collections + let cols = storage.list_collections().await.unwrap(); + assert_eq!(cols.len(), 1); - storage.delete_collection(col.id).await.unwrap(); - let cols = storage.list_collections().await.unwrap(); - assert_eq!(cols.len(), 0); + storage.delete_collection(col.id).await.unwrap(); + let cols = storage.list_collections().await.unwrap(); + assert_eq!(cols.len(), 0); } #[tokio::test] async fn test_custom_fields() { - let storage = setup().await; + let storage = setup().await; - let now = chrono::Utc::now(); - let id = MediaId::new(); - let item = MediaItem { - id, - path: "/tmp/test.md".into(), - file_name: "test.md".to_string(), - media_type: pinakes_core::media_type::MediaType::Builtin( - pinakes_core::media_type::BuiltinMediaType::Markdown, - ), - content_hash: ContentHash::new("mdhash".to_string()), - file_size: 500, - title: None, - artist: None, - album: None, - genre: None, - year: None, - duration_secs: None, - description: None, - thumbnail_path: None, - custom_fields: HashMap::new(), - file_mtime: None, - date_taken: None, - latitude: None, - longitude: None, - camera_make: None, - camera_model: None, - rating: None, - perceptual_hash: None, - storage_mode: StorageMode::External, - original_filename: None, - uploaded_at: None, - storage_key: None, - created_at: now, - updated_at: now, - deleted_at: None, - links_extracted_at: None, - }; - storage.insert_media(&item).await.unwrap(); + let now = chrono::Utc::now(); + let id = MediaId::new(); + let item = MediaItem { + id, + path: "/tmp/test.md".into(), + file_name: "test.md".to_string(), + media_type: pinakes_core::media_type::MediaType::Builtin( + pinakes_core::media_type::BuiltinMediaType::Markdown, + ), + content_hash: ContentHash::new("mdhash".to_string()), + file_size: 500, + title: None, + artist: None, + album: None, + genre: None, + year: None, + duration_secs: None, + description: None, + thumbnail_path: None, + custom_fields: HashMap::new(), + file_mtime: None, + date_taken: None, + latitude: None, + longitude: None, + camera_make: None, + camera_model: None, + rating: None, + perceptual_hash: None, + storage_mode: StorageMode::External, + original_filename: None, + uploaded_at: None, + storage_key: None, + created_at: now, + updated_at: now, + deleted_at: None, + links_extracted_at: None, + }; + storage.insert_media(&item).await.unwrap(); - // Set custom field - let field = CustomField { - field_type: CustomFieldType::Text, - value: "important".to_string(), - }; - storage - .set_custom_field(id, "priority", &field) - .await - .unwrap(); + // Set custom field + let field = CustomField { + field_type: CustomFieldType::Text, + value: "important".to_string(), + }; + storage + .set_custom_field(id, "priority", &field) + .await + .unwrap(); - // Get custom fields - let fields = storage.get_custom_fields(id).await.unwrap(); - assert_eq!(fields.len(), 1); - assert_eq!(fields["priority"].value, "important"); + // Get custom fields + let fields = storage.get_custom_fields(id).await.unwrap(); + assert_eq!(fields.len(), 1); + assert_eq!(fields["priority"].value, "important"); - // Verify custom fields are loaded with get_media - let media = storage.get_media(id).await.unwrap(); - assert_eq!(media.custom_fields.len(), 1); - assert_eq!(media.custom_fields["priority"].value, "important"); + // Verify custom fields are loaded with get_media + let media = storage.get_media(id).await.unwrap(); + assert_eq!(media.custom_fields.len(), 1); + assert_eq!(media.custom_fields["priority"].value, "important"); - // Delete custom field - storage.delete_custom_field(id, "priority").await.unwrap(); - let fields = storage.get_custom_fields(id).await.unwrap(); - assert_eq!(fields.len(), 0); + // Delete custom field + storage.delete_custom_field(id, "priority").await.unwrap(); + let fields = storage.get_custom_fields(id).await.unwrap(); + assert_eq!(fields.len(), 0); } #[tokio::test] async fn test_search() { - let storage = setup().await; + let storage = setup().await; - let now = chrono::Utc::now(); - // Insert a few items - for (i, (name, title, artist)) in [ - ("song1.mp3", "Bohemian Rhapsody", "Queen"), - ("song2.mp3", "Stairway to Heaven", "Led Zeppelin"), - ("doc.pdf", "Rust Programming", ""), - ] - .iter() - .enumerate() - { - let item = MediaItem { - id: MediaId::new(), - path: format!("/tmp/{name}").into(), - file_name: name.to_string(), - media_type: pinakes_core::media_type::MediaType::from_path(std::path::Path::new(name)) - .unwrap(), - content_hash: ContentHash::new(format!("hash{i}")), - file_size: 1000 * (i as u64 + 1), - title: Some(title.to_string()), - artist: if artist.is_empty() { - None - } else { - Some(artist.to_string()) - }, - album: None, - genre: None, - year: None, - duration_secs: None, - description: None, - thumbnail_path: None, - custom_fields: HashMap::new(), - file_mtime: None, - date_taken: None, - latitude: None, - longitude: None, - camera_make: None, - camera_model: None, - rating: None, - perceptual_hash: None, - storage_mode: StorageMode::External, - original_filename: None, - uploaded_at: None, - storage_key: None, - created_at: now, - updated_at: now, - deleted_at: None, - links_extracted_at: None, - }; - storage.insert_media(&item).await.unwrap(); - } - - // Full-text search - let request = pinakes_core::search::SearchRequest { - query: pinakes_core::search::parse_search_query("Bohemian").unwrap(), - sort: pinakes_core::search::SortOrder::Relevance, - pagination: Pagination::new(0, 50, None), + let now = chrono::Utc::now(); + // Insert a few items + for (i, (name, title, artist)) in [ + ("song1.mp3", "Bohemian Rhapsody", "Queen"), + ("song2.mp3", "Stairway to Heaven", "Led Zeppelin"), + ("doc.pdf", "Rust Programming", ""), + ] + .iter() + .enumerate() + { + let item = MediaItem { + id: MediaId::new(), + path: format!("/tmp/{name}").into(), + file_name: name.to_string(), + media_type: pinakes_core::media_type::MediaType::from_path( + std::path::Path::new(name), + ) + .unwrap(), + content_hash: ContentHash::new(format!("hash{i}")), + file_size: 1000 * (i as u64 + 1), + title: Some(title.to_string()), + artist: if artist.is_empty() { + None + } else { + Some(artist.to_string()) + }, + album: None, + genre: None, + year: None, + duration_secs: None, + description: None, + thumbnail_path: None, + custom_fields: HashMap::new(), + file_mtime: None, + date_taken: None, + latitude: None, + longitude: None, + camera_make: None, + camera_model: None, + rating: None, + perceptual_hash: None, + storage_mode: StorageMode::External, + original_filename: None, + uploaded_at: None, + storage_key: None, + created_at: now, + updated_at: now, + deleted_at: None, + links_extracted_at: None, }; - let results = storage.search(&request).await.unwrap(); - assert_eq!(results.total_count, 1); - assert_eq!(results.items[0].title.as_deref(), Some("Bohemian Rhapsody")); + storage.insert_media(&item).await.unwrap(); + } - // Type filter - let request = pinakes_core::search::SearchRequest { - query: pinakes_core::search::parse_search_query("type:pdf").unwrap(), - sort: pinakes_core::search::SortOrder::Relevance, - pagination: Pagination::new(0, 50, None), - }; - let results = storage.search(&request).await.unwrap(); - assert_eq!(results.total_count, 1); - assert_eq!(results.items[0].file_name, "doc.pdf"); + // Full-text search + let request = pinakes_core::search::SearchRequest { + query: pinakes_core::search::parse_search_query("Bohemian").unwrap(), + sort: pinakes_core::search::SortOrder::Relevance, + pagination: Pagination::new(0, 50, None), + }; + let results = storage.search(&request).await.unwrap(); + assert_eq!(results.total_count, 1); + assert_eq!(results.items[0].title.as_deref(), Some("Bohemian Rhapsody")); + + // Type filter + let request = pinakes_core::search::SearchRequest { + query: pinakes_core::search::parse_search_query("type:pdf").unwrap(), + sort: pinakes_core::search::SortOrder::Relevance, + pagination: Pagination::new(0, 50, None), + }; + let results = storage.search(&request).await.unwrap(); + assert_eq!(results.total_count, 1); + assert_eq!(results.items[0].file_name, "doc.pdf"); } #[tokio::test] async fn test_audit_log() { - let storage = setup().await; + let storage = setup().await; - let entry = AuditEntry { - id: uuid::Uuid::now_v7(), - media_id: None, - action: AuditAction::Scanned, - details: Some("test scan".to_string()), - timestamp: chrono::Utc::now(), - }; - storage.record_audit(&entry).await.unwrap(); + let entry = AuditEntry { + id: uuid::Uuid::now_v7(), + media_id: None, + action: AuditAction::Scanned, + details: Some("test scan".to_string()), + timestamp: chrono::Utc::now(), + }; + storage.record_audit(&entry).await.unwrap(); - let entries = storage - .list_audit_entries(None, &Pagination::new(0, 10, None)) - .await - .unwrap(); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].action, AuditAction::Scanned); + let entries = storage + .list_audit_entries(None, &Pagination::new(0, 10, None)) + .await + .unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].action, AuditAction::Scanned); } #[tokio::test] async fn test_import_with_dedup() { - let storage = setup().await as pinakes_core::storage::DynStorageBackend; + let storage = setup().await as pinakes_core::storage::DynStorageBackend; - // Create a temp file - let dir = tempfile::tempdir().unwrap(); - let file_path = dir.path().join("test.txt"); - std::fs::write(&file_path, "hello world").unwrap(); + // Create a temp file + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("test.txt"); + std::fs::write(&file_path, "hello world").unwrap(); - // First import - let result1 = pinakes_core::import::import_file(&storage, &file_path) - .await - .unwrap(); - assert!(!result1.was_duplicate); + // First import + let result1 = pinakes_core::import::import_file(&storage, &file_path) + .await + .unwrap(); + assert!(!result1.was_duplicate); - // Second import of same file - let result2 = pinakes_core::import::import_file(&storage, &file_path) - .await - .unwrap(); - assert!(result2.was_duplicate); - assert_eq!(result1.media_id, result2.media_id); + // Second import of same file + let result2 = pinakes_core::import::import_file(&storage, &file_path) + .await + .unwrap(); + assert!(result2.was_duplicate); + assert_eq!(result1.media_id, result2.media_id); } #[tokio::test] async fn test_root_dirs() { - let storage = setup().await; + let storage = setup().await; - storage.add_root_dir("/tmp/music".into()).await.unwrap(); - storage.add_root_dir("/tmp/docs".into()).await.unwrap(); + storage.add_root_dir("/tmp/music".into()).await.unwrap(); + storage.add_root_dir("/tmp/docs".into()).await.unwrap(); - let dirs = storage.list_root_dirs().await.unwrap(); - assert_eq!(dirs.len(), 2); + let dirs = storage.list_root_dirs().await.unwrap(); + assert_eq!(dirs.len(), 2); - storage - .remove_root_dir(std::path::Path::new("/tmp/music")) - .await - .unwrap(); - let dirs = storage.list_root_dirs().await.unwrap(); - assert_eq!(dirs.len(), 1); - assert_eq!(dirs[0], std::path::PathBuf::from("/tmp/docs")); + storage + .remove_root_dir(std::path::Path::new("/tmp/music")) + .await + .unwrap(); + let dirs = storage.list_root_dirs().await.unwrap(); + assert_eq!(dirs.len(), 1); + assert_eq!(dirs[0], std::path::PathBuf::from("/tmp/docs")); } #[tokio::test] async fn test_library_statistics_empty() { - let storage = setup().await; - let stats = storage.library_statistics().await.unwrap(); - assert_eq!(stats.total_media, 0); - assert_eq!(stats.total_size_bytes, 0); - assert_eq!(stats.avg_file_size_bytes, 0); - assert!(stats.media_by_type.is_empty()); - assert!(stats.storage_by_type.is_empty()); - assert!(stats.top_tags.is_empty()); - assert!(stats.top_collections.is_empty()); - assert!(stats.newest_item.is_none()); - assert!(stats.oldest_item.is_none()); - assert_eq!(stats.total_tags, 0); - assert_eq!(stats.total_collections, 0); - assert_eq!(stats.total_duplicates, 0); + let storage = setup().await; + let stats = storage.library_statistics().await.unwrap(); + assert_eq!(stats.total_media, 0); + assert_eq!(stats.total_size_bytes, 0); + assert_eq!(stats.avg_file_size_bytes, 0); + assert!(stats.media_by_type.is_empty()); + assert!(stats.storage_by_type.is_empty()); + assert!(stats.top_tags.is_empty()); + assert!(stats.top_collections.is_empty()); + assert!(stats.newest_item.is_none()); + assert!(stats.oldest_item.is_none()); + assert_eq!(stats.total_tags, 0); + assert_eq!(stats.total_collections, 0); + assert_eq!(stats.total_duplicates, 0); } #[tokio::test] async fn test_library_statistics_with_data() { - let storage = setup().await; + let storage = setup().await; - let now = chrono::Utc::now(); - let item = MediaItem { - id: MediaId::new(), - path: "/tmp/stats_test.mp3".into(), - file_name: "stats_test.mp3".to_string(), - media_type: pinakes_core::media_type::MediaType::Builtin( - pinakes_core::media_type::BuiltinMediaType::Mp3, - ), - content_hash: ContentHash::new("stats_hash".to_string()), - file_size: 5000, - title: Some("Stats Song".to_string()), - artist: None, - album: None, - genre: None, - year: None, - duration_secs: Some(120.0), - description: None, - thumbnail_path: None, - custom_fields: HashMap::new(), - file_mtime: None, - date_taken: None, - latitude: None, - longitude: None, - camera_make: None, - camera_model: None, - rating: None, - perceptual_hash: None, - storage_mode: StorageMode::External, - original_filename: None, - uploaded_at: None, - storage_key: None, - created_at: now, - updated_at: now, - deleted_at: None, - links_extracted_at: None, - }; - storage.insert_media(&item).await.unwrap(); + let now = chrono::Utc::now(); + let item = MediaItem { + id: MediaId::new(), + path: "/tmp/stats_test.mp3".into(), + file_name: "stats_test.mp3".to_string(), + media_type: pinakes_core::media_type::MediaType::Builtin( + pinakes_core::media_type::BuiltinMediaType::Mp3, + ), + content_hash: ContentHash::new("stats_hash".to_string()), + file_size: 5000, + title: Some("Stats Song".to_string()), + artist: None, + album: None, + genre: None, + year: None, + duration_secs: Some(120.0), + description: None, + thumbnail_path: None, + custom_fields: HashMap::new(), + file_mtime: None, + date_taken: None, + latitude: None, + longitude: None, + camera_make: None, + camera_model: None, + rating: None, + perceptual_hash: None, + storage_mode: StorageMode::External, + original_filename: None, + uploaded_at: None, + storage_key: None, + created_at: now, + updated_at: now, + deleted_at: None, + links_extracted_at: None, + }; + storage.insert_media(&item).await.unwrap(); - let stats = storage.library_statistics().await.unwrap(); - assert_eq!(stats.total_media, 1); - assert_eq!(stats.total_size_bytes, 5000); - assert_eq!(stats.avg_file_size_bytes, 5000); - assert!(!stats.media_by_type.is_empty()); - assert!(stats.newest_item.is_some()); - assert!(stats.oldest_item.is_some()); + let stats = storage.library_statistics().await.unwrap(); + assert_eq!(stats.total_media, 1); + assert_eq!(stats.total_size_bytes, 5000); + assert_eq!(stats.avg_file_size_bytes, 5000); + assert!(!stats.media_by_type.is_empty()); + assert!(stats.newest_item.is_some()); + assert!(stats.oldest_item.is_some()); } -// ===== Phase 2: Media Server Features ===== - +// Media Server Features #[tokio::test] async fn test_ratings_crud() { - let storage = setup().await; - let item = make_test_media("rating1"); - storage.insert_media(&item).await.unwrap(); + let storage = setup().await; + let item = make_test_media("rating1"); + storage.insert_media(&item).await.unwrap(); - let user_id = pinakes_core::users::UserId::new(); + let user_id = pinakes_core::users::UserId::new(); - // Rate media - let rating = storage - .rate_media(user_id, item.id, 4, Some("Great video")) - .await - .unwrap(); - assert_eq!(rating.stars, 4); - assert_eq!(rating.review_text.as_deref(), Some("Great video")); + // Rate media + let rating = storage + .rate_media(user_id, item.id, 4, Some("Great video")) + .await + .unwrap(); + assert_eq!(rating.stars, 4); + assert_eq!(rating.review_text.as_deref(), Some("Great video")); - // Get user's rating - let fetched = storage.get_user_rating(user_id, item.id).await.unwrap(); - assert!(fetched.is_some()); - assert_eq!(fetched.unwrap().stars, 4); + // Get user's rating + let fetched = storage.get_user_rating(user_id, item.id).await.unwrap(); + assert!(fetched.is_some()); + assert_eq!(fetched.unwrap().stars, 4); - // Get media ratings - let ratings = storage.get_media_ratings(item.id).await.unwrap(); - assert_eq!(ratings.len(), 1); + // Get media ratings + let ratings = storage.get_media_ratings(item.id).await.unwrap(); + assert_eq!(ratings.len(), 1); - // Delete rating - storage.delete_rating(rating.id).await.unwrap(); - let empty = storage.get_media_ratings(item.id).await.unwrap(); - assert!(empty.is_empty()); + // Delete rating + storage.delete_rating(rating.id).await.unwrap(); + let empty = storage.get_media_ratings(item.id).await.unwrap(); + assert!(empty.is_empty()); } #[tokio::test] async fn test_comments_crud() { - let storage = setup().await; - let item = make_test_media("comment1"); - storage.insert_media(&item).await.unwrap(); + let storage = setup().await; + let item = make_test_media("comment1"); + storage.insert_media(&item).await.unwrap(); - let user_id = pinakes_core::users::UserId::new(); + let user_id = pinakes_core::users::UserId::new(); - // Add comment - let comment = storage - .add_comment(user_id, item.id, "Nice video!", None) - .await - .unwrap(); - assert_eq!(comment.text, "Nice video!"); - assert!(comment.parent_comment_id.is_none()); + // Add comment + let comment = storage + .add_comment(user_id, item.id, "Nice video!", None) + .await + .unwrap(); + assert_eq!(comment.text, "Nice video!"); + assert!(comment.parent_comment_id.is_none()); - // Add reply - let reply = storage - .add_comment(user_id, item.id, "Thanks!", Some(comment.id)) - .await - .unwrap(); - assert_eq!(reply.parent_comment_id, Some(comment.id)); + // Add reply + let reply = storage + .add_comment(user_id, item.id, "Thanks!", Some(comment.id)) + .await + .unwrap(); + assert_eq!(reply.parent_comment_id, Some(comment.id)); - // List comments - let comments = storage.get_media_comments(item.id).await.unwrap(); - assert_eq!(comments.len(), 2); + // List comments + let comments = storage.get_media_comments(item.id).await.unwrap(); + assert_eq!(comments.len(), 2); - // Delete comment - storage.delete_comment(reply.id).await.unwrap(); - let remaining = storage.get_media_comments(item.id).await.unwrap(); - assert_eq!(remaining.len(), 1); + // Delete comment + storage.delete_comment(reply.id).await.unwrap(); + let remaining = storage.get_media_comments(item.id).await.unwrap(); + assert_eq!(remaining.len(), 1); } #[tokio::test] async fn test_favorites_toggle() { - let storage = setup().await; - let item = make_test_media("fav1"); - storage.insert_media(&item).await.unwrap(); + let storage = setup().await; + let item = make_test_media("fav1"); + storage.insert_media(&item).await.unwrap(); - let user_id = pinakes_core::users::UserId::new(); + let user_id = pinakes_core::users::UserId::new(); - // Not a favorite initially - assert!(!storage.is_favorite(user_id, item.id).await.unwrap()); + // Not a favorite initially + assert!(!storage.is_favorite(user_id, item.id).await.unwrap()); - // Add favorite - storage.add_favorite(user_id, item.id).await.unwrap(); - assert!(storage.is_favorite(user_id, item.id).await.unwrap()); + // Add favorite + storage.add_favorite(user_id, item.id).await.unwrap(); + assert!(storage.is_favorite(user_id, item.id).await.unwrap()); - // List favorites - let favs = storage - .get_user_favorites(user_id, &Pagination::default()) - .await - .unwrap(); - assert_eq!(favs.len(), 1); + // List favorites + let favs = storage + .get_user_favorites(user_id, &Pagination::default()) + .await + .unwrap(); + assert_eq!(favs.len(), 1); - // Remove favorite - storage.remove_favorite(user_id, item.id).await.unwrap(); - assert!(!storage.is_favorite(user_id, item.id).await.unwrap()); + // Remove favorite + storage.remove_favorite(user_id, item.id).await.unwrap(); + assert!(!storage.is_favorite(user_id, item.id).await.unwrap()); } #[tokio::test] async fn test_share_links() { - let storage = setup().await; - let item = make_test_media("share1"); - storage.insert_media(&item).await.unwrap(); + let storage = setup().await; + let item = make_test_media("share1"); + storage.insert_media(&item).await.unwrap(); - let user_id = pinakes_core::users::UserId::new(); - let token = "test_share_token_abc123"; + let user_id = pinakes_core::users::UserId::new(); + let token = "test_share_token_abc123"; - // Create share link - let link = storage - .create_share_link(item.id, user_id, token, None, None) - .await - .unwrap(); - assert_eq!(link.token, token); - assert_eq!(link.view_count, 0); + // Create share link + let link = storage + .create_share_link(item.id, user_id, token, None, None) + .await + .unwrap(); + assert_eq!(link.token, token); + assert_eq!(link.view_count, 0); - // Get share link - let fetched = storage.get_share_link(token).await.unwrap(); - assert_eq!(fetched.media_id, item.id); + // Get share link + let fetched = storage.get_share_link(token).await.unwrap(); + assert_eq!(fetched.media_id, item.id); - // Increment views - storage.increment_share_views(token).await.unwrap(); - let updated = storage.get_share_link(token).await.unwrap(); - assert_eq!(updated.view_count, 1); + // Increment views + storage.increment_share_views(token).await.unwrap(); + let updated = storage.get_share_link(token).await.unwrap(); + assert_eq!(updated.view_count, 1); - // Delete share link - storage.delete_share_link(link.id).await.unwrap(); - let result = storage.get_share_link(token).await; - assert!(result.is_err()); + // Delete share link + storage.delete_share_link(link.id).await.unwrap(); + let result = storage.get_share_link(token).await; + assert!(result.is_err()); } #[tokio::test] async fn test_playlists_crud() { - let storage = setup().await; - let item1 = make_test_media("pl1"); - let item2 = make_test_media("pl2"); - storage.insert_media(&item1).await.unwrap(); - storage.insert_media(&item2).await.unwrap(); + let storage = setup().await; + let item1 = make_test_media("pl1"); + let item2 = make_test_media("pl2"); + storage.insert_media(&item1).await.unwrap(); + storage.insert_media(&item2).await.unwrap(); - let owner = pinakes_core::users::UserId::new(); + let owner = pinakes_core::users::UserId::new(); - // Create playlist - let playlist = storage - .create_playlist( - owner, - "My Playlist", - Some("A test playlist"), - true, - false, - None, - ) - .await - .unwrap(); - assert_eq!(playlist.name, "My Playlist"); - assert!(playlist.is_public); + // Create playlist + let playlist = storage + .create_playlist( + owner, + "My Playlist", + Some("A test playlist"), + true, + false, + None, + ) + .await + .unwrap(); + assert_eq!(playlist.name, "My Playlist"); + assert!(playlist.is_public); - // Get playlist - let fetched = storage.get_playlist(playlist.id).await.unwrap(); - assert_eq!(fetched.name, "My Playlist"); + // Get playlist + let fetched = storage.get_playlist(playlist.id).await.unwrap(); + assert_eq!(fetched.name, "My Playlist"); - // Add items - storage - .add_to_playlist(playlist.id, item1.id, 0) - .await - .unwrap(); - storage - .add_to_playlist(playlist.id, item2.id, 1) - .await - .unwrap(); + // Add items + storage + .add_to_playlist(playlist.id, item1.id, 0) + .await + .unwrap(); + storage + .add_to_playlist(playlist.id, item2.id, 1) + .await + .unwrap(); - // Get playlist items - let items = storage.get_playlist_items(playlist.id).await.unwrap(); - assert_eq!(items.len(), 2); + // Get playlist items + let items = storage.get_playlist_items(playlist.id).await.unwrap(); + assert_eq!(items.len(), 2); - // Reorder - storage - .reorder_playlist(playlist.id, item2.id, 0) - .await - .unwrap(); + // Reorder + storage + .reorder_playlist(playlist.id, item2.id, 0) + .await + .unwrap(); - // Remove item - storage - .remove_from_playlist(playlist.id, item1.id) - .await - .unwrap(); - let items = storage.get_playlist_items(playlist.id).await.unwrap(); - assert_eq!(items.len(), 1); + // Remove item + storage + .remove_from_playlist(playlist.id, item1.id) + .await + .unwrap(); + let items = storage.get_playlist_items(playlist.id).await.unwrap(); + assert_eq!(items.len(), 1); - // Update playlist - let updated = storage - .update_playlist(playlist.id, Some("Renamed"), None, Some(false)) - .await - .unwrap(); - assert_eq!(updated.name, "Renamed"); - assert!(!updated.is_public); + // Update playlist + let updated = storage + .update_playlist(playlist.id, Some("Renamed"), None, Some(false)) + .await + .unwrap(); + assert_eq!(updated.name, "Renamed"); + assert!(!updated.is_public); - // List playlists - let playlists = storage.list_playlists(None).await.unwrap(); - assert!(!playlists.is_empty()); + // List playlists + let playlists = storage.list_playlists(None).await.unwrap(); + assert!(!playlists.is_empty()); - // Delete playlist - storage.delete_playlist(playlist.id).await.unwrap(); - let result = storage.get_playlist(playlist.id).await; - assert!(result.is_err()); + // Delete playlist + storage.delete_playlist(playlist.id).await.unwrap(); + let result = storage.get_playlist(playlist.id).await; + assert!(result.is_err()); } #[tokio::test] async fn test_analytics_usage_events() { - let storage = setup().await; - let item = make_test_media("analytics1"); - storage.insert_media(&item).await.unwrap(); + let storage = setup().await; + let item = make_test_media("analytics1"); + storage.insert_media(&item).await.unwrap(); - let user_id = pinakes_core::users::UserId::new(); + let user_id = pinakes_core::users::UserId::new(); - // Record events - let event = pinakes_core::analytics::UsageEvent { - id: uuid::Uuid::now_v7(), - media_id: Some(item.id), - user_id: Some(user_id), - event_type: pinakes_core::analytics::UsageEventType::View, - timestamp: chrono::Utc::now(), - duration_secs: Some(60.0), - context_json: None, - }; - storage.record_usage_event(&event).await.unwrap(); + // Record events + let event = pinakes_core::analytics::UsageEvent { + id: uuid::Uuid::now_v7(), + media_id: Some(item.id), + user_id: Some(user_id), + event_type: pinakes_core::analytics::UsageEventType::View, + timestamp: chrono::Utc::now(), + duration_secs: Some(60.0), + context_json: None, + }; + storage.record_usage_event(&event).await.unwrap(); - // Get usage events - let events = storage - .get_usage_events(Some(item.id), None, 10) - .await - .unwrap(); - assert_eq!(events.len(), 1); - assert_eq!( - events[0].event_type, - pinakes_core::analytics::UsageEventType::View - ); + // Get usage events + let events = storage + .get_usage_events(Some(item.id), None, 10) + .await + .unwrap(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0].event_type, + pinakes_core::analytics::UsageEventType::View + ); - // Most viewed - let most_viewed = storage.get_most_viewed(10).await.unwrap(); - assert_eq!(most_viewed.len(), 1); - assert_eq!(most_viewed[0].1, 1); + // Most viewed + let most_viewed = storage.get_most_viewed(10).await.unwrap(); + assert_eq!(most_viewed.len(), 1); + assert_eq!(most_viewed[0].1, 1); - // Recently viewed - let recent = storage.get_recently_viewed(user_id, 10).await.unwrap(); - assert_eq!(recent.len(), 1); + // Recently viewed + let recent = storage.get_recently_viewed(user_id, 10).await.unwrap(); + assert_eq!(recent.len(), 1); } #[tokio::test] async fn test_watch_progress() { - let storage = setup().await; - let item = make_test_media("progress1"); - storage.insert_media(&item).await.unwrap(); + let storage = setup().await; + let item = make_test_media("progress1"); + storage.insert_media(&item).await.unwrap(); - let user_id = pinakes_core::users::UserId::new(); + let user_id = pinakes_core::users::UserId::new(); - // No progress initially - let progress = storage.get_watch_progress(user_id, item.id).await.unwrap(); - assert!(progress.is_none()); + // No progress initially + let progress = storage.get_watch_progress(user_id, item.id).await.unwrap(); + assert!(progress.is_none()); - // Update progress - storage - .update_watch_progress(user_id, item.id, 45.5) - .await - .unwrap(); - let progress = storage.get_watch_progress(user_id, item.id).await.unwrap(); - assert_eq!(progress, Some(45.5)); + // Update progress + storage + .update_watch_progress(user_id, item.id, 45.5) + .await + .unwrap(); + let progress = storage.get_watch_progress(user_id, item.id).await.unwrap(); + assert_eq!(progress, Some(45.5)); - // Update again (should upsert) - storage - .update_watch_progress(user_id, item.id, 90.0) - .await - .unwrap(); - let progress = storage.get_watch_progress(user_id, item.id).await.unwrap(); - assert_eq!(progress, Some(90.0)); + // Update again (should upsert) + storage + .update_watch_progress(user_id, item.id, 90.0) + .await + .unwrap(); + let progress = storage.get_watch_progress(user_id, item.id).await.unwrap(); + assert_eq!(progress, Some(90.0)); } #[tokio::test] async fn test_cleanup_old_events() { - let storage = setup().await; + let storage = setup().await; - let old_event = pinakes_core::analytics::UsageEvent { - id: uuid::Uuid::now_v7(), - media_id: None, - user_id: None, - event_type: pinakes_core::analytics::UsageEventType::Search, - timestamp: chrono::Utc::now() - chrono::Duration::days(100), - duration_secs: None, - context_json: None, - }; - storage.record_usage_event(&old_event).await.unwrap(); + let old_event = pinakes_core::analytics::UsageEvent { + id: uuid::Uuid::now_v7(), + media_id: None, + user_id: None, + event_type: pinakes_core::analytics::UsageEventType::Search, + timestamp: chrono::Utc::now() - chrono::Duration::days(100), + duration_secs: None, + context_json: None, + }; + storage.record_usage_event(&old_event).await.unwrap(); - let cutoff = chrono::Utc::now() - chrono::Duration::days(90); - let cleaned = storage.cleanup_old_events(cutoff).await.unwrap(); - assert_eq!(cleaned, 1); + let cutoff = chrono::Utc::now() - chrono::Duration::days(90); + let cleaned = storage.cleanup_old_events(cutoff).await.unwrap(); + assert_eq!(cleaned, 1); } #[tokio::test] async fn test_subtitles_crud() { - let storage = setup().await; - let item = make_test_media("sub1"); - storage.insert_media(&item).await.unwrap(); + let storage = setup().await; + let item = make_test_media("sub1"); + storage.insert_media(&item).await.unwrap(); - let subtitle = pinakes_core::subtitles::Subtitle { - id: uuid::Uuid::now_v7(), - media_id: item.id, - language: Some("en".to_string()), - format: pinakes_core::subtitles::SubtitleFormat::Srt, - file_path: Some("/tmp/test.srt".into()), - is_embedded: false, - track_index: None, - offset_ms: 0, - created_at: chrono::Utc::now(), - }; - storage.add_subtitle(&subtitle).await.unwrap(); + let subtitle = pinakes_core::subtitles::Subtitle { + id: uuid::Uuid::now_v7(), + media_id: item.id, + language: Some("en".to_string()), + format: pinakes_core::subtitles::SubtitleFormat::Srt, + file_path: Some("/tmp/test.srt".into()), + is_embedded: false, + track_index: None, + offset_ms: 0, + created_at: chrono::Utc::now(), + }; + storage.add_subtitle(&subtitle).await.unwrap(); - // Get subtitles - let subs = storage.get_media_subtitles(item.id).await.unwrap(); - assert_eq!(subs.len(), 1); - assert_eq!(subs[0].language.as_deref(), Some("en")); - assert_eq!(subs[0].format, pinakes_core::subtitles::SubtitleFormat::Srt); + // Get subtitles + let subs = storage.get_media_subtitles(item.id).await.unwrap(); + assert_eq!(subs.len(), 1); + assert_eq!(subs[0].language.as_deref(), Some("en")); + assert_eq!(subs[0].format, pinakes_core::subtitles::SubtitleFormat::Srt); - // Update offset - storage - .update_subtitle_offset(subtitle.id, 500) - .await - .unwrap(); - let updated = storage.get_media_subtitles(item.id).await.unwrap(); - assert_eq!(updated[0].offset_ms, 500); + // Update offset + storage + .update_subtitle_offset(subtitle.id, 500) + .await + .unwrap(); + let updated = storage.get_media_subtitles(item.id).await.unwrap(); + assert_eq!(updated[0].offset_ms, 500); - // Delete subtitle - storage.delete_subtitle(subtitle.id).await.unwrap(); - let empty = storage.get_media_subtitles(item.id).await.unwrap(); - assert!(empty.is_empty()); + // Delete subtitle + storage.delete_subtitle(subtitle.id).await.unwrap(); + let empty = storage.get_media_subtitles(item.id).await.unwrap(); + assert!(empty.is_empty()); } #[tokio::test] async fn test_external_metadata() { - let storage = setup().await; - let item = make_test_media("enrich1"); - storage.insert_media(&item).await.unwrap(); + let storage = setup().await; + let item = make_test_media("enrich1"); + storage.insert_media(&item).await.unwrap(); - let meta = pinakes_core::enrichment::ExternalMetadata { - id: uuid::Uuid::now_v7(), - media_id: item.id, - source: pinakes_core::enrichment::EnrichmentSourceType::MusicBrainz, - external_id: Some("mb-123".to_string()), - metadata_json: r#"{"title":"Test"}"#.to_string(), - confidence: 0.85, - last_updated: chrono::Utc::now(), - }; - storage.store_external_metadata(&meta).await.unwrap(); + let meta = pinakes_core::enrichment::ExternalMetadata { + id: uuid::Uuid::now_v7(), + media_id: item.id, + source: pinakes_core::enrichment::EnrichmentSourceType::MusicBrainz, + external_id: Some("mb-123".to_string()), + metadata_json: r#"{"title":"Test"}"#.to_string(), + confidence: 0.85, + last_updated: chrono::Utc::now(), + }; + storage.store_external_metadata(&meta).await.unwrap(); - // Get external metadata - let metas = storage.get_external_metadata(item.id).await.unwrap(); - assert_eq!(metas.len(), 1); - assert_eq!( - metas[0].source, - pinakes_core::enrichment::EnrichmentSourceType::MusicBrainz - ); - assert_eq!(metas[0].external_id.as_deref(), Some("mb-123")); - assert!((metas[0].confidence - 0.85).abs() < 0.01); + // Get external metadata + let metas = storage.get_external_metadata(item.id).await.unwrap(); + assert_eq!(metas.len(), 1); + assert_eq!( + metas[0].source, + pinakes_core::enrichment::EnrichmentSourceType::MusicBrainz + ); + assert_eq!(metas[0].external_id.as_deref(), Some("mb-123")); + assert!((metas[0].confidence - 0.85).abs() < 0.01); - // Delete - storage.delete_external_metadata(meta.id).await.unwrap(); - let empty = storage.get_external_metadata(item.id).await.unwrap(); - assert!(empty.is_empty()); + // Delete + storage.delete_external_metadata(meta.id).await.unwrap(); + let empty = storage.get_external_metadata(item.id).await.unwrap(); + assert!(empty.is_empty()); } #[tokio::test] async fn test_transcode_sessions() { - let storage = setup().await; - let item = make_test_media("transcode1"); - storage.insert_media(&item).await.unwrap(); + let storage = setup().await; + let item = make_test_media("transcode1"); + storage.insert_media(&item).await.unwrap(); - let session = pinakes_core::transcode::TranscodeSession { - id: uuid::Uuid::now_v7(), - media_id: item.id, - user_id: None, - profile: "720p".to_string(), - cache_path: "/tmp/transcode/test.mp4".into(), - status: pinakes_core::transcode::TranscodeStatus::Pending, - progress: 0.0, - created_at: chrono::Utc::now(), - expires_at: Some(chrono::Utc::now() + chrono::Duration::hours(24)), - duration_secs: None, - child_cancel: None, - }; - storage.create_transcode_session(&session).await.unwrap(); + let session = pinakes_core::transcode::TranscodeSession { + id: uuid::Uuid::now_v7(), + media_id: item.id, + user_id: None, + profile: "720p".to_string(), + cache_path: "/tmp/transcode/test.mp4".into(), + status: pinakes_core::transcode::TranscodeStatus::Pending, + progress: 0.0, + created_at: chrono::Utc::now(), + expires_at: Some(chrono::Utc::now() + chrono::Duration::hours(24)), + duration_secs: None, + child_cancel: None, + }; + storage.create_transcode_session(&session).await.unwrap(); - // Get session - let fetched = storage.get_transcode_session(session.id).await.unwrap(); - assert_eq!(fetched.profile, "720p"); - assert_eq!(fetched.status.as_str(), "pending"); + // Get session + let fetched = storage.get_transcode_session(session.id).await.unwrap(); + assert_eq!(fetched.profile, "720p"); + assert_eq!(fetched.status.as_str(), "pending"); - // Update status - storage - .update_transcode_status( - session.id, - pinakes_core::transcode::TranscodeStatus::Transcoding, - 0.5, - ) - .await - .unwrap(); - let updated = storage.get_transcode_session(session.id).await.unwrap(); - assert_eq!(updated.status.as_str(), "transcoding"); - assert!((updated.progress - 0.5).abs() < 0.01); + // Update status + storage + .update_transcode_status( + session.id, + pinakes_core::transcode::TranscodeStatus::Transcoding, + 0.5, + ) + .await + .unwrap(); + let updated = storage.get_transcode_session(session.id).await.unwrap(); + assert_eq!(updated.status.as_str(), "transcoding"); + assert!((updated.progress - 0.5).abs() < 0.01); - // List sessions - let sessions = storage.list_transcode_sessions(None).await.unwrap(); - assert_eq!(sessions.len(), 1); + // List sessions + let sessions = storage.list_transcode_sessions(None).await.unwrap(); + assert_eq!(sessions.len(), 1); - // List by media ID - let sessions = storage - .list_transcode_sessions(Some(item.id)) - .await - .unwrap(); - assert_eq!(sessions.len(), 1); + // List by media ID + let sessions = storage + .list_transcode_sessions(Some(item.id)) + .await + .unwrap(); + assert_eq!(sessions.len(), 1); - // Cleanup expired - let far_future = chrono::Utc::now() + chrono::Duration::days(365); - let cleaned = storage - .cleanup_expired_transcodes(far_future) - .await - .unwrap(); - assert_eq!(cleaned, 1); + // Cleanup expired + let far_future = chrono::Utc::now() + chrono::Duration::days(365); + let cleaned = storage + .cleanup_expired_transcodes(far_future) + .await + .unwrap(); + assert_eq!(cleaned, 1); } diff --git a/crates/pinakes-core/tests/integrity.rs b/crates/pinakes-core/tests/integrity.rs index 978f232..64ad01e 100644 --- a/crates/pinakes-core/tests/integrity.rs +++ b/crates/pinakes-core/tests/integrity.rs @@ -7,172 +7,176 @@ use common::{create_test_media_item, setup_test_storage}; #[tokio::test] async fn test_detect_orphaned_files() { - let (storage, temp_dir) = setup_test_storage().await; + let (storage, temp_dir) = setup_test_storage().await; - let nonexistent_path = temp_dir.path().join("nonexistent.mp3"); - let orphaned_item = create_test_media_item(nonexistent_path, "hash1"); + let nonexistent_path = temp_dir.path().join("nonexistent.mp3"); + let orphaned_item = create_test_media_item(nonexistent_path, "hash1"); - storage.insert_media(&orphaned_item).await.unwrap(); + storage.insert_media(&orphaned_item).await.unwrap(); - let report = detect_orphans(&storage).await.unwrap(); + let report = detect_orphans(&storage).await.unwrap(); - assert_eq!(report.orphaned_ids.len(), 1); - assert_eq!(report.orphaned_ids[0], orphaned_item.id); + assert_eq!(report.orphaned_ids.len(), 1); + assert_eq!(report.orphaned_ids[0], orphaned_item.id); } #[tokio::test] async fn test_detect_untracked_files() { - let (storage, temp_dir) = setup_test_storage().await; + let (storage, temp_dir) = setup_test_storage().await; - let root_dir = temp_dir.path().join("media"); - fs::create_dir(&root_dir).unwrap(); - storage.add_root_dir(root_dir.clone()).await.unwrap(); + let root_dir = temp_dir.path().join("media"); + fs::create_dir(&root_dir).unwrap(); + storage.add_root_dir(root_dir.clone()).await.unwrap(); - let tracked_file = root_dir.join("tracked.mp3"); - let untracked_file = root_dir.join("untracked.mp3"); + let tracked_file = root_dir.join("tracked.mp3"); + let untracked_file = root_dir.join("untracked.mp3"); - fs::write(&tracked_file, b"tracked content").unwrap(); - fs::write(&untracked_file, b"untracked content").unwrap(); + fs::write(&tracked_file, b"tracked content").unwrap(); + fs::write(&untracked_file, b"untracked content").unwrap(); - let tracked_item = create_test_media_item(tracked_file.clone(), "hash_tracked"); - storage.insert_media(&tracked_item).await.unwrap(); + let tracked_item = + create_test_media_item(tracked_file.clone(), "hash_tracked"); + storage.insert_media(&tracked_item).await.unwrap(); - let report = detect_orphans(&storage).await.unwrap(); + let report = detect_orphans(&storage).await.unwrap(); - assert_eq!(report.untracked_paths.len(), 1); - assert!(report.untracked_paths.contains(&untracked_file)); + assert_eq!(report.untracked_paths.len(), 1); + assert!(report.untracked_paths.contains(&untracked_file)); } #[tokio::test] async fn test_detect_moved_files() { - let (storage, temp_dir) = setup_test_storage().await; + let (storage, temp_dir) = setup_test_storage().await; - let old_path = temp_dir.path().join("old_location.mp3"); + let old_path = temp_dir.path().join("old_location.mp3"); - fs::write(&old_path, b"content").unwrap(); + fs::write(&old_path, b"content").unwrap(); - let old_item = create_test_media_item(old_path.clone(), "hash_unique"); - storage.insert_media(&old_item).await.unwrap(); + let old_item = create_test_media_item(old_path.clone(), "hash_unique"); + storage.insert_media(&old_item).await.unwrap(); - fs::remove_file(&old_path).unwrap(); + fs::remove_file(&old_path).unwrap(); - let report = detect_orphans(&storage).await.unwrap(); + let report = detect_orphans(&storage).await.unwrap(); - assert_eq!(report.orphaned_ids.len(), 1); - assert_eq!(report.moved_files.len(), 0); + assert_eq!(report.orphaned_ids.len(), 1); + assert_eq!(report.moved_files.len(), 0); } #[tokio::test] async fn test_ignore_patterns_respected() { - let (storage, temp_dir) = setup_test_storage().await; + let (storage, temp_dir) = setup_test_storage().await; - let root_dir = temp_dir.path().join("media"); - fs::create_dir(&root_dir).unwrap(); - storage.add_root_dir(root_dir.clone()).await.unwrap(); + let root_dir = temp_dir.path().join("media"); + fs::create_dir(&root_dir).unwrap(); + storage.add_root_dir(root_dir.clone()).await.unwrap(); - let hidden_dir = root_dir.join(".hidden"); - fs::create_dir(&hidden_dir).unwrap(); + let hidden_dir = root_dir.join(".hidden"); + fs::create_dir(&hidden_dir).unwrap(); - let hidden_file = hidden_dir.join("hidden.mp3"); - fs::write(&hidden_file, b"hidden content").unwrap(); + let hidden_file = hidden_dir.join("hidden.mp3"); + fs::write(&hidden_file, b"hidden content").unwrap(); - let normal_file = root_dir.join("normal.mp3"); - fs::write(&normal_file, b"normal content").unwrap(); + let normal_file = root_dir.join("normal.mp3"); + fs::write(&normal_file, b"normal content").unwrap(); - let report = detect_orphans(&storage).await.unwrap(); + let report = detect_orphans(&storage).await.unwrap(); - assert_eq!(report.untracked_paths.len(), 1); - assert!(report.untracked_paths.contains(&normal_file)); - assert!(!report.untracked_paths.contains(&hidden_file)); + assert_eq!(report.untracked_paths.len(), 1); + assert!(report.untracked_paths.contains(&normal_file)); + assert!(!report.untracked_paths.contains(&hidden_file)); } #[tokio::test] async fn test_only_supported_media_types() { - let (storage, temp_dir) = setup_test_storage().await; + let (storage, temp_dir) = setup_test_storage().await; - let root_dir = temp_dir.path().join("media"); - fs::create_dir(&root_dir).unwrap(); - storage.add_root_dir(root_dir.clone()).await.unwrap(); + let root_dir = temp_dir.path().join("media"); + fs::create_dir(&root_dir).unwrap(); + storage.add_root_dir(root_dir.clone()).await.unwrap(); - let mp3_file = root_dir.join("audio.mp3"); - let txt_file = root_dir.join("readme.txt"); - let exe_file = root_dir.join("program.exe"); + let mp3_file = root_dir.join("audio.mp3"); + let txt_file = root_dir.join("readme.txt"); + let exe_file = root_dir.join("program.exe"); - fs::write(&mp3_file, b"audio").unwrap(); - fs::write(&txt_file, b"text").unwrap(); - fs::write(&exe_file, b"binary").unwrap(); + fs::write(&mp3_file, b"audio").unwrap(); + fs::write(&txt_file, b"text").unwrap(); + fs::write(&exe_file, b"binary").unwrap(); - let report = detect_orphans(&storage).await.unwrap(); + let report = detect_orphans(&storage).await.unwrap(); - assert!(report.untracked_paths.len() <= 2); - assert!(!report.untracked_paths.contains(&exe_file)); + assert!(report.untracked_paths.len() <= 2); + assert!(!report.untracked_paths.contains(&exe_file)); } #[tokio::test] async fn test_complete_orphan_workflow() { - let (storage, temp_dir) = setup_test_storage().await; + let (storage, temp_dir) = setup_test_storage().await; - let root_dir = temp_dir.path().join("media"); - fs::create_dir(&root_dir).unwrap(); - storage.add_root_dir(root_dir.clone()).await.unwrap(); + let root_dir = temp_dir.path().join("media"); + fs::create_dir(&root_dir).unwrap(); + storage.add_root_dir(root_dir.clone()).await.unwrap(); - let orphaned_path = root_dir.join("orphaned.mp3"); - let orphaned_item = create_test_media_item(orphaned_path.clone(), "hash_orphaned"); - storage.insert_media(&orphaned_item).await.unwrap(); + let orphaned_path = root_dir.join("orphaned.mp3"); + let orphaned_item = + create_test_media_item(orphaned_path.clone(), "hash_orphaned"); + storage.insert_media(&orphaned_item).await.unwrap(); - let untracked_path = root_dir.join("untracked.mp3"); - fs::write(&untracked_path, b"untracked").unwrap(); + let untracked_path = root_dir.join("untracked.mp3"); + fs::write(&untracked_path, b"untracked").unwrap(); - let another_orphaned = root_dir.join("another_orphaned.mp3"); - let another_item = create_test_media_item(another_orphaned.clone(), "hash_another"); - storage.insert_media(&another_item).await.unwrap(); + let another_orphaned = root_dir.join("another_orphaned.mp3"); + let another_item = + create_test_media_item(another_orphaned.clone(), "hash_another"); + storage.insert_media(&another_item).await.unwrap(); - let tracked_path = root_dir.join("tracked.mp3"); - fs::write(&tracked_path, b"tracked").unwrap(); + let tracked_path = root_dir.join("tracked.mp3"); + fs::write(&tracked_path, b"tracked").unwrap(); - let tracked_item = create_test_media_item(tracked_path.clone(), "hash_tracked"); - storage.insert_media(&tracked_item).await.unwrap(); + let tracked_item = + create_test_media_item(tracked_path.clone(), "hash_tracked"); + storage.insert_media(&tracked_item).await.unwrap(); - let report = detect_orphans(&storage).await.unwrap(); + let report = detect_orphans(&storage).await.unwrap(); - assert_eq!(report.orphaned_ids.len(), 2); - assert!(report.orphaned_ids.contains(&orphaned_item.id)); - assert!(report.orphaned_ids.contains(&another_item.id)); + assert_eq!(report.orphaned_ids.len(), 2); + assert!(report.orphaned_ids.contains(&orphaned_item.id)); + assert!(report.orphaned_ids.contains(&another_item.id)); - assert_eq!(report.untracked_paths.len(), 1); - assert!(report.untracked_paths.contains(&untracked_path)); + assert_eq!(report.untracked_paths.len(), 1); + assert!(report.untracked_paths.contains(&untracked_path)); - assert_eq!(report.moved_files.len(), 0); + assert_eq!(report.moved_files.len(), 0); } #[tokio::test] async fn test_large_directory_performance() { - let (storage, temp_dir) = setup_test_storage().await; + let (storage, temp_dir) = setup_test_storage().await; - let root_dir = temp_dir.path().join("media"); - fs::create_dir(&root_dir).unwrap(); - storage.add_root_dir(root_dir.clone()).await.unwrap(); + let root_dir = temp_dir.path().join("media"); + fs::create_dir(&root_dir).unwrap(); + storage.add_root_dir(root_dir.clone()).await.unwrap(); - for i in 0..1000 { - let file_path = root_dir.join(format!("file_{}.mp3", i)); - fs::write(&file_path, format!("content {}", i)).unwrap(); - } + for i in 0..1000 { + let file_path = root_dir.join(format!("file_{}.mp3", i)); + fs::write(&file_path, format!("content {}", i)).unwrap(); + } - for i in 0..500 { - let file_path = root_dir.join(format!("file_{}.mp3", i)); - let item = create_test_media_item(file_path, &format!("hash_{}", i)); - storage.insert_media(&item).await.unwrap(); - } + for i in 0..500 { + let file_path = root_dir.join(format!("file_{}.mp3", i)); + let item = create_test_media_item(file_path, &format!("hash_{}", i)); + storage.insert_media(&item).await.unwrap(); + } - let start = std::time::Instant::now(); - let report = detect_orphans(&storage).await.unwrap(); - let elapsed = start.elapsed(); + let start = std::time::Instant::now(); + let report = detect_orphans(&storage).await.unwrap(); + let elapsed = start.elapsed(); - assert!( - elapsed.as_secs() < 5, - "Detection took too long: {:?}", - elapsed - ); + assert!( + elapsed.as_secs() < 5, + "Detection took too long: {:?}", + elapsed + ); - assert_eq!(report.untracked_paths.len(), 500); + assert_eq!(report.untracked_paths.len(), 500); } diff --git a/crates/pinakes-core/tests/markdown_links_atomicity.rs b/crates/pinakes-core/tests/markdown_links_atomicity.rs index 289a76c..12aa81f 100644 --- a/crates/pinakes-core/tests/markdown_links_atomicity.rs +++ b/crates/pinakes-core/tests/markdown_links_atomicity.rs @@ -1,212 +1,210 @@ -use pinakes_core::links::extract_links; -use pinakes_core::model::*; -use pinakes_core::storage::StorageBackend; +use pinakes_core::{links::extract_links, model::*, storage::StorageBackend}; mod common; /// Create test markdown content with multiple links fn create_test_note_content(num_links: usize) -> String { - let mut content = String::from("# Test Note\n\n"); - for i in 0..num_links { - content.push_str(&format!("Link {}: [[note_{}]]\n", i, i)); - } - content + let mut content = String::from("# Test Note\n\n"); + for i in 0..num_links { + content.push_str(&format!("Link {}: [[note_{}]]\n", i, i)); + } + content } #[tokio::test] async fn test_save_links_atomicity_success_case() { - // Setup: Create in-memory database - let storage = common::setup().await; + // Setup: Create in-memory database + let storage = common::setup().await; - // Create a test note - let note_id = MediaId::new(); - let item = common::make_test_markdown_item(note_id); - storage.insert_media(&item).await.unwrap(); + // Create a test note + let note_id = MediaId::new(); + let item = common::make_test_markdown_item(note_id); + storage.insert_media(&item).await.unwrap(); - // Extract links from test content - let content = create_test_note_content(5); - let links = extract_links(note_id, &content); + // Extract links from test content + let content = create_test_note_content(5); + let links = extract_links(note_id, &content); - assert_eq!(links.len(), 5, "Should extract 5 links"); + assert_eq!(links.len(), 5, "Should extract 5 links"); - // Save links (first time - should succeed) - storage.save_markdown_links(note_id, &links).await.unwrap(); + // Save links (first time - should succeed) + storage.save_markdown_links(note_id, &links).await.unwrap(); - // Verify all links were saved - let saved_links = storage.get_outgoing_links(note_id).await.unwrap(); - assert_eq!(saved_links.len(), 5, "All 5 links should be saved"); + // Verify all links were saved + let saved_links = storage.get_outgoing_links(note_id).await.unwrap(); + assert_eq!(saved_links.len(), 5, "All 5 links should be saved"); - // Update with new links - let new_content = create_test_note_content(3); - let new_links = extract_links(note_id, &new_content); + // Update with new links + let new_content = create_test_note_content(3); + let new_links = extract_links(note_id, &new_content); - // Save again (should replace old links) - storage - .save_markdown_links(note_id, &new_links) - .await - .unwrap(); + // Save again (should replace old links) + storage + .save_markdown_links(note_id, &new_links) + .await + .unwrap(); - // Verify old links were deleted and new links saved - let updated_links = storage.get_outgoing_links(note_id).await.unwrap(); - assert_eq!( - updated_links.len(), - 3, - "Should have exactly 3 links after update" - ); + // Verify old links were deleted and new links saved + let updated_links = storage.get_outgoing_links(note_id).await.unwrap(); + assert_eq!( + updated_links.len(), + 3, + "Should have exactly 3 links after update" + ); } #[tokio::test] async fn test_save_links_atomicity_with_valid_data() { - // This test verifies that the transaction commit works correctly - // by saving links multiple times and ensuring consistency - let storage = common::setup().await; - let note_id = MediaId::new(); - let item = common::make_test_markdown_item(note_id); - storage.insert_media(&item).await.unwrap(); + // This test verifies that the transaction commit works correctly + // by saving links multiple times and ensuring consistency + let storage = common::setup().await; + let note_id = MediaId::new(); + let item = common::make_test_markdown_item(note_id); + storage.insert_media(&item).await.unwrap(); - // First batch of links - let content1 = "[[note1]] and [[note2]]"; - let links1 = extract_links(note_id, content1); - storage.save_markdown_links(note_id, &links1).await.unwrap(); + // First batch of links + let content1 = "[[note1]] and [[note2]]"; + let links1 = extract_links(note_id, content1); + storage.save_markdown_links(note_id, &links1).await.unwrap(); - let saved1 = storage.get_outgoing_links(note_id).await.unwrap(); - assert_eq!(saved1.len(), 2, "First save: 2 links"); + let saved1 = storage.get_outgoing_links(note_id).await.unwrap(); + assert_eq!(saved1.len(), 2, "First save: 2 links"); - // Second batch (replace) - let content2 = "[[note3]] [[note4]] [[note5]]"; - let links2 = extract_links(note_id, content2); - storage.save_markdown_links(note_id, &links2).await.unwrap(); + // Second batch (replace) + let content2 = "[[note3]] [[note4]] [[note5]]"; + let links2 = extract_links(note_id, content2); + storage.save_markdown_links(note_id, &links2).await.unwrap(); - let saved2 = storage.get_outgoing_links(note_id).await.unwrap(); - assert_eq!(saved2.len(), 3, "Second save: 3 links (old ones deleted)"); + let saved2 = storage.get_outgoing_links(note_id).await.unwrap(); + assert_eq!(saved2.len(), 3, "Second save: 3 links (old ones deleted)"); - // Third batch (empty) - storage.save_markdown_links(note_id, &[]).await.unwrap(); + // Third batch (empty) + storage.save_markdown_links(note_id, &[]).await.unwrap(); - let saved3 = storage.get_outgoing_links(note_id).await.unwrap(); - assert_eq!(saved3.len(), 0, "Third save: 0 links (all deleted)"); + let saved3 = storage.get_outgoing_links(note_id).await.unwrap(); + assert_eq!(saved3.len(), 0, "Third save: 0 links (all deleted)"); - // Fourth batch (restore some links) - let content4 = "[[final_note]]"; - let links4 = extract_links(note_id, content4); - storage.save_markdown_links(note_id, &links4).await.unwrap(); + // Fourth batch (restore some links) + let content4 = "[[final_note]]"; + let links4 = extract_links(note_id, content4); + storage.save_markdown_links(note_id, &links4).await.unwrap(); - let saved4 = storage.get_outgoing_links(note_id).await.unwrap(); - assert_eq!(saved4.len(), 1, "Fourth save: 1 link"); - assert_eq!(saved4[0].target_path, "final_note", "Correct link target"); + let saved4 = storage.get_outgoing_links(note_id).await.unwrap(); + assert_eq!(saved4.len(), 1, "Fourth save: 1 link"); + assert_eq!(saved4[0].target_path, "final_note", "Correct link target"); } #[tokio::test] async fn test_save_links_idempotency() { - // Verify that saving the same links multiple times is safe - let storage = common::setup().await; - let note_id = MediaId::new(); - let item = common::make_test_markdown_item(note_id); - storage.insert_media(&item).await.unwrap(); + // Verify that saving the same links multiple times is safe + let storage = common::setup().await; + let note_id = MediaId::new(); + let item = common::make_test_markdown_item(note_id); + storage.insert_media(&item).await.unwrap(); - let content = "[[note_a]] [[note_b]]"; - let links = extract_links(note_id, content); + let content = "[[note_a]] [[note_b]]"; + let links = extract_links(note_id, content); - // Save same links 3 times - storage.save_markdown_links(note_id, &links).await.unwrap(); - storage.save_markdown_links(note_id, &links).await.unwrap(); - storage.save_markdown_links(note_id, &links).await.unwrap(); + // Save same links 3 times + storage.save_markdown_links(note_id, &links).await.unwrap(); + storage.save_markdown_links(note_id, &links).await.unwrap(); + storage.save_markdown_links(note_id, &links).await.unwrap(); - // Should still have exactly 2 links (not duplicated) - let saved = storage.get_outgoing_links(note_id).await.unwrap(); - assert_eq!( - saved.len(), - 2, - "Should have exactly 2 links (no duplicates)" - ); + // Should still have exactly 2 links (not duplicated) + let saved = storage.get_outgoing_links(note_id).await.unwrap(); + assert_eq!( + saved.len(), + 2, + "Should have exactly 2 links (no duplicates)" + ); } #[tokio::test] async fn test_save_links_concurrent_updates() { - // Test that concurrent updates to different notes don't interfere - let storage = common::setup().await; + // Test that concurrent updates to different notes don't interfere + let storage = common::setup().await; - // Create two different notes - let note1_id = MediaId::new(); - let note2_id = MediaId::new(); - let item1 = common::make_test_markdown_item(note1_id); - let item2 = common::make_test_markdown_item(note2_id); - storage.insert_media(&item1).await.unwrap(); - storage.insert_media(&item2).await.unwrap(); + // Create two different notes + let note1_id = MediaId::new(); + let note2_id = MediaId::new(); + let item1 = common::make_test_markdown_item(note1_id); + let item2 = common::make_test_markdown_item(note2_id); + storage.insert_media(&item1).await.unwrap(); + storage.insert_media(&item2).await.unwrap(); - // Save links for both notes - let links1 = extract_links(note1_id, "[[target1]]"); - let links2 = extract_links(note2_id, "[[target2]] [[target3]]"); + // Save links for both notes + let links1 = extract_links(note1_id, "[[target1]]"); + let links2 = extract_links(note2_id, "[[target2]] [[target3]]"); - // Execute both saves. We do so in sequence since we can't test true concurrency easily - // ...or so I think. Database tests are annoying. - storage - .save_markdown_links(note1_id, &links1) - .await - .unwrap(); - storage - .save_markdown_links(note2_id, &links2) - .await - .unwrap(); + // Execute both saves. We do so in sequence since we can't test true + // concurrency easily ...or so I think. Database tests are annoying. + storage + .save_markdown_links(note1_id, &links1) + .await + .unwrap(); + storage + .save_markdown_links(note2_id, &links2) + .await + .unwrap(); - // Verify both notes have correct links - let saved1 = storage.get_outgoing_links(note1_id).await.unwrap(); - let saved2 = storage.get_outgoing_links(note2_id).await.unwrap(); + // Verify both notes have correct links + let saved1 = storage.get_outgoing_links(note1_id).await.unwrap(); + let saved2 = storage.get_outgoing_links(note2_id).await.unwrap(); - assert_eq!(saved1.len(), 1, "Note 1 should have 1 link"); - assert_eq!(saved2.len(), 2, "Note 2 should have 2 links"); + assert_eq!(saved1.len(), 1, "Note 1 should have 1 link"); + assert_eq!(saved2.len(), 2, "Note 2 should have 2 links"); - // Update note 1 - should not affect note 2 - let new_links1 = extract_links(note1_id, "[[target_new1]] [[target_new2]]"); - storage - .save_markdown_links(note1_id, &new_links1) - .await - .unwrap(); + // Update note 1 - should not affect note 2 + let new_links1 = extract_links(note1_id, "[[target_new1]] [[target_new2]]"); + storage + .save_markdown_links(note1_id, &new_links1) + .await + .unwrap(); - // Verify note 1 updated but note 2 unchanged - let updated1 = storage.get_outgoing_links(note1_id).await.unwrap(); - let unchanged2 = storage.get_outgoing_links(note2_id).await.unwrap(); + // Verify note 1 updated but note 2 unchanged + let updated1 = storage.get_outgoing_links(note1_id).await.unwrap(); + let unchanged2 = storage.get_outgoing_links(note2_id).await.unwrap(); - assert_eq!(updated1.len(), 2, "Note 1 should have 2 links after update"); - assert_eq!(unchanged2.len(), 2, "Note 2 should still have 2 links"); + assert_eq!(updated1.len(), 2, "Note 1 should have 2 links after update"); + assert_eq!(unchanged2.len(), 2, "Note 2 should still have 2 links"); } #[tokio::test] async fn test_save_links_with_large_batch() { - // Test atomicity with a large number of links - let storage = common::setup().await; - let note_id = MediaId::new(); - let item = common::make_test_markdown_item(note_id); - storage.insert_media(&item).await.unwrap(); + // Test atomicity with a large number of links + let storage = common::setup().await; + let note_id = MediaId::new(); + let item = common::make_test_markdown_item(note_id); + storage.insert_media(&item).await.unwrap(); - // Create note with 100 links - let content = create_test_note_content(100); - let links = extract_links(note_id, &content); + // Create note with 100 links + let content = create_test_note_content(100); + let links = extract_links(note_id, &content); - assert_eq!(links.len(), 100, "Should extract 100 links"); + assert_eq!(links.len(), 100, "Should extract 100 links"); - // Save all 100 links - storage.save_markdown_links(note_id, &links).await.unwrap(); + // Save all 100 links + storage.save_markdown_links(note_id, &links).await.unwrap(); - // Verify all saved - let saved = storage.get_outgoing_links(note_id).await.unwrap(); - assert_eq!(saved.len(), 100, "All 100 links should be saved atomically"); + // Verify all saved + let saved = storage.get_outgoing_links(note_id).await.unwrap(); + assert_eq!(saved.len(), 100, "All 100 links should be saved atomically"); - // Replace with smaller set - let small_content = create_test_note_content(10); - let small_links = extract_links(note_id, &small_content); - storage - .save_markdown_links(note_id, &small_links) - .await - .unwrap(); + // Replace with smaller set + let small_content = create_test_note_content(10); + let small_links = extract_links(note_id, &small_content); + storage + .save_markdown_links(note_id, &small_links) + .await + .unwrap(); - // Verify replacement worked - let updated = storage.get_outgoing_links(note_id).await.unwrap(); - assert_eq!( - updated.len(), - 10, - "Should have exactly 10 links after replacement" - ); + // Verify replacement worked + let updated = storage.get_outgoing_links(note_id).await.unwrap(); + assert_eq!( + updated.len(), + 10, + "Should have exactly 10 links after replacement" + ); } // XXX: Testing actual transaction rollback on error is difficult without diff --git a/crates/pinakes-core/tests/session_persistence.rs b/crates/pinakes-core/tests/session_persistence.rs index 61951c4..9ef4fba 100644 --- a/crates/pinakes-core/tests/session_persistence.rs +++ b/crates/pinakes-core/tests/session_persistence.rs @@ -2,307 +2,309 @@ use chrono::Utc; use pinakes_core::storage::{SessionData, StorageBackend}; use tempfile::TempDir; -async fn setup_sqlite_storage() -> pinakes_core::storage::sqlite::SqliteBackend { - let temp_dir = TempDir::new().unwrap(); - let db_path = temp_dir - .path() - .join(format!("test_{}.db", uuid::Uuid::now_v7())); +async fn setup_sqlite_storage() -> pinakes_core::storage::sqlite::SqliteBackend +{ + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir + .path() + .join(format!("test_{}.db", uuid::Uuid::now_v7())); - let storage = pinakes_core::storage::sqlite::SqliteBackend::new(&db_path).unwrap(); - storage.run_migrations().await.unwrap(); + let storage = + pinakes_core::storage::sqlite::SqliteBackend::new(&db_path).unwrap(); + storage.run_migrations().await.unwrap(); - // Keep temp_dir alive by leaking it (tests are short-lived anyway) - std::mem::forget(temp_dir); + // Keep temp_dir alive by leaking it (tests are short-lived anyway) + std::mem::forget(temp_dir); - storage + storage } #[tokio::test] async fn test_create_and_get_session() { - let storage = setup_sqlite_storage().await; + let storage = setup_sqlite_storage().await; - let now = Utc::now(); - let session = SessionData { - session_token: "test_token_123".to_string(), - user_id: Some("user_1".to_string()), - username: "testuser".to_string(), - role: "admin".to_string(), - created_at: now, - expires_at: now + chrono::Duration::hours(24), - last_accessed: now, - }; + let now = Utc::now(); + let session = SessionData { + session_token: "test_token_123".to_string(), + user_id: Some("user_1".to_string()), + username: "testuser".to_string(), + role: "admin".to_string(), + created_at: now, + expires_at: now + chrono::Duration::hours(24), + last_accessed: now, + }; - // Create session - storage.create_session(&session).await.unwrap(); + // Create session + storage.create_session(&session).await.unwrap(); - // Get session - let retrieved = storage.get_session("test_token_123").await.unwrap(); - assert!(retrieved.is_some()); + // Get session + let retrieved = storage.get_session("test_token_123").await.unwrap(); + assert!(retrieved.is_some()); - let retrieved = retrieved.unwrap(); - assert_eq!(retrieved.session_token, "test_token_123"); - assert_eq!(retrieved.username, "testuser"); - assert_eq!(retrieved.role, "admin"); + let retrieved = retrieved.unwrap(); + assert_eq!(retrieved.session_token, "test_token_123"); + assert_eq!(retrieved.username, "testuser"); + assert_eq!(retrieved.role, "admin"); } #[tokio::test] async fn test_get_nonexistent_session() { - let storage = setup_sqlite_storage().await; + let storage = setup_sqlite_storage().await; - let result = storage.get_session("nonexistent").await.unwrap(); - assert!(result.is_none()); + let result = storage.get_session("nonexistent").await.unwrap(); + assert!(result.is_none()); } #[tokio::test] async fn test_touch_session() { - let storage = setup_sqlite_storage().await; + let storage = setup_sqlite_storage().await; - let now = Utc::now(); - let session = SessionData { - session_token: "test_token_456".to_string(), - user_id: None, - username: "testuser".to_string(), - role: "viewer".to_string(), - created_at: now, - expires_at: now + chrono::Duration::hours(24), - last_accessed: now, - }; + let now = Utc::now(); + let session = SessionData { + session_token: "test_token_456".to_string(), + user_id: None, + username: "testuser".to_string(), + role: "viewer".to_string(), + created_at: now, + expires_at: now + chrono::Duration::hours(24), + last_accessed: now, + }; - storage.create_session(&session).await.unwrap(); + storage.create_session(&session).await.unwrap(); - // Wait a bit - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + // Wait a bit + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - // Touch session - storage.touch_session("test_token_456").await.unwrap(); + // Touch session + storage.touch_session("test_token_456").await.unwrap(); - // Verify last_accessed was updated - let updated = storage - .get_session("test_token_456") - .await - .unwrap() - .unwrap(); - assert!(updated.last_accessed > now); + // Verify last_accessed was updated + let updated = storage + .get_session("test_token_456") + .await + .unwrap() + .unwrap(); + assert!(updated.last_accessed > now); } #[tokio::test] async fn test_delete_session() { - let storage = setup_sqlite_storage().await; + let storage = setup_sqlite_storage().await; - let now = Utc::now(); - let session = SessionData { - session_token: "delete_me".to_string(), - user_id: None, - username: "testuser".to_string(), - role: "editor".to_string(), - created_at: now, - expires_at: now + chrono::Duration::hours(24), - last_accessed: now, - }; + let now = Utc::now(); + let session = SessionData { + session_token: "delete_me".to_string(), + user_id: None, + username: "testuser".to_string(), + role: "editor".to_string(), + created_at: now, + expires_at: now + chrono::Duration::hours(24), + last_accessed: now, + }; - storage.create_session(&session).await.unwrap(); - assert!(storage.get_session("delete_me").await.unwrap().is_some()); + storage.create_session(&session).await.unwrap(); + assert!(storage.get_session("delete_me").await.unwrap().is_some()); - // Delete session - storage.delete_session("delete_me").await.unwrap(); + // Delete session + storage.delete_session("delete_me").await.unwrap(); - // Verify it's gone - assert!(storage.get_session("delete_me").await.unwrap().is_none()); + // Verify it's gone + assert!(storage.get_session("delete_me").await.unwrap().is_none()); } #[tokio::test] async fn test_delete_user_sessions() { - let storage = setup_sqlite_storage().await; + let storage = setup_sqlite_storage().await; - let now = Utc::now(); + let now = Utc::now(); - // Create multiple sessions for the same user - for i in 0..3 { - let session = SessionData { - session_token: format!("token_{}", i), - user_id: None, - username: "testuser".to_string(), - role: "viewer".to_string(), - created_at: now, - expires_at: now + chrono::Duration::hours(24), - last_accessed: now, - }; - storage.create_session(&session).await.unwrap(); - } - - // Create session for different user - let other_session = SessionData { - session_token: "other_token".to_string(), - user_id: None, - username: "otheruser".to_string(), - role: "viewer".to_string(), - created_at: now, - expires_at: now + chrono::Duration::hours(24), - last_accessed: now, + // Create multiple sessions for the same user + for i in 0..3 { + let session = SessionData { + session_token: format!("token_{}", i), + user_id: None, + username: "testuser".to_string(), + role: "viewer".to_string(), + created_at: now, + expires_at: now + chrono::Duration::hours(24), + last_accessed: now, }; - storage.create_session(&other_session).await.unwrap(); + storage.create_session(&session).await.unwrap(); + } - // Delete all sessions for testuser - let deleted = storage.delete_user_sessions("testuser").await.unwrap(); - assert_eq!(deleted, 3); + // Create session for different user + let other_session = SessionData { + session_token: "other_token".to_string(), + user_id: None, + username: "otheruser".to_string(), + role: "viewer".to_string(), + created_at: now, + expires_at: now + chrono::Duration::hours(24), + last_accessed: now, + }; + storage.create_session(&other_session).await.unwrap(); - // Verify testuser sessions are gone - for i in 0..3 { - assert!( - storage - .get_session(&format!("token_{}", i)) - .await - .unwrap() - .is_none() - ); - } + // Delete all sessions for testuser + let deleted = storage.delete_user_sessions("testuser").await.unwrap(); + assert_eq!(deleted, 3); - // Verify otheruser session still exists - assert!(storage.get_session("other_token").await.unwrap().is_some()); + // Verify testuser sessions are gone + for i in 0..3 { + assert!( + storage + .get_session(&format!("token_{}", i)) + .await + .unwrap() + .is_none() + ); + } + + // Verify otheruser session still exists + assert!(storage.get_session("other_token").await.unwrap().is_some()); } #[tokio::test] async fn test_delete_expired_sessions() { - let storage = setup_sqlite_storage().await; + let storage = setup_sqlite_storage().await; - let now = Utc::now(); + let now = Utc::now(); - // Create expired session - let expired = SessionData { - session_token: "expired_token".to_string(), - user_id: None, - username: "testuser".to_string(), - role: "viewer".to_string(), - created_at: now - chrono::Duration::hours(25), - expires_at: now - chrono::Duration::hours(1), // Expired 1 hour ago - last_accessed: now - chrono::Duration::hours(2), - }; - storage.create_session(&expired).await.unwrap(); + // Create expired session + let expired = SessionData { + session_token: "expired_token".to_string(), + user_id: None, + username: "testuser".to_string(), + role: "viewer".to_string(), + created_at: now - chrono::Duration::hours(25), + expires_at: now - chrono::Duration::hours(1), // Expired 1 hour ago + last_accessed: now - chrono::Duration::hours(2), + }; + storage.create_session(&expired).await.unwrap(); - // Create valid session - let valid = SessionData { - session_token: "valid_token".to_string(), - user_id: None, - username: "testuser".to_string(), - role: "viewer".to_string(), - created_at: now, - expires_at: now + chrono::Duration::hours(24), - last_accessed: now, - }; - storage.create_session(&valid).await.unwrap(); + // Create valid session + let valid = SessionData { + session_token: "valid_token".to_string(), + user_id: None, + username: "testuser".to_string(), + role: "viewer".to_string(), + created_at: now, + expires_at: now + chrono::Duration::hours(24), + last_accessed: now, + }; + storage.create_session(&valid).await.unwrap(); - // Delete expired sessions - let deleted = storage.delete_expired_sessions().await.unwrap(); - assert_eq!(deleted, 1); + // Delete expired sessions + let deleted = storage.delete_expired_sessions().await.unwrap(); + assert_eq!(deleted, 1); - // Verify expired is gone, valid remains - assert!( - storage - .get_session("expired_token") - .await - .unwrap() - .is_none() - ); - assert!(storage.get_session("valid_token").await.unwrap().is_some()); + // Verify expired is gone, valid remains + assert!( + storage + .get_session("expired_token") + .await + .unwrap() + .is_none() + ); + assert!(storage.get_session("valid_token").await.unwrap().is_some()); } #[tokio::test] async fn test_list_active_sessions() { - let storage = setup_sqlite_storage().await; + let storage = setup_sqlite_storage().await; - let now = Utc::now(); + let now = Utc::now(); - // Create active sessions for different users - for i in 0..3 { - let session = SessionData { - session_token: format!("user1_token_{}", i), - user_id: None, - username: "user1".to_string(), - role: "viewer".to_string(), - created_at: now, - expires_at: now + chrono::Duration::hours(24), - last_accessed: now, - }; - storage.create_session(&session).await.unwrap(); - } - - for i in 0..2 { - let session = SessionData { - session_token: format!("user2_token_{}", i), - user_id: None, - username: "user2".to_string(), - role: "admin".to_string(), - created_at: now, - expires_at: now + chrono::Duration::hours(24), - last_accessed: now, - }; - storage.create_session(&session).await.unwrap(); - } - - // Create expired session - let expired = SessionData { - session_token: "expired".to_string(), - user_id: None, - username: "user1".to_string(), - role: "viewer".to_string(), - created_at: now - chrono::Duration::hours(25), - expires_at: now - chrono::Duration::hours(1), - last_accessed: now - chrono::Duration::hours(2), + // Create active sessions for different users + for i in 0..3 { + let session = SessionData { + session_token: format!("user1_token_{}", i), + user_id: None, + username: "user1".to_string(), + role: "viewer".to_string(), + created_at: now, + expires_at: now + chrono::Duration::hours(24), + last_accessed: now, }; - storage.create_session(&expired).await.unwrap(); + storage.create_session(&session).await.unwrap(); + } - // List all active sessions - let all_active = storage.list_active_sessions(None).await.unwrap(); - assert_eq!(all_active.len(), 5); // 3 + 2, expired not included + for i in 0..2 { + let session = SessionData { + session_token: format!("user2_token_{}", i), + user_id: None, + username: "user2".to_string(), + role: "admin".to_string(), + created_at: now, + expires_at: now + chrono::Duration::hours(24), + last_accessed: now, + }; + storage.create_session(&session).await.unwrap(); + } - // List active sessions for user1 - let user1_active = storage.list_active_sessions(Some("user1")).await.unwrap(); - assert_eq!(user1_active.len(), 3); + // Create expired session + let expired = SessionData { + session_token: "expired".to_string(), + user_id: None, + username: "user1".to_string(), + role: "viewer".to_string(), + created_at: now - chrono::Duration::hours(25), + expires_at: now - chrono::Duration::hours(1), + last_accessed: now - chrono::Duration::hours(2), + }; + storage.create_session(&expired).await.unwrap(); - // List active sessions for user2 - let user2_active = storage.list_active_sessions(Some("user2")).await.unwrap(); - assert_eq!(user2_active.len(), 2); + // List all active sessions + let all_active = storage.list_active_sessions(None).await.unwrap(); + assert_eq!(all_active.len(), 5); // 3 + 2, expired not included + + // List active sessions for user1 + let user1_active = storage.list_active_sessions(Some("user1")).await.unwrap(); + assert_eq!(user1_active.len(), 3); + + // List active sessions for user2 + let user2_active = storage.list_active_sessions(Some("user2")).await.unwrap(); + assert_eq!(user2_active.len(), 2); } #[tokio::test] async fn test_concurrent_session_operations() { - let storage = setup_sqlite_storage().await; + let storage = setup_sqlite_storage().await; - let now = Utc::now(); - let storage = std::sync::Arc::new(storage); + let now = Utc::now(); + let storage = std::sync::Arc::new(storage); - // Create sessions concurrently - let mut handles = vec![]; - for i in 0..10 { - let storage = storage.clone(); - let handle = tokio::spawn(async move { - let session = SessionData { - session_token: format!("concurrent_{}", i), - user_id: None, - username: format!("user{}", i), - role: "viewer".to_string(), - created_at: now, - expires_at: now + chrono::Duration::hours(24), - last_accessed: now, - }; - storage.create_session(&session).await.unwrap(); - }); - handles.push(handle); - } + // Create sessions concurrently + let mut handles = vec![]; + for i in 0..10 { + let storage = storage.clone(); + let handle = tokio::spawn(async move { + let session = SessionData { + session_token: format!("concurrent_{}", i), + user_id: None, + username: format!("user{}", i), + role: "viewer".to_string(), + created_at: now, + expires_at: now + chrono::Duration::hours(24), + last_accessed: now, + }; + storage.create_session(&session).await.unwrap(); + }); + handles.push(handle); + } - // Wait for all to complete - for handle in handles { - handle.await.unwrap(); - } + // Wait for all to complete + for handle in handles { + handle.await.unwrap(); + } - // Verify all sessions were created - for i in 0..10 { - assert!( - storage - .get_session(&format!("concurrent_{}", i)) - .await - .unwrap() - .is_some() - ); - } + // Verify all sessions were created + for i in 0..10 { + assert!( + storage + .get_session(&format!("concurrent_{}", i)) + .await + .unwrap() + .is_some() + ); + } } diff --git a/crates/pinakes-plugin-api/src/lib.rs b/crates/pinakes-plugin-api/src/lib.rs index abfa474..2fb96c6 100644 --- a/crates/pinakes-plugin-api/src/lib.rs +++ b/crates/pinakes-plugin-api/src/lib.rs @@ -1,12 +1,16 @@ //! Pinakes Plugin API //! //! This crate defines the stable plugin interface for Pinakes. -//! Plugins can extend Pinakes by implementing one or more of the provided traits. +//! Plugins can extend Pinakes by implementing one or more of the provided +//! traits. + +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; use async_trait::async_trait; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; use thiserror::Error; pub mod manifest; @@ -26,350 +30,361 @@ pub type PluginResult = Result; /// Errors that can occur in plugin operations #[derive(Debug, Error, Serialize, Deserialize)] pub enum PluginError { - #[error("Plugin initialization failed: {0}")] - InitializationFailed(String), + #[error("Plugin initialization failed: {0}")] + InitializationFailed(String), - #[error("Unsupported operation: {0}")] - UnsupportedOperation(String), + #[error("Unsupported operation: {0}")] + UnsupportedOperation(String), - #[error("Invalid input: {0}")] - InvalidInput(String), + #[error("Invalid input: {0}")] + InvalidInput(String), - #[error("IO error: {0}")] - IoError(String), + #[error("IO error: {0}")] + IoError(String), - #[error("Metadata extraction failed: {0}")] - MetadataExtractionFailed(String), + #[error("Metadata extraction failed: {0}")] + MetadataExtractionFailed(String), - #[error("Thumbnail generation failed: {0}")] - ThumbnailGenerationFailed(String), + #[error("Thumbnail generation failed: {0}")] + ThumbnailGenerationFailed(String), - #[error("Search backend error: {0}")] - SearchBackendError(String), + #[error("Search backend error: {0}")] + SearchBackendError(String), - #[error("Permission denied: {0}")] - PermissionDenied(String), + #[error("Permission denied: {0}")] + PermissionDenied(String), - #[error("Resource limit exceeded: {0}")] - ResourceLimitExceeded(String), + #[error("Resource limit exceeded: {0}")] + ResourceLimitExceeded(String), - #[error("Plugin error: {0}")] - Other(String), + #[error("Plugin error: {0}")] + Other(String), } /// Context provided to plugins during initialization #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginContext { - /// Plugin's data directory for persistent storage - pub data_dir: PathBuf, + /// Plugin's data directory for persistent storage + pub data_dir: PathBuf, - /// Plugin's cache directory for temporary data - pub cache_dir: PathBuf, + /// Plugin's cache directory for temporary data + pub cache_dir: PathBuf, - /// Plugin configuration from manifest - pub config: HashMap, + /// Plugin configuration from manifest + pub config: HashMap, - /// Capabilities granted to the plugin - pub capabilities: Capabilities, + /// Capabilities granted to the plugin + pub capabilities: Capabilities, } /// Capabilities that can be granted to plugins #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Capabilities { - /// Filesystem access permissions - pub filesystem: FilesystemCapability, + /// Filesystem access permissions + pub filesystem: FilesystemCapability, - /// Network access permissions - pub network: NetworkCapability, + /// Network access permissions + pub network: NetworkCapability, - /// Environment variable access - pub environment: EnvironmentCapability, + /// Environment variable access + pub environment: EnvironmentCapability, - /// Maximum memory usage in bytes - pub max_memory_bytes: Option, + /// Maximum memory usage in bytes + pub max_memory_bytes: Option, - /// Maximum CPU time in milliseconds - pub max_cpu_time_ms: Option, + /// Maximum CPU time in milliseconds + pub max_cpu_time_ms: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct FilesystemCapability { - /// Paths allowed for reading - pub read: Vec, + /// Paths allowed for reading + pub read: Vec, - /// Paths allowed for writing - pub write: Vec, + /// Paths allowed for writing + pub write: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct NetworkCapability { - /// Whether network access is allowed - pub enabled: bool, + /// Whether network access is allowed + pub enabled: bool, - /// Allowed domains (if None, all domains allowed when enabled) - pub allowed_domains: Option>, + /// Allowed domains (if None, all domains allowed when enabled) + pub allowed_domains: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct EnvironmentCapability { - /// Whether environment variable access is allowed - pub enabled: bool, + /// Whether environment variable access is allowed + pub enabled: bool, - /// Specific environment variables allowed (if None, all allowed when enabled) - pub allowed_vars: Option>, + /// Specific environment variables allowed (if None, all allowed when + /// enabled) + pub allowed_vars: Option>, } /// Base trait that all plugins must implement #[async_trait] pub trait Plugin: Send + Sync { - /// Get plugin metadata - fn metadata(&self) -> &PluginMetadata; + /// Get plugin metadata + fn metadata(&self) -> &PluginMetadata; - /// Initialize the plugin with provided context - async fn initialize(&mut self, context: PluginContext) -> PluginResult<()>; + /// Initialize the plugin with provided context + async fn initialize(&mut self, context: PluginContext) -> PluginResult<()>; - /// Shutdown the plugin gracefully - async fn shutdown(&mut self) -> PluginResult<()>; + /// Shutdown the plugin gracefully + async fn shutdown(&mut self) -> PluginResult<()>; - /// Get plugin health status - async fn health_check(&self) -> PluginResult; + /// Get plugin health status + async fn health_check(&self) -> PluginResult; } /// Plugin metadata #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginMetadata { - pub id: String, - pub name: String, - pub version: String, - pub author: String, - pub description: String, - pub api_version: String, - pub capabilities_required: Capabilities, + pub id: String, + pub name: String, + pub version: String, + pub author: String, + pub description: String, + pub api_version: String, + pub capabilities_required: Capabilities, } /// Health status of a plugin #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HealthStatus { - pub healthy: bool, - pub message: Option, - pub metrics: HashMap, + pub healthy: bool, + pub message: Option, + pub metrics: HashMap, } /// Trait for plugins that provide custom media type support #[async_trait] pub trait MediaTypeProvider: Plugin { - /// Get the list of media types this plugin supports - fn supported_media_types(&self) -> Vec; + /// Get the list of media types this plugin supports + fn supported_media_types(&self) -> Vec; - /// Check if this plugin can handle the given file - async fn can_handle(&self, path: &Path, mime_type: Option<&str>) -> PluginResult; + /// Check if this plugin can handle the given file + async fn can_handle( + &self, + path: &Path, + mime_type: Option<&str>, + ) -> PluginResult; } /// Definition of a custom media type #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MediaTypeDefinition { - /// Unique identifier for this media type - pub id: String, + /// Unique identifier for this media type + pub id: String, - /// Display name - pub name: String, + /// Display name + pub name: String, - /// Category (e.g., "video", "audio", "document", "image") - pub category: String, + /// Category (e.g., "video", "audio", "document", "image") + pub category: String, - /// File extensions associated with this type - pub extensions: Vec, + /// File extensions associated with this type + pub extensions: Vec, - /// MIME types associated with this type - pub mime_types: Vec, + /// MIME types associated with this type + pub mime_types: Vec, - /// Icon name or path - pub icon: Option, + /// Icon name or path + pub icon: Option, } /// Trait for plugins that extract metadata from files #[async_trait] pub trait MetadataExtractor: Plugin { - /// Extract metadata from a file - async fn extract_metadata(&self, path: &Path) -> PluginResult; + /// Extract metadata from a file + async fn extract_metadata( + &self, + path: &Path, + ) -> PluginResult; - /// Get the media types this extractor supports - fn supported_types(&self) -> Vec; + /// Get the media types this extractor supports + fn supported_types(&self) -> Vec; } /// Metadata extracted from a file #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ExtractedMetadata { - pub title: Option, - pub description: Option, - pub author: Option, - pub created_at: Option, - pub duration_secs: Option, - pub width: Option, - pub height: Option, - pub file_size_bytes: Option, - pub codec: Option, - pub bitrate_kbps: Option, + pub title: Option, + pub description: Option, + pub author: Option, + pub created_at: Option, + pub duration_secs: Option, + pub width: Option, + pub height: Option, + pub file_size_bytes: Option, + pub codec: Option, + pub bitrate_kbps: Option, - /// Custom metadata fields specific to this file type - pub custom_fields: HashMap, + /// Custom metadata fields specific to this file type + pub custom_fields: HashMap, - /// Tags extracted from the file - pub tags: Vec, + /// Tags extracted from the file + pub tags: Vec, } /// Trait for plugins that generate thumbnails #[async_trait] pub trait ThumbnailGenerator: Plugin { - /// Generate a thumbnail for the given file - async fn generate_thumbnail( - &self, - path: &Path, - output_path: &Path, - options: ThumbnailOptions, - ) -> PluginResult; + /// Generate a thumbnail for the given file + async fn generate_thumbnail( + &self, + path: &Path, + output_path: &Path, + options: ThumbnailOptions, + ) -> PluginResult; - /// Get the media types this generator supports - fn supported_types(&self) -> Vec; + /// Get the media types this generator supports + fn supported_types(&self) -> Vec; } /// Options for thumbnail generation #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ThumbnailOptions { - pub width: u32, - pub height: u32, - pub quality: u8, - pub format: ThumbnailFormat, + pub width: u32, + pub height: u32, + pub quality: u8, + pub format: ThumbnailFormat, } #[derive(Debug, Clone, Serialize, Deserialize)] pub enum ThumbnailFormat { - Jpeg, - Png, - WebP, + Jpeg, + Png, + WebP, } /// Information about a generated thumbnail #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ThumbnailInfo { - pub path: PathBuf, - pub width: u32, - pub height: u32, - pub file_size_bytes: u64, + pub path: PathBuf, + pub width: u32, + pub height: u32, + pub file_size_bytes: u64, } /// Trait for plugins that provide custom search backends #[async_trait] pub trait SearchBackend: Plugin { - /// Index a media item for search - async fn index_item(&self, item: &SearchIndexItem) -> PluginResult<()>; + /// Index a media item for search + async fn index_item(&self, item: &SearchIndexItem) -> PluginResult<()>; - /// Remove an item from the search index - async fn remove_item(&self, item_id: &str) -> PluginResult<()>; + /// Remove an item from the search index + async fn remove_item(&self, item_id: &str) -> PluginResult<()>; - /// Perform a search query - async fn search(&self, query: &SearchQuery) -> PluginResult>; + /// Perform a search query + async fn search( + &self, + query: &SearchQuery, + ) -> PluginResult>; - /// Get search statistics - async fn get_stats(&self) -> PluginResult; + /// Get search statistics + async fn get_stats(&self) -> PluginResult; } /// Item to be indexed for search #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SearchIndexItem { - pub id: String, - pub title: Option, - pub description: Option, - pub content: Option, - pub tags: Vec, - pub media_type: String, - pub metadata: HashMap, + pub id: String, + pub title: Option, + pub description: Option, + pub content: Option, + pub tags: Vec, + pub media_type: String, + pub metadata: HashMap, } /// Search query #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SearchQuery { - pub query_text: String, - pub filters: HashMap, - pub limit: usize, - pub offset: usize, + pub query_text: String, + pub filters: HashMap, + pub limit: usize, + pub offset: usize, } /// Search result #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SearchResult { - pub id: String, - pub score: f64, - pub highlights: Vec, + pub id: String, + pub score: f64, + pub highlights: Vec, } /// Search statistics #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SearchStats { - pub total_indexed: usize, - pub index_size_bytes: u64, - pub last_update: Option, + pub total_indexed: usize, + pub index_size_bytes: u64, + pub last_update: Option, } /// Trait for plugins that handle events #[async_trait] pub trait EventHandler: Plugin { - /// Handle an event - async fn handle_event(&self, event: &Event) -> PluginResult<()>; + /// Handle an event + async fn handle_event(&self, event: &Event) -> PluginResult<()>; - /// Get the event types this handler is interested in - fn interested_events(&self) -> Vec; + /// Get the event types this handler is interested in + fn interested_events(&self) -> Vec; } /// Event type #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] pub enum EventType { - MediaImported, - MediaUpdated, - MediaDeleted, - MediaTagged, - MediaUntagged, - CollectionCreated, - CollectionUpdated, - CollectionDeleted, - ScanStarted, - ScanCompleted, - Custom(String), + MediaImported, + MediaUpdated, + MediaDeleted, + MediaTagged, + MediaUntagged, + CollectionCreated, + CollectionUpdated, + CollectionDeleted, + ScanStarted, + ScanCompleted, + Custom(String), } /// Event data #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Event { - pub event_type: EventType, - pub timestamp: String, - pub data: HashMap, + pub event_type: EventType, + pub timestamp: String, + pub data: HashMap, } /// Trait for plugins that provide UI themes #[async_trait] pub trait ThemeProvider: Plugin { - /// Get available themes from this provider - fn get_themes(&self) -> Vec; + /// Get available themes from this provider + fn get_themes(&self) -> Vec; - /// Load a specific theme by ID - async fn load_theme(&self, theme_id: &str) -> PluginResult; + /// Load a specific theme by ID + async fn load_theme(&self, theme_id: &str) -> PluginResult; } /// Theme definition #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ThemeDefinition { - pub id: String, - pub name: String, - pub description: String, - pub author: String, - pub preview_url: Option, + pub id: String, + pub name: String, + pub description: String, + pub author: String, + pub preview_url: Option, } /// Theme data #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Theme { - pub id: String, - pub colors: HashMap, - pub fonts: HashMap, - pub custom_css: Option, + pub id: String, + pub colors: HashMap, + pub fonts: HashMap, + pub custom_css: Option, } diff --git a/crates/pinakes-plugin-api/src/manifest.rs b/crates/pinakes-plugin-api/src/manifest.rs index c9f0702..0e52a62 100644 --- a/crates/pinakes-plugin-api/src/manifest.rs +++ b/crates/pinakes-plugin-api/src/manifest.rs @@ -1,213 +1,218 @@ //! Plugin manifest parsing and validation +use std::{collections::HashMap, path::Path}; + use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::path::Path; use thiserror::Error; -use crate::{Capabilities, EnvironmentCapability, FilesystemCapability, NetworkCapability}; +use crate::{ + Capabilities, + EnvironmentCapability, + FilesystemCapability, + NetworkCapability, +}; /// Plugin manifest file format (TOML) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginManifest { - pub plugin: PluginInfo, + pub plugin: PluginInfo, - #[serde(default)] - pub capabilities: ManifestCapabilities, + #[serde(default)] + pub capabilities: ManifestCapabilities, - #[serde(default)] - pub config: HashMap, + #[serde(default)] + pub config: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginInfo { - pub name: String, - pub version: String, - pub api_version: String, - pub author: Option, - pub description: Option, - pub homepage: Option, - pub license: Option, + pub name: String, + pub version: String, + pub api_version: String, + pub author: Option, + pub description: Option, + pub homepage: Option, + pub license: Option, - /// Plugin kind(s) - e.g., ["media_type", "metadata_extractor"] - pub kind: Vec, + /// Plugin kind(s) - e.g., ["media_type", "metadata_extractor"] + pub kind: Vec, - /// Binary configuration - pub binary: PluginBinary, + /// Binary configuration + pub binary: PluginBinary, - /// Dependencies on other plugins - #[serde(default)] - pub dependencies: Vec, + /// Dependencies on other plugins + #[serde(default)] + pub dependencies: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginBinary { - /// Path to WASM binary - pub wasm: String, + /// Path to WASM binary + pub wasm: String, - /// Optional entrypoint function name (default: "_start") - pub entrypoint: Option, + /// Optional entrypoint function name (default: "_start") + pub entrypoint: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ManifestCapabilities { - #[serde(default)] - pub filesystem: ManifestFilesystemCapability, + #[serde(default)] + pub filesystem: ManifestFilesystemCapability, - #[serde(default)] - pub network: bool, + #[serde(default)] + pub network: bool, - #[serde(default)] - pub environment: Option>, + #[serde(default)] + pub environment: Option>, - #[serde(default)] - pub max_memory_mb: Option, + #[serde(default)] + pub max_memory_mb: Option, - #[serde(default)] - pub max_cpu_time_secs: Option, + #[serde(default)] + pub max_cpu_time_secs: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ManifestFilesystemCapability { - #[serde(default)] - pub read: Vec, + #[serde(default)] + pub read: Vec, - #[serde(default)] - pub write: Vec, + #[serde(default)] + pub write: Vec, } #[derive(Debug, Error)] pub enum ManifestError { - #[error("Failed to read manifest file: {0}")] - IoError(#[from] std::io::Error), + #[error("Failed to read manifest file: {0}")] + IoError(#[from] std::io::Error), - #[error("Failed to parse manifest: {0}")] - ParseError(#[from] toml::de::Error), + #[error("Failed to parse manifest: {0}")] + ParseError(#[from] toml::de::Error), - #[error("Invalid manifest: {0}")] - ValidationError(String), + #[error("Invalid manifest: {0}")] + ValidationError(String), } impl PluginManifest { - /// Load and parse a plugin manifest from a TOML file - pub fn from_file(path: &Path) -> Result { - let content = std::fs::read_to_string(path)?; - let manifest: Self = toml::from_str(&content)?; - manifest.validate()?; - Ok(manifest) + /// Load and parse a plugin manifest from a TOML file + pub fn from_file(path: &Path) -> Result { + let content = std::fs::read_to_string(path)?; + let manifest: Self = toml::from_str(&content)?; + manifest.validate()?; + Ok(manifest) + } + + /// Parse a manifest from TOML string + pub fn parse_str(content: &str) -> Result { + let manifest: Self = toml::from_str(content)?; + manifest.validate()?; + Ok(manifest) + } + + /// Validate the manifest + pub fn validate(&self) -> Result<(), ManifestError> { + // Check API version format + if self.plugin.api_version.is_empty() { + return Err(ManifestError::ValidationError( + "api_version cannot be empty".to_string(), + )); } - /// Parse a manifest from TOML string - pub fn parse_str(content: &str) -> Result { - let manifest: Self = toml::from_str(content)?; - manifest.validate()?; - Ok(manifest) + // Check version format (basic semver check) + if !self.plugin.version.contains('.') { + return Err(ManifestError::ValidationError( + "version must be in semver format (e.g., 1.0.0)".to_string(), + )); } - /// Validate the manifest - pub fn validate(&self) -> Result<(), ManifestError> { - // Check API version format - if self.plugin.api_version.is_empty() { - return Err(ManifestError::ValidationError( - "api_version cannot be empty".to_string(), - )); - } - - // Check version format (basic semver check) - if !self.plugin.version.contains('.') { - return Err(ManifestError::ValidationError( - "version must be in semver format (e.g., 1.0.0)".to_string(), - )); - } - - // Check that at least one kind is specified - if self.plugin.kind.is_empty() { - return Err(ManifestError::ValidationError( - "at least one plugin kind must be specified".to_string(), - )); - } - - // Check plugin kinds are valid - let valid_kinds = [ - "media_type", - "metadata_extractor", - "thumbnail_generator", - "search_backend", - "event_handler", - "theme_provider", - ]; - - for kind in &self.plugin.kind { - if !valid_kinds.contains(&kind.as_str()) { - return Err(ManifestError::ValidationError(format!( - "Invalid plugin kind: {}. Must be one of: {}", - kind, - valid_kinds.join(", ") - ))); - } - } - - // Check WASM binary path is not empty - if self.plugin.binary.wasm.is_empty() { - return Err(ManifestError::ValidationError( - "WASM binary path cannot be empty".to_string(), - )); - } - - Ok(()) + // Check that at least one kind is specified + if self.plugin.kind.is_empty() { + return Err(ManifestError::ValidationError( + "at least one plugin kind must be specified".to_string(), + )); } - /// Convert manifest capabilities to API capabilities - pub fn to_capabilities(&self) -> Capabilities { - Capabilities { - filesystem: FilesystemCapability { - read: self - .capabilities - .filesystem - .read - .iter() - .map(|s| s.into()) - .collect(), - write: self - .capabilities - .filesystem - .write - .iter() - .map(|s| s.into()) - .collect(), - }, - network: NetworkCapability { - enabled: self.capabilities.network, - allowed_domains: None, - }, - environment: EnvironmentCapability { - enabled: self.capabilities.environment.is_some(), - allowed_vars: self.capabilities.environment.clone(), - }, - max_memory_bytes: self - .capabilities - .max_memory_mb - .map(|mb| mb.saturating_mul(1024).saturating_mul(1024)), - max_cpu_time_ms: self - .capabilities - .max_cpu_time_secs - .map(|secs| secs.saturating_mul(1000)), - } + // Check plugin kinds are valid + let valid_kinds = [ + "media_type", + "metadata_extractor", + "thumbnail_generator", + "search_backend", + "event_handler", + "theme_provider", + ]; + + for kind in &self.plugin.kind { + if !valid_kinds.contains(&kind.as_str()) { + return Err(ManifestError::ValidationError(format!( + "Invalid plugin kind: {}. Must be one of: {}", + kind, + valid_kinds.join(", ") + ))); + } } - /// Get plugin ID (derived from name and version) - pub fn plugin_id(&self) -> String { - format!("{}@{}", self.plugin.name, self.plugin.version) + // Check WASM binary path is not empty + if self.plugin.binary.wasm.is_empty() { + return Err(ManifestError::ValidationError( + "WASM binary path cannot be empty".to_string(), + )); } + + Ok(()) + } + + /// Convert manifest capabilities to API capabilities + pub fn to_capabilities(&self) -> Capabilities { + Capabilities { + filesystem: FilesystemCapability { + read: self + .capabilities + .filesystem + .read + .iter() + .map(|s| s.into()) + .collect(), + write: self + .capabilities + .filesystem + .write + .iter() + .map(|s| s.into()) + .collect(), + }, + network: NetworkCapability { + enabled: self.capabilities.network, + allowed_domains: None, + }, + environment: EnvironmentCapability { + enabled: self.capabilities.environment.is_some(), + allowed_vars: self.capabilities.environment.clone(), + }, + max_memory_bytes: self + .capabilities + .max_memory_mb + .map(|mb| mb.saturating_mul(1024).saturating_mul(1024)), + max_cpu_time_ms: self + .capabilities + .max_cpu_time_secs + .map(|secs| secs.saturating_mul(1000)), + } + } + + /// Get plugin ID (derived from name and version) + pub fn plugin_id(&self) -> String { + format!("{}@{}", self.plugin.name, self.plugin.version) + } } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn test_parse_valid_manifest() { - let toml = r#" + #[test] + fn test_parse_valid_manifest() { + let toml = r#" [plugin] name = "heif-support" version = "1.0.0" @@ -223,15 +228,15 @@ wasm = "plugin.wasm" read = ["/tmp/pinakes-thumbnails"] "#; - let manifest = PluginManifest::parse_str(toml).unwrap(); - assert_eq!(manifest.plugin.name, "heif-support"); - assert_eq!(manifest.plugin.version, "1.0.0"); - assert_eq!(manifest.plugin.kind.len(), 2); - } + let manifest = PluginManifest::parse_str(toml).unwrap(); + assert_eq!(manifest.plugin.name, "heif-support"); + assert_eq!(manifest.plugin.version, "1.0.0"); + assert_eq!(manifest.plugin.kind.len(), 2); + } - #[test] - fn test_invalid_api_version() { - let toml = r#" + #[test] + fn test_invalid_api_version() { + let toml = r#" [plugin] name = "test" version = "1.0.0" @@ -242,12 +247,12 @@ kind = ["media_type"] wasm = "plugin.wasm" "#; - assert!(PluginManifest::parse_str(toml).is_err()); - } + assert!(PluginManifest::parse_str(toml).is_err()); + } - #[test] - fn test_invalid_kind() { - let toml = r#" + #[test] + fn test_invalid_kind() { + let toml = r#" [plugin] name = "test" version = "1.0.0" @@ -258,6 +263,6 @@ kind = ["invalid_kind"] wasm = "plugin.wasm" "#; - assert!(PluginManifest::parse_str(toml).is_err()); - } + assert!(PluginManifest::parse_str(toml).is_err()); + } } diff --git a/crates/pinakes-plugin-api/src/types.rs b/crates/pinakes-plugin-api/src/types.rs index 0cb0344..8975188 100644 --- a/crates/pinakes-plugin-api/src/types.rs +++ b/crates/pinakes-plugin-api/src/types.rs @@ -1,156 +1,157 @@ //! Shared types used across the plugin API -use serde::{Deserialize, Serialize}; use std::fmt; +use serde::{Deserialize, Serialize}; + /// Plugin identifier #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct PluginId(String); impl PluginId { - pub fn new(id: impl Into) -> Self { - Self(id.into()) - } + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } - pub fn as_str(&self) -> &str { - &self.0 - } + pub fn as_str(&self) -> &str { + &self.0 + } } impl fmt::Display for PluginId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } } impl From for PluginId { - fn from(s: String) -> Self { - Self(s) - } + fn from(s: String) -> Self { + Self(s) + } } impl From<&str> for PluginId { - fn from(s: &str) -> Self { - Self(s.to_string()) - } + fn from(s: &str) -> Self { + Self(s.to_string()) + } } /// Plugin lifecycle state #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum PluginState { - /// Plugin is being loaded - Loading, + /// Plugin is being loaded + Loading, - /// Plugin is initialized and ready - Ready, + /// Plugin is initialized and ready + Ready, - /// Plugin is running - Running, + /// Plugin is running + Running, - /// Plugin encountered an error - Error, + /// Plugin encountered an error + Error, - /// Plugin is being shut down - ShuttingDown, + /// Plugin is being shut down + ShuttingDown, - /// Plugin is stopped - Stopped, + /// Plugin is stopped + Stopped, } impl fmt::Display for PluginState { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Loading => write!(f, "loading"), - Self::Ready => write!(f, "ready"), - Self::Running => write!(f, "running"), - Self::Error => write!(f, "error"), - Self::ShuttingDown => write!(f, "shutting_down"), - Self::Stopped => write!(f, "stopped"), - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Loading => write!(f, "loading"), + Self::Ready => write!(f, "ready"), + Self::Running => write!(f, "running"), + Self::Error => write!(f, "error"), + Self::ShuttingDown => write!(f, "shutting_down"), + Self::Stopped => write!(f, "stopped"), } + } } /// Plugin installation status #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginStatus { - pub id: PluginId, - pub name: String, - pub version: String, - pub state: PluginState, - pub enabled: bool, - pub error_message: Option, + pub id: PluginId, + pub name: String, + pub version: String, + pub state: PluginState, + pub enabled: bool, + pub error_message: Option, } /// Version information #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Version { - pub major: u32, - pub minor: u32, - pub patch: u32, + pub major: u32, + pub minor: u32, + pub patch: u32, } impl Version { - pub fn new(major: u32, minor: u32, patch: u32) -> Self { - Self { - major, - minor, - patch, - } + pub fn new(major: u32, minor: u32, patch: u32) -> Self { + Self { + major, + minor, + patch, + } + } + + /// Parse version from string (e.g., "1.2.3") + pub fn parse(s: &str) -> Option { + let parts: Vec<&str> = s.split('.').collect(); + if parts.len() != 3 { + return None; } - /// Parse version from string (e.g., "1.2.3") - pub fn parse(s: &str) -> Option { - let parts: Vec<&str> = s.split('.').collect(); - if parts.len() != 3 { - return None; - } + Some(Self { + major: parts[0].parse().ok()?, + minor: parts[1].parse().ok()?, + patch: parts[2].parse().ok()?, + }) + } - Some(Self { - major: parts[0].parse().ok()?, - minor: parts[1].parse().ok()?, - patch: parts[2].parse().ok()?, - }) - } - - /// Check if this version is compatible with another version - /// Compatible if major version matches and minor version is >= required - pub fn is_compatible_with(&self, required: &Version) -> bool { - self.major == required.major && self.minor >= required.minor - } + /// Check if this version is compatible with another version + /// Compatible if major version matches and minor version is >= required + pub fn is_compatible_with(&self, required: &Version) -> bool { + self.major == required.major && self.minor >= required.minor + } } impl fmt::Display for Version { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}.{}.{}", self.major, self.minor, self.patch) - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}.{}.{}", self.major, self.minor, self.patch) + } } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn test_version_parse() { - let v = Version::parse("1.2.3").unwrap(); - assert_eq!(v.major, 1); - assert_eq!(v.minor, 2); - assert_eq!(v.patch, 3); - } + #[test] + fn test_version_parse() { + let v = Version::parse("1.2.3").unwrap(); + assert_eq!(v.major, 1); + assert_eq!(v.minor, 2); + assert_eq!(v.patch, 3); + } - #[test] - fn test_version_compatibility() { - let v1 = Version::new(1, 2, 0); - let v2 = Version::new(1, 1, 0); - let v3 = Version::new(2, 0, 0); + #[test] + fn test_version_compatibility() { + let v1 = Version::new(1, 2, 0); + let v2 = Version::new(1, 1, 0); + let v3 = Version::new(2, 0, 0); - assert!(v1.is_compatible_with(&v2)); // 1.2 >= 1.1 - assert!(!v2.is_compatible_with(&v1)); // 1.1 < 1.2 - assert!(!v1.is_compatible_with(&v3)); // Different major version - } + assert!(v1.is_compatible_with(&v2)); // 1.2 >= 1.1 + assert!(!v2.is_compatible_with(&v1)); // 1.1 < 1.2 + assert!(!v1.is_compatible_with(&v3)); // Different major version + } - #[test] - fn test_version_display() { - let v = Version::new(1, 2, 3); - assert_eq!(v.to_string(), "1.2.3"); - } + #[test] + fn test_version_display() { + let v = Version::new(1, 2, 3); + assert_eq!(v.to_string(), "1.2.3"); + } } diff --git a/crates/pinakes-plugin-api/src/wasm.rs b/crates/pinakes-plugin-api/src/wasm.rs index f9ec932..a1df9ea 100644 --- a/crates/pinakes-plugin-api/src/wasm.rs +++ b/crates/pinakes-plugin-api/src/wasm.rs @@ -1,186 +1,196 @@ //! WASM bridge types and helpers for plugin communication -use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use serde::{Deserialize, Serialize}; + /// Memory allocation info for passing data between host and plugin #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WasmMemoryAlloc { - /// Pointer to allocated memory - pub ptr: u32, + /// Pointer to allocated memory + pub ptr: u32, - /// Size of allocation in bytes - pub len: u32, + /// Size of allocation in bytes + pub len: u32, } /// Request from host to plugin #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HostRequest { - /// Request ID for matching with response - pub request_id: String, + /// Request ID for matching with response + pub request_id: String, - /// Method name being called - pub method: String, + /// Method name being called + pub method: String, - /// Serialized parameters - pub params: Vec, + /// Serialized parameters + pub params: Vec, } /// Response from plugin to host #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginResponse { - /// Request ID this response corresponds to - pub request_id: String, + /// Request ID this response corresponds to + pub request_id: String, - /// Success or error - pub result: WasmResult>, + /// Success or error + pub result: WasmResult>, } /// Result type for WASM operations #[derive(Debug, Clone, Serialize, Deserialize)] pub enum WasmResult { - Ok(T), - Err(String), + Ok(T), + Err(String), } impl From> for WasmResult { - fn from(r: Result) -> Self { - match r { - Ok(v) => WasmResult::Ok(v), - Err(e) => WasmResult::Err(e), - } + fn from(r: Result) -> Self { + match r { + Ok(v) => WasmResult::Ok(v), + Err(e) => WasmResult::Err(e), } + } } /// Host functions available to plugins pub mod host_functions { - /// Log a message from plugin - pub const LOG: &str = "host_log"; + /// Log a message from plugin + pub const LOG: &str = "host_log"; - /// Read a file (if permitted) - pub const READ_FILE: &str = "host_read_file"; + /// Read a file (if permitted) + pub const READ_FILE: &str = "host_read_file"; - /// Write a file (if permitted) - pub const WRITE_FILE: &str = "host_write_file"; + /// Write a file (if permitted) + pub const WRITE_FILE: &str = "host_write_file"; - /// Make an HTTP request (if permitted) - pub const HTTP_REQUEST: &str = "host_http_request"; + /// Make an HTTP request (if permitted) + pub const HTTP_REQUEST: &str = "host_http_request"; - /// Get configuration value - pub const GET_CONFIG: &str = "host_get_config"; + /// Get configuration value + pub const GET_CONFIG: &str = "host_get_config"; - /// Emit an event - pub const EMIT_EVENT: &str = "host_emit_event"; + /// Emit an event + pub const EMIT_EVENT: &str = "host_emit_event"; } /// Log level for plugin logging #[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub enum LogLevel { - Trace, - Debug, - Info, - Warn, - Error, + Trace, + Debug, + Info, + Warn, + Error, } /// Log message from plugin #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LogMessage { - pub level: LogLevel, - pub target: String, - pub message: String, - pub fields: HashMap, + pub level: LogLevel, + pub target: String, + pub message: String, + pub fields: HashMap, } /// HTTP request parameters #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HttpRequest { - pub method: String, - pub url: String, - pub headers: HashMap, - pub body: Option>, + pub method: String, + pub url: String, + pub headers: HashMap, + pub body: Option>, } /// HTTP response #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HttpResponse { - pub status: u16, - pub headers: HashMap, - pub body: Vec, + pub status: u16, + pub headers: HashMap, + pub body: Vec, } /// Helper functions for serializing/deserializing data across WASM boundary pub mod helpers { - use super::*; + use super::*; - /// Serialize a value to bytes for passing to WASM - pub fn serialize(value: &T) -> Result, String> { - serde_json::to_vec(value).map_err(|e| format!("Serialization error: {}", e)) - } + /// Serialize a value to bytes for passing to WASM + pub fn serialize(value: &T) -> Result, String> { + serde_json::to_vec(value).map_err(|e| format!("Serialization error: {}", e)) + } - /// Deserialize bytes from WASM to a value - pub fn deserialize Deserialize<'de>>(bytes: &[u8]) -> Result { - serde_json::from_slice(bytes).map_err(|e| format!("Deserialization error: {}", e)) - } + /// Deserialize bytes from WASM to a value + pub fn deserialize Deserialize<'de>>( + bytes: &[u8], + ) -> Result { + serde_json::from_slice(bytes) + .map_err(|e| format!("Deserialization error: {}", e)) + } - /// Create a success response - pub fn ok_response(request_id: String, value: &T) -> Result, String> { - let result = WasmResult::Ok(serialize(value)?); - let response = PluginResponse { request_id, result }; - serialize(&response) - } + /// Create a success response + pub fn ok_response( + request_id: String, + value: &T, + ) -> Result, String> { + let result = WasmResult::Ok(serialize(value)?); + let response = PluginResponse { request_id, result }; + serialize(&response) + } - /// Create an error response - pub fn error_response(request_id: String, error: String) -> Result, String> { - let result = WasmResult::>::Err(error); - let response = PluginResponse { request_id, result }; - serialize(&response) - } + /// Create an error response + pub fn error_response( + request_id: String, + error: String, + ) -> Result, String> { + let result = WasmResult::>::Err(error); + let response = PluginResponse { request_id, result }; + serialize(&response) + } } #[cfg(test)] mod tests { - use super::helpers::*; - use super::*; + use super::{helpers::*, *}; - #[test] - fn test_serialize_deserialize() { - let data = vec![1u8, 2, 3, 4]; - let bytes = serialize(&data).unwrap(); - let recovered: Vec = deserialize(&bytes).unwrap(); - assert_eq!(data, recovered); + #[test] + fn test_serialize_deserialize() { + let data = vec![1u8, 2, 3, 4]; + let bytes = serialize(&data).unwrap(); + let recovered: Vec = deserialize(&bytes).unwrap(); + assert_eq!(data, recovered); + } + + #[test] + fn test_ok_response() { + let request_id = "test-123".to_string(); + let value = "success"; + let response_bytes = ok_response(request_id.clone(), &value).unwrap(); + + let response: PluginResponse = deserialize(&response_bytes).unwrap(); + assert_eq!(response.request_id, request_id); + + match response.result { + WasmResult::Ok(data) => { + let recovered: String = deserialize(&data).unwrap(); + assert_eq!(recovered, value); + }, + WasmResult::Err(_) => panic!("Expected Ok result"), } + } - #[test] - fn test_ok_response() { - let request_id = "test-123".to_string(); - let value = "success"; - let response_bytes = ok_response(request_id.clone(), &value).unwrap(); + #[test] + fn test_error_response() { + let request_id = "test-456".to_string(); + let error_msg = "Something went wrong"; + let response_bytes = + error_response(request_id.clone(), error_msg.to_string()).unwrap(); - let response: PluginResponse = deserialize(&response_bytes).unwrap(); - assert_eq!(response.request_id, request_id); + let response: PluginResponse = deserialize(&response_bytes).unwrap(); + assert_eq!(response.request_id, request_id); - match response.result { - WasmResult::Ok(data) => { - let recovered: String = deserialize(&data).unwrap(); - assert_eq!(recovered, value); - } - WasmResult::Err(_) => panic!("Expected Ok result"), - } - } - - #[test] - fn test_error_response() { - let request_id = "test-456".to_string(); - let error_msg = "Something went wrong"; - let response_bytes = error_response(request_id.clone(), error_msg.to_string()).unwrap(); - - let response: PluginResponse = deserialize(&response_bytes).unwrap(); - assert_eq!(response.request_id, request_id); - - match response.result { - WasmResult::Err(msg) => assert_eq!(msg, error_msg), - WasmResult::Ok(_) => panic!("Expected Err result"), - } + match response.result { + WasmResult::Err(msg) => assert_eq!(msg, error_msg), + WasmResult::Ok(_) => panic!("Expected Err result"), } + } } diff --git a/crates/pinakes-plugin-api/tests/api.rs b/crates/pinakes-plugin-api/tests/api.rs index 1ff4289..36dc16e 100644 --- a/crates/pinakes-plugin-api/tests/api.rs +++ b/crates/pinakes-plugin-api/tests/api.rs @@ -1,575 +1,600 @@ +use std::{collections::HashMap, path::PathBuf}; + use async_trait::async_trait; -use pinakes_plugin_api::wasm::{HttpRequest, HttpResponse, LogLevel, LogMessage}; use pinakes_plugin_api::{ - Capabilities, EnvironmentCapability, Event, EventType, ExtractedMetadata, FilesystemCapability, - HealthStatus, MediaTypeDefinition, NetworkCapability, Plugin, PluginContext, PluginError, - PluginMetadata, PluginResult, SearchIndexItem, SearchQuery, SearchResult, SearchStats, - ThumbnailFormat, ThumbnailInfo, ThumbnailOptions, + Capabilities, + EnvironmentCapability, + Event, + EventType, + ExtractedMetadata, + FilesystemCapability, + HealthStatus, + MediaTypeDefinition, + NetworkCapability, + Plugin, + PluginContext, + PluginError, + PluginMetadata, + PluginResult, + SearchIndexItem, + SearchQuery, + SearchResult, + SearchStats, + ThumbnailFormat, + ThumbnailInfo, + ThumbnailOptions, + wasm::{HttpRequest, HttpResponse, LogLevel, LogMessage}, }; -use std::collections::HashMap; -use std::path::PathBuf; struct TestPlugin { - initialized: bool, - shutdown: bool, - health_status: HealthStatus, - metadata: PluginMetadata, + initialized: bool, + shutdown: bool, + health_status: HealthStatus, + metadata: PluginMetadata, } impl TestPlugin { - fn new() -> Self { - Self { - initialized: false, - shutdown: false, - health_status: HealthStatus { - healthy: true, - message: Some("OK".to_string()), - metrics: HashMap::new(), - }, - metadata: PluginMetadata { - id: "test-plugin".to_string(), - name: "Test Plugin".to_string(), - version: "1.0.0".to_string(), - author: "Test Author".to_string(), - description: "A test plugin".to_string(), - api_version: "1.0".to_string(), - capabilities_required: Capabilities::default(), - }, - } + fn new() -> Self { + Self { + initialized: false, + shutdown: false, + health_status: HealthStatus { + healthy: true, + message: Some("OK".to_string()), + metrics: HashMap::new(), + }, + metadata: PluginMetadata { + id: "test-plugin".to_string(), + name: "Test Plugin".to_string(), + version: "1.0.0".to_string(), + author: "Test Author".to_string(), + description: "A test plugin".to_string(), + api_version: "1.0".to_string(), + capabilities_required: Capabilities::default(), + }, } + } } #[async_trait] impl Plugin for TestPlugin { - fn metadata(&self) -> &PluginMetadata { - &self.metadata - } + fn metadata(&self) -> &PluginMetadata { + &self.metadata + } - async fn initialize(&mut self, _context: PluginContext) -> PluginResult<()> { - self.initialized = true; - Ok(()) - } + async fn initialize(&mut self, _context: PluginContext) -> PluginResult<()> { + self.initialized = true; + Ok(()) + } - async fn shutdown(&mut self) -> PluginResult<()> { - self.shutdown = true; - Ok(()) - } + async fn shutdown(&mut self) -> PluginResult<()> { + self.shutdown = true; + Ok(()) + } - async fn health_check(&self) -> PluginResult { - Ok(self.health_status.clone()) - } + async fn health_check(&self) -> PluginResult { + Ok(self.health_status.clone()) + } } #[tokio::test] async fn test_plugin_context_creation() { - let context = PluginContext { - data_dir: PathBuf::from("/data/test-plugin"), - cache_dir: PathBuf::from("/cache/test-plugin"), - config: HashMap::from([ - ("enabled".to_string(), serde_json::json!(true)), - ("max_items".to_string(), serde_json::json!(100)), - ]), - capabilities: Capabilities { - filesystem: FilesystemCapability { - read: vec![PathBuf::from("/data")], - write: vec![PathBuf::from("/data")], - }, - network: NetworkCapability { - enabled: true, - allowed_domains: Some(vec!["api.example.com".to_string()]), - }, - environment: EnvironmentCapability { - enabled: true, - allowed_vars: Some(vec!["API_KEY".to_string()]), - }, - max_memory_bytes: Some(256 * 1024 * 1024), - max_cpu_time_ms: Some(30000), - }, - }; + let context = PluginContext { + data_dir: PathBuf::from("/data/test-plugin"), + cache_dir: PathBuf::from("/cache/test-plugin"), + config: HashMap::from([ + ("enabled".to_string(), serde_json::json!(true)), + ("max_items".to_string(), serde_json::json!(100)), + ]), + capabilities: Capabilities { + filesystem: FilesystemCapability { + read: vec![PathBuf::from("/data")], + write: vec![PathBuf::from("/data")], + }, + network: NetworkCapability { + enabled: true, + allowed_domains: Some(vec!["api.example.com".to_string()]), + }, + environment: EnvironmentCapability { + enabled: true, + allowed_vars: Some(vec!["API_KEY".to_string()]), + }, + max_memory_bytes: Some(256 * 1024 * 1024), + max_cpu_time_ms: Some(30000), + }, + }; - assert_eq!(context.data_dir, PathBuf::from("/data/test-plugin")); - assert_eq!(context.cache_dir, PathBuf::from("/cache/test-plugin")); - assert_eq!( - context.config.get("enabled").unwrap(), - &serde_json::json!(true) - ); - assert!(context.capabilities.network.enabled); - assert_eq!( - context.capabilities.max_memory_bytes, - Some(256 * 1024 * 1024) - ); + assert_eq!(context.data_dir, PathBuf::from("/data/test-plugin")); + assert_eq!(context.cache_dir, PathBuf::from("/cache/test-plugin")); + assert_eq!( + context.config.get("enabled").unwrap(), + &serde_json::json!(true) + ); + assert!(context.capabilities.network.enabled); + assert_eq!( + context.capabilities.max_memory_bytes, + Some(256 * 1024 * 1024) + ); } #[tokio::test] async fn test_plugin_context_fields() { - let context = PluginContext { - data_dir: PathBuf::from("/custom/data"), - cache_dir: PathBuf::from("/custom/cache"), - config: HashMap::new(), - capabilities: Capabilities::default(), - }; + let context = PluginContext { + data_dir: PathBuf::from("/custom/data"), + cache_dir: PathBuf::from("/custom/cache"), + config: HashMap::new(), + capabilities: Capabilities::default(), + }; - assert_eq!(context.data_dir, PathBuf::from("/custom/data")); - assert_eq!(context.cache_dir, PathBuf::from("/custom/cache")); + assert_eq!(context.data_dir, PathBuf::from("/custom/data")); + assert_eq!(context.cache_dir, PathBuf::from("/custom/cache")); } #[tokio::test] async fn test_plugin_lifecycle() { - let mut plugin = TestPlugin::new(); + let mut plugin = TestPlugin::new(); - assert!(!plugin.initialized); - assert!(!plugin.shutdown); + assert!(!plugin.initialized); + assert!(!plugin.shutdown); - let context = PluginContext { - data_dir: PathBuf::from("/data"), - cache_dir: PathBuf::from("/cache"), - config: HashMap::new(), - capabilities: Capabilities::default(), - }; - plugin.initialize(context).await.unwrap(); - assert!(plugin.initialized); + let context = PluginContext { + data_dir: PathBuf::from("/data"), + cache_dir: PathBuf::from("/cache"), + config: HashMap::new(), + capabilities: Capabilities::default(), + }; + plugin.initialize(context).await.unwrap(); + assert!(plugin.initialized); - let health = plugin.health_check().await.unwrap(); - assert!(health.healthy); - assert_eq!(health.message, Some("OK".to_string())); + let health = plugin.health_check().await.unwrap(); + assert!(health.healthy); + assert_eq!(health.message, Some("OK".to_string())); - plugin.shutdown().await.unwrap(); - assert!(plugin.shutdown); + plugin.shutdown().await.unwrap(); + assert!(plugin.shutdown); } #[tokio::test] async fn test_extracted_metadata_structure() { - let metadata = ExtractedMetadata { - title: Some("Test Document".to_string()), - description: Some("A test document".to_string()), - author: Some("John Doe".to_string()), - created_at: Some("2024-01-15T10:30:00Z".to_string()), - duration_secs: Some(120.5), - width: Some(1920), - height: Some(1080), - file_size_bytes: Some(1_500_000), - codec: Some("h264".to_string()), - bitrate_kbps: Some(5000), - custom_fields: HashMap::from([ - ("color_space".to_string(), serde_json::json!("sRGB")), - ("orientation".to_string(), serde_json::json!(90)), - ]), - tags: vec!["test".to_string(), "document".to_string()], - }; + let metadata = ExtractedMetadata { + title: Some("Test Document".to_string()), + description: Some("A test document".to_string()), + author: Some("John Doe".to_string()), + created_at: Some("2024-01-15T10:30:00Z".to_string()), + duration_secs: Some(120.5), + width: Some(1920), + height: Some(1080), + file_size_bytes: Some(1_500_000), + codec: Some("h264".to_string()), + bitrate_kbps: Some(5000), + custom_fields: HashMap::from([ + ("color_space".to_string(), serde_json::json!("sRGB")), + ("orientation".to_string(), serde_json::json!(90)), + ]), + tags: vec!["test".to_string(), "document".to_string()], + }; - assert_eq!(metadata.title, Some("Test Document".to_string())); - assert_eq!(metadata.width, Some(1920)); - assert_eq!(metadata.height, Some(1080)); - assert_eq!(metadata.tags.len(), 2); - assert_eq!(metadata.custom_fields.get("color_space").unwrap(), "sRGB"); + assert_eq!(metadata.title, Some("Test Document".to_string())); + assert_eq!(metadata.width, Some(1920)); + assert_eq!(metadata.height, Some(1080)); + assert_eq!(metadata.tags.len(), 2); + assert_eq!(metadata.custom_fields.get("color_space").unwrap(), "sRGB"); } #[tokio::test] async fn test_search_query_serialization() { - let query = SearchQuery { - query_text: "nature landscape".to_string(), - filters: HashMap::from([ - ("type".to_string(), serde_json::json!("image")), - ("year".to_string(), serde_json::json!(2023)), - ]), - limit: 50, - offset: 0, - }; + let query = SearchQuery { + query_text: "nature landscape".to_string(), + filters: HashMap::from([ + ("type".to_string(), serde_json::json!("image")), + ("year".to_string(), serde_json::json!(2023)), + ]), + limit: 50, + offset: 0, + }; - let serialized = serde_json::to_string(&query).unwrap(); - let deserialized: SearchQuery = serde_json::from_str(&serialized).unwrap(); + let serialized = serde_json::to_string(&query).unwrap(); + let deserialized: SearchQuery = serde_json::from_str(&serialized).unwrap(); - assert_eq!(deserialized.query_text, "nature landscape"); - assert_eq!(deserialized.limit, 50); - assert_eq!(deserialized.offset, 0); - assert_eq!(deserialized.filters.get("type").unwrap(), "image"); + assert_eq!(deserialized.query_text, "nature landscape"); + assert_eq!(deserialized.limit, 50); + assert_eq!(deserialized.offset, 0); + assert_eq!(deserialized.filters.get("type").unwrap(), "image"); } #[tokio::test] async fn test_search_result_serialization() { - let result = SearchResult { - id: "media-123".to_string(), - score: 0.95, - highlights: vec!["matched content".to_string()], - }; + let result = SearchResult { + id: "media-123".to_string(), + score: 0.95, + highlights: vec!["matched content".to_string()], + }; - let serialized = serde_json::to_string(&result).unwrap(); - let deserialized: SearchResult = serde_json::from_str(&serialized).unwrap(); + let serialized = serde_json::to_string(&result).unwrap(); + let deserialized: SearchResult = serde_json::from_str(&serialized).unwrap(); - assert_eq!(deserialized.id, "media-123"); - assert!((deserialized.score - 0.95).abs() < 0.001); - assert_eq!(deserialized.highlights.len(), 1); + assert_eq!(deserialized.id, "media-123"); + assert!((deserialized.score - 0.95).abs() < 0.001); + assert_eq!(deserialized.highlights.len(), 1); } #[tokio::test] async fn test_search_stats_serialization() { - let stats = SearchStats { - total_indexed: 10_000, - index_size_bytes: 500_000_000, - last_update: Some("2024-01-15T12:00:00Z".to_string()), - }; + let stats = SearchStats { + total_indexed: 10_000, + index_size_bytes: 500_000_000, + last_update: Some("2024-01-15T12:00:00Z".to_string()), + }; - let serialized = serde_json::to_string(&stats).unwrap(); - let deserialized: SearchStats = serde_json::from_str(&serialized).unwrap(); + let serialized = serde_json::to_string(&stats).unwrap(); + let deserialized: SearchStats = serde_json::from_str(&serialized).unwrap(); - assert_eq!(deserialized.total_indexed, 10_000); - assert_eq!(deserialized.index_size_bytes, 500_000_000); - assert!(deserialized.last_update.is_some()); + assert_eq!(deserialized.total_indexed, 10_000); + assert_eq!(deserialized.index_size_bytes, 500_000_000); + assert!(deserialized.last_update.is_some()); } #[tokio::test] async fn test_thumbnail_options_serialization() { - let options = ThumbnailOptions { - width: 320, - height: 240, - quality: 85, - format: ThumbnailFormat::Jpeg, - }; + let options = ThumbnailOptions { + width: 320, + height: 240, + quality: 85, + format: ThumbnailFormat::Jpeg, + }; - let serialized = serde_json::to_string(&options).unwrap(); - let deserialized: ThumbnailOptions = serde_json::from_str(&serialized).unwrap(); + let serialized = serde_json::to_string(&options).unwrap(); + let deserialized: ThumbnailOptions = + serde_json::from_str(&serialized).unwrap(); - assert_eq!(deserialized.width, 320); - assert_eq!(deserialized.height, 240); - assert_eq!(deserialized.quality, 85); - assert!(matches!(deserialized.format, ThumbnailFormat::Jpeg)); + assert_eq!(deserialized.width, 320); + assert_eq!(deserialized.height, 240); + assert_eq!(deserialized.quality, 85); + assert!(matches!(deserialized.format, ThumbnailFormat::Jpeg)); } #[tokio::test] async fn test_thumbnail_format_variants() { - for format in [ - ThumbnailFormat::Jpeg, - ThumbnailFormat::Png, - ThumbnailFormat::WebP, - ] { - let options = ThumbnailOptions { - width: 100, - height: 100, - quality: 90, - format, - }; - let serialized = serde_json::to_string(&options).unwrap(); - let deserialized: ThumbnailOptions = serde_json::from_str(&serialized).unwrap(); - assert!(matches!(deserialized.format, _)); - } + for format in [ + ThumbnailFormat::Jpeg, + ThumbnailFormat::Png, + ThumbnailFormat::WebP, + ] { + let options = ThumbnailOptions { + width: 100, + height: 100, + quality: 90, + format, + }; + let serialized = serde_json::to_string(&options).unwrap(); + let deserialized: ThumbnailOptions = + serde_json::from_str(&serialized).unwrap(); + assert!(matches!(deserialized.format, _)); + } } #[tokio::test] async fn test_thumbnail_info_structure() { - let info = ThumbnailInfo { - path: PathBuf::from("/thumbnails/test.jpg"), - width: 320, - height: 240, - file_size_bytes: 45_000, - }; + let info = ThumbnailInfo { + path: PathBuf::from("/thumbnails/test.jpg"), + width: 320, + height: 240, + file_size_bytes: 45_000, + }; - assert_eq!(info.path, PathBuf::from("/thumbnails/test.jpg")); - assert_eq!(info.width, 320); - assert_eq!(info.height, 240); - assert_eq!(info.file_size_bytes, 45_000); + assert_eq!(info.path, PathBuf::from("/thumbnails/test.jpg")); + assert_eq!(info.width, 320); + assert_eq!(info.height, 240); + assert_eq!(info.file_size_bytes, 45_000); } #[tokio::test] async fn test_media_type_definition() { - let media_type = MediaTypeDefinition { - id: "custom-image format".to_string(), - name: "Custom Image Format".to_string(), - category: "image".to_string(), - extensions: vec!["cif".to_string(), "custom-img".to_string()], - mime_types: vec!["image/x-custom".to_string()], - icon: Some("image-cif".to_string()), - }; + let media_type = MediaTypeDefinition { + id: "custom-image format".to_string(), + name: "Custom Image Format".to_string(), + category: "image".to_string(), + extensions: vec!["cif".to_string(), "custom-img".to_string()], + mime_types: vec!["image/x-custom".to_string()], + icon: Some("image-cif".to_string()), + }; - assert_eq!(media_type.id, "custom-image format"); - assert_eq!(media_type.category, "image"); - assert_eq!(media_type.extensions.len(), 2); - assert!(media_type.icon.is_some()); + assert_eq!(media_type.id, "custom-image format"); + assert_eq!(media_type.category, "image"); + assert_eq!(media_type.extensions.len(), 2); + assert!(media_type.icon.is_some()); } #[tokio::test] async fn test_event_type_variants() { - let variants: Vec = vec![ - EventType::MediaImported, - EventType::MediaUpdated, - EventType::MediaDeleted, - EventType::MediaTagged, - EventType::MediaUntagged, - EventType::CollectionCreated, - EventType::CollectionUpdated, - EventType::CollectionDeleted, - EventType::ScanStarted, - EventType::ScanCompleted, - EventType::Custom("custom".to_string()), - ]; + let variants: Vec = vec![ + EventType::MediaImported, + EventType::MediaUpdated, + EventType::MediaDeleted, + EventType::MediaTagged, + EventType::MediaUntagged, + EventType::CollectionCreated, + EventType::CollectionUpdated, + EventType::CollectionDeleted, + EventType::ScanStarted, + EventType::ScanCompleted, + EventType::Custom("custom".to_string()), + ]; - for event_type in &variants { - let serialized = serde_json::to_string(event_type).unwrap(); - let _deserialized: EventType = serde_json::from_str(&serialized).unwrap(); - } + for event_type in &variants { + let serialized = serde_json::to_string(event_type).unwrap(); + let _deserialized: EventType = serde_json::from_str(&serialized).unwrap(); + } } #[tokio::test] async fn test_event_serialization() { - let event = Event { - event_type: EventType::MediaImported, - timestamp: "2024-01-15T10:00:00Z".to_string(), - data: HashMap::from([ - ("path".to_string(), serde_json::json!("/media/test.jpg")), - ("size".to_string(), serde_json::json!(1024)), - ]), - }; + let event = Event { + event_type: EventType::MediaImported, + timestamp: "2024-01-15T10:00:00Z".to_string(), + data: HashMap::from([ + ("path".to_string(), serde_json::json!("/media/test.jpg")), + ("size".to_string(), serde_json::json!(1024)), + ]), + }; - let serialized = serde_json::to_string(&event).unwrap(); - let deserialized: Event = serde_json::from_str(&serialized).unwrap(); + let serialized = serde_json::to_string(&event).unwrap(); + let deserialized: Event = serde_json::from_str(&serialized).unwrap(); - assert!(matches!(deserialized.event_type, EventType::MediaImported)); - assert_eq!(deserialized.timestamp, "2024-01-15T10:00:00Z"); + assert!(matches!(deserialized.event_type, EventType::MediaImported)); + assert_eq!(deserialized.timestamp, "2024-01-15T10:00:00Z"); } #[tokio::test] async fn test_http_request_serialization() { - let request = HttpRequest { - method: "GET".to_string(), - url: "https://api.example.com/data".to_string(), - headers: HashMap::from([ - ("Authorization".to_string(), "Bearer token".to_string()), - ("Content-Type".to_string(), "application/json".to_string()), - ]), - body: None, - }; + let request = HttpRequest { + method: "GET".to_string(), + url: "https://api.example.com/data".to_string(), + headers: HashMap::from([ + ("Authorization".to_string(), "Bearer token".to_string()), + ("Content-Type".to_string(), "application/json".to_string()), + ]), + body: None, + }; - let serialized = serde_json::to_string(&request).unwrap(); - let deserialized: HttpRequest = serde_json::from_str(&serialized).unwrap(); + let serialized = serde_json::to_string(&request).unwrap(); + let deserialized: HttpRequest = serde_json::from_str(&serialized).unwrap(); - assert_eq!(deserialized.method, "GET"); - assert_eq!(deserialized.url, "https://api.example.com/data"); - assert!(deserialized.body.is_none()); + assert_eq!(deserialized.method, "GET"); + assert_eq!(deserialized.url, "https://api.example.com/data"); + assert!(deserialized.body.is_none()); } #[tokio::test] async fn test_http_response_serialization() { - let response = HttpResponse { - status: 200, - headers: HashMap::from([("Content-Type".to_string(), "application/json".to_string())]), - body: b"{\"success\": true}".to_vec(), - }; + let response = HttpResponse { + status: 200, + headers: HashMap::from([( + "Content-Type".to_string(), + "application/json".to_string(), + )]), + body: b"{\"success\": true}".to_vec(), + }; - let serialized = serde_json::to_string(&response).unwrap(); - let deserialized: HttpResponse = serde_json::from_str(&serialized).unwrap(); + let serialized = serde_json::to_string(&response).unwrap(); + let deserialized: HttpResponse = serde_json::from_str(&serialized).unwrap(); - assert_eq!(deserialized.status, 200); - assert_eq!(deserialized.body, b"{\"success\": true}"); + assert_eq!(deserialized.status, 200); + assert_eq!(deserialized.body, b"{\"success\": true}"); } #[tokio::test] async fn test_log_message_serialization() { - let message = LogMessage { - level: LogLevel::Info, - target: "plugin::metadata".to_string(), - message: "Metadata extraction complete".to_string(), - fields: HashMap::from([ - ("file_count".to_string(), "42".to_string()), - ("duration_ms".to_string(), "150".to_string()), - ]), - }; + let message = LogMessage { + level: LogLevel::Info, + target: "plugin::metadata".to_string(), + message: "Metadata extraction complete".to_string(), + fields: HashMap::from([ + ("file_count".to_string(), "42".to_string()), + ("duration_ms".to_string(), "150".to_string()), + ]), + }; - let serialized = serde_json::to_string(&message).unwrap(); - let deserialized: LogMessage = serde_json::from_str(&serialized).unwrap(); + let serialized = serde_json::to_string(&message).unwrap(); + let deserialized: LogMessage = serde_json::from_str(&serialized).unwrap(); - assert!(matches!(deserialized.level, LogLevel::Info)); - assert_eq!(deserialized.target, "plugin::metadata"); - assert_eq!(deserialized.message, "Metadata extraction complete"); + assert!(matches!(deserialized.level, LogLevel::Info)); + assert_eq!(deserialized.target, "plugin::metadata"); + assert_eq!(deserialized.message, "Metadata extraction complete"); } #[tokio::test] async fn test_log_level_variants() { - let levels = [ - LogLevel::Trace, - LogLevel::Debug, - LogLevel::Info, - LogLevel::Warn, - LogLevel::Error, - ]; + let levels = [ + LogLevel::Trace, + LogLevel::Debug, + LogLevel::Info, + LogLevel::Warn, + LogLevel::Error, + ]; - for level in levels { - let serialized = serde_json::to_string(&level).unwrap(); - let _deserialized: LogLevel = serde_json::from_str(&serialized).unwrap(); - } + for level in levels { + let serialized = serde_json::to_string(&level).unwrap(); + let _deserialized: LogLevel = serde_json::from_str(&serialized).unwrap(); + } } #[tokio::test] async fn test_plugin_error_variants() { - let errors: Vec = vec![ - PluginError::InitializationFailed("WASM load failed".to_string()), - PluginError::UnsupportedOperation("Custom search not implemented".to_string()), - PluginError::InvalidInput("Invalid file path".to_string()), - PluginError::IoError("File not found".to_string()), - PluginError::MetadataExtractionFailed("Parse error".to_string()), - PluginError::ThumbnailGenerationFailed("Format not supported".to_string()), - PluginError::SearchBackendError("Index corrupted".to_string()), - PluginError::PermissionDenied("Access denied to /data".to_string()), - PluginError::ResourceLimitExceeded("Memory limit exceeded".to_string()), - PluginError::Other("Unknown error".to_string()), - ]; + let errors: Vec = vec![ + PluginError::InitializationFailed("WASM load failed".to_string()), + PluginError::UnsupportedOperation( + "Custom search not implemented".to_string(), + ), + PluginError::InvalidInput("Invalid file path".to_string()), + PluginError::IoError("File not found".to_string()), + PluginError::MetadataExtractionFailed("Parse error".to_string()), + PluginError::ThumbnailGenerationFailed("Format not supported".to_string()), + PluginError::SearchBackendError("Index corrupted".to_string()), + PluginError::PermissionDenied("Access denied to /data".to_string()), + PluginError::ResourceLimitExceeded("Memory limit exceeded".to_string()), + PluginError::Other("Unknown error".to_string()), + ]; - for error in errors { - let serialized = serde_json::to_string(&error).unwrap(); - let deserialized: PluginError = serde_json::from_str(&serialized).unwrap(); - assert_eq!(format!("{}", error), format!("{}", deserialized)); - } + for error in errors { + let serialized = serde_json::to_string(&error).unwrap(); + let deserialized: PluginError = serde_json::from_str(&serialized).unwrap(); + assert_eq!(format!("{}", error), format!("{}", deserialized)); + } } #[tokio::test] async fn test_search_index_item_serialization() { - let item = SearchIndexItem { - id: "media-456".to_string(), - title: Some("Summer Vacation".to_string()), - description: Some("Photos from summer vacation 2023".to_string()), - content: None, - tags: vec![ - "vacation".to_string(), - "summer".to_string(), - "photos".to_string(), - ], - media_type: "image/jpeg".to_string(), - metadata: HashMap::from([ - ("camera".to_string(), serde_json::json!("Canon EOS R5")), - ("location".to_string(), serde_json::json!("Beach")), - ]), - }; + let item = SearchIndexItem { + id: "media-456".to_string(), + title: Some("Summer Vacation".to_string()), + description: Some("Photos from summer vacation 2023".to_string()), + content: None, + tags: vec![ + "vacation".to_string(), + "summer".to_string(), + "photos".to_string(), + ], + media_type: "image/jpeg".to_string(), + metadata: HashMap::from([ + ("camera".to_string(), serde_json::json!("Canon EOS R5")), + ("location".to_string(), serde_json::json!("Beach")), + ]), + }; - let serialized = serde_json::to_string(&item).unwrap(); - let deserialized: SearchIndexItem = serde_json::from_str(&serialized).unwrap(); + let serialized = serde_json::to_string(&item).unwrap(); + let deserialized: SearchIndexItem = + serde_json::from_str(&serialized).unwrap(); - assert_eq!(deserialized.id, "media-456"); - assert_eq!(deserialized.title, Some("Summer Vacation".to_string())); - assert_eq!(deserialized.tags.len(), 3); - assert_eq!(deserialized.media_type, "image/jpeg"); + assert_eq!(deserialized.id, "media-456"); + assert_eq!(deserialized.title, Some("Summer Vacation".to_string())); + assert_eq!(deserialized.tags.len(), 3); + assert_eq!(deserialized.media_type, "image/jpeg"); } #[tokio::test] async fn test_health_status_variants() { - let healthy = HealthStatus { - healthy: true, - message: Some("All systems operational".to_string()), - metrics: HashMap::from([ - ("items_processed".to_string(), 1000.0), - ("avg_process_time_ms".to_string(), 45.5), - ]), - }; - assert!(healthy.healthy); + let healthy = HealthStatus { + healthy: true, + message: Some("All systems operational".to_string()), + metrics: HashMap::from([ + ("items_processed".to_string(), 1000.0), + ("avg_process_time_ms".to_string(), 45.5), + ]), + }; + assert!(healthy.healthy); - let unhealthy = HealthStatus { - healthy: false, - message: Some("Database connection failed".to_string()), - metrics: HashMap::new(), - }; - assert!(!unhealthy.healthy); - assert_eq!( - unhealthy.message, - Some("Database connection failed".to_string()) - ); + let unhealthy = HealthStatus { + healthy: false, + message: Some("Database connection failed".to_string()), + metrics: HashMap::new(), + }; + assert!(!unhealthy.healthy); + assert_eq!( + unhealthy.message, + Some("Database connection failed".to_string()) + ); } #[tokio::test] async fn test_capabilities_merge() { - let mut caps = Capabilities::default(); - assert!(!caps.network.enabled); + let mut caps = Capabilities::default(); + assert!(!caps.network.enabled); - caps.network.enabled = true; - caps.max_memory_bytes = Some(512 * 1024 * 1024); + caps.network.enabled = true; + caps.max_memory_bytes = Some(512 * 1024 * 1024); - assert!(caps.network.enabled); - assert_eq!(caps.max_memory_bytes, Some(512 * 1024 * 1024)); + assert!(caps.network.enabled); + assert_eq!(caps.max_memory_bytes, Some(512 * 1024 * 1024)); } #[tokio::test] async fn test_filesystem_capability_paths() { - let caps = FilesystemCapability { - read: vec![ - PathBuf::from("/data"), - PathBuf::from("/media"), - PathBuf::from("/home/user/uploads"), - ], - write: vec![PathBuf::from("/tmp/pinakes")], - }; + let caps = FilesystemCapability { + read: vec![ + PathBuf::from("/data"), + PathBuf::from("/media"), + PathBuf::from("/home/user/uploads"), + ], + write: vec![PathBuf::from("/tmp/pinakes")], + }; - assert_eq!(caps.read.len(), 3); - assert_eq!(caps.write.len(), 1); - assert!(caps.read.contains(&PathBuf::from("/data"))); - assert!(caps.write.contains(&PathBuf::from("/tmp/pinakes"))); + assert_eq!(caps.read.len(), 3); + assert_eq!(caps.write.len(), 1); + assert!(caps.read.contains(&PathBuf::from("/data"))); + assert!(caps.write.contains(&PathBuf::from("/tmp/pinakes"))); } #[tokio::test] async fn test_plugin_metadata_structure() { - let metadata = PluginMetadata { - id: "test-plugin".to_string(), - name: "Test Plugin".to_string(), - version: "1.0.0".to_string(), - author: "Test Author".to_string(), - description: "A test plugin for unit testing".to_string(), - api_version: "1.0".to_string(), - capabilities_required: Capabilities::default(), - }; + let metadata = PluginMetadata { + id: "test-plugin".to_string(), + name: "Test Plugin".to_string(), + version: "1.0.0".to_string(), + author: "Test Author".to_string(), + description: "A test plugin for unit testing".to_string(), + api_version: "1.0".to_string(), + capabilities_required: Capabilities::default(), + }; - assert_eq!(metadata.id, "test-plugin"); - assert_eq!(metadata.version, "1.0.0"); - assert_eq!(metadata.api_version, "1.0"); + assert_eq!(metadata.id, "test-plugin"); + assert_eq!(metadata.version, "1.0.0"); + assert_eq!(metadata.api_version, "1.0"); } #[tokio::test] async fn test_network_capability_defaults() { - let network = NetworkCapability::default(); - assert!(!network.enabled); - assert!(network.allowed_domains.is_none()); + let network = NetworkCapability::default(); + assert!(!network.enabled); + assert!(network.allowed_domains.is_none()); } #[tokio::test] async fn test_environment_capability_defaults() { - let env = EnvironmentCapability::default(); - assert!(!env.enabled); - assert!(env.allowed_vars.is_none()); + let env = EnvironmentCapability::default(); + assert!(!env.enabled); + assert!(env.allowed_vars.is_none()); } #[tokio::test] async fn test_extracted_metadata_default() { - let metadata = ExtractedMetadata::default(); + let metadata = ExtractedMetadata::default(); - assert!(metadata.title.is_none()); - assert!(metadata.description.is_none()); - assert!(metadata.author.is_none()); - assert!(metadata.duration_secs.is_none()); - assert!(metadata.width.is_none()); - assert!(metadata.height.is_none()); - assert!(metadata.custom_fields.is_empty()); - assert!(metadata.tags.is_empty()); + assert!(metadata.title.is_none()); + assert!(metadata.description.is_none()); + assert!(metadata.author.is_none()); + assert!(metadata.duration_secs.is_none()); + assert!(metadata.width.is_none()); + assert!(metadata.height.is_none()); + assert!(metadata.custom_fields.is_empty()); + assert!(metadata.tags.is_empty()); } #[tokio::test] async fn test_search_query_structure() { - let query = SearchQuery { - query_text: "test query".to_string(), - filters: HashMap::new(), - limit: 10, - offset: 0, - }; + let query = SearchQuery { + query_text: "test query".to_string(), + filters: HashMap::new(), + limit: 10, + offset: 0, + }; - assert!(!query.query_text.is_empty()); - assert_eq!(query.limit, 10); - assert_eq!(query.offset, 0); + assert!(!query.query_text.is_empty()); + assert_eq!(query.limit, 10); + assert_eq!(query.offset, 0); } #[tokio::test] async fn test_thumbnail_options_structure() { - let options = ThumbnailOptions { - width: 640, - height: 480, - quality: 75, - format: ThumbnailFormat::Png, - }; + let options = ThumbnailOptions { + width: 640, + height: 480, + quality: 75, + format: ThumbnailFormat::Png, + }; - assert_eq!(options.width, 640); - assert_eq!(options.height, 480); - assert_eq!(options.quality, 75); - assert!(matches!(options.format, ThumbnailFormat::Png)); + assert_eq!(options.width, 640); + assert_eq!(options.height, 480); + assert_eq!(options.quality, 75); + assert!(matches!(options.format, ThumbnailFormat::Png)); } diff --git a/crates/pinakes-plugin-api/tests/validate.rs b/crates/pinakes-plugin-api/tests/validate.rs index 3b6e416..e73cd49 100644 --- a/crates/pinakes-plugin-api/tests/validate.rs +++ b/crates/pinakes-plugin-api/tests/validate.rs @@ -1,67 +1,69 @@ -use pinakes_plugin_api::PluginManifest; use std::path::PathBuf; +use pinakes_plugin_api::PluginManifest; + #[test] fn test_markdown_metadata_manifest() { - let manifest_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("examples/plugins/markdown-metadata/plugin.toml"); + let manifest_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("examples/plugins/markdown-metadata/plugin.toml"); - let manifest = PluginManifest::from_file(&manifest_path) - .expect("Failed to parse markdown-metadata plugin.toml"); + let manifest = PluginManifest::from_file(&manifest_path) + .expect("Failed to parse markdown-metadata plugin.toml"); - assert_eq!(manifest.plugin.name, "markdown-metadata"); - assert_eq!(manifest.plugin.version, "1.0.0"); - assert_eq!(manifest.plugin.api_version, "1.0"); - assert_eq!(manifest.plugin.kind, vec!["metadata_extractor"]); - assert_eq!(manifest.plugin.binary.wasm, "markdown_metadata.wasm"); + assert_eq!(manifest.plugin.name, "markdown-metadata"); + assert_eq!(manifest.plugin.version, "1.0.0"); + assert_eq!(manifest.plugin.api_version, "1.0"); + assert_eq!(manifest.plugin.kind, vec!["metadata_extractor"]); + assert_eq!(manifest.plugin.binary.wasm, "markdown_metadata.wasm"); - // Validate capabilities - let caps = manifest.to_capabilities(); - assert_eq!(caps.filesystem.read.len(), 0); - assert_eq!(caps.filesystem.write.len(), 0); - assert!(!caps.network.enabled); + // Validate capabilities + let caps = manifest.to_capabilities(); + assert_eq!(caps.filesystem.read.len(), 0); + assert_eq!(caps.filesystem.write.len(), 0); + assert!(!caps.network.enabled); - // Validate config - assert!(manifest.config.contains_key("extract_tags")); - assert!(manifest.config.contains_key("parse_yaml")); - assert!(manifest.config.contains_key("max_file_size")); + // Validate config + assert!(manifest.config.contains_key("extract_tags")); + assert!(manifest.config.contains_key("parse_yaml")); + assert!(manifest.config.contains_key("max_file_size")); } #[test] fn test_heif_support_manifest() { - let manifest_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("examples/plugins/heif-support/plugin.toml"); + let manifest_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("examples/plugins/heif-support/plugin.toml"); - let manifest = PluginManifest::from_file(&manifest_path) - .expect("Failed to parse heif-support plugin.toml"); + let manifest = PluginManifest::from_file(&manifest_path) + .expect("Failed to parse heif-support plugin.toml"); - assert_eq!(manifest.plugin.name, "heif-support"); - assert_eq!(manifest.plugin.version, "1.0.0"); - assert_eq!(manifest.plugin.api_version, "1.0"); - assert_eq!( - manifest.plugin.kind, - vec!["media_type", "metadata_extractor", "thumbnail_generator"] - ); - assert_eq!(manifest.plugin.binary.wasm, "heif_support.wasm"); + assert_eq!(manifest.plugin.name, "heif-support"); + assert_eq!(manifest.plugin.version, "1.0.0"); + assert_eq!(manifest.plugin.api_version, "1.0"); + assert_eq!(manifest.plugin.kind, vec![ + "media_type", + "metadata_extractor", + "thumbnail_generator" + ]); + assert_eq!(manifest.plugin.binary.wasm, "heif_support.wasm"); - // Validate capabilities - let caps = manifest.to_capabilities(); - assert_eq!(caps.filesystem.read.len(), 1); - assert_eq!(caps.filesystem.write.len(), 1); - assert!(!caps.network.enabled); - assert_eq!(caps.max_memory_bytes, Some(256 * 1024 * 1024)); // 256MB - assert_eq!(caps.max_cpu_time_ms, Some(30 * 1000)); // 30 seconds + // Validate capabilities + let caps = manifest.to_capabilities(); + assert_eq!(caps.filesystem.read.len(), 1); + assert_eq!(caps.filesystem.write.len(), 1); + assert!(!caps.network.enabled); + assert_eq!(caps.max_memory_bytes, Some(256 * 1024 * 1024)); // 256MB + assert_eq!(caps.max_cpu_time_ms, Some(30 * 1000)); // 30 seconds - // Validate config - assert!(manifest.config.contains_key("extract_exif")); - assert!(manifest.config.contains_key("generate_thumbnails")); - assert!(manifest.config.contains_key("thumbnail_quality")); + // Validate config + assert!(manifest.config.contains_key("extract_exif")); + assert!(manifest.config.contains_key("generate_thumbnails")); + assert!(manifest.config.contains_key("thumbnail_quality")); } diff --git a/crates/pinakes-server/src/app.rs b/crates/pinakes-server/src/app.rs index 62b6c6b..2e70ac4 100644 --- a/crates/pinakes-server/src/app.rs +++ b/crates/pinakes-server/src/app.rs @@ -1,74 +1,76 @@ use std::sync::Arc; -use axum::Router; -use axum::extract::DefaultBodyLimit; -use axum::http::{HeaderValue, Method, header}; -use axum::middleware; -use axum::routing::{delete, get, patch, post, put}; +use axum::{ + Router, + extract::DefaultBodyLimit, + http::{HeaderValue, Method, header}, + middleware, + routing::{delete, get, patch, post, put}, +}; use tower::ServiceBuilder; -use tower_governor::GovernorLayer; -use tower_governor::governor::GovernorConfigBuilder; -use tower_http::cors::CorsLayer; -use tower_http::set_header::SetResponseHeaderLayer; -use tower_http::trace::TraceLayer; +use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder}; +use tower_http::{ + cors::CorsLayer, + set_header::SetResponseHeaderLayer, + trace::TraceLayer, +}; -use crate::auth; -use crate::routes; -use crate::state::AppState; +use crate::{auth, routes, state::AppState}; /// Create the router with optional TLS configuration for HSTS headers pub fn create_router(state: AppState) -> Router { - create_router_with_tls(state, None) + create_router_with_tls(state, None) } /// Create the router with TLS configuration for security headers pub fn create_router_with_tls( - state: AppState, - tls_config: Option<&pinakes_core::config::TlsConfig>, + state: AppState, + tls_config: Option<&pinakes_core::config::TlsConfig>, ) -> Router { - // Global rate limit: 100 requests/sec per IP - let global_governor = Arc::new( - GovernorConfigBuilder::default() - .per_second(1) - .burst_size(100) - .finish() - .unwrap(), - ); + // Global rate limit: 100 requests/sec per IP + let global_governor = Arc::new( + GovernorConfigBuilder::default() + .per_second(1) + .burst_size(100) + .finish() + .unwrap(), + ); - // Strict rate limit for login: 5 requests/min per IP - let login_governor = Arc::new( - GovernorConfigBuilder::default() + // Strict rate limit for login: 5 requests/min per IP + let login_governor = Arc::new( + GovernorConfigBuilder::default() .per_second(12) // replenish one every 12 seconds .burst_size(5) .finish() .unwrap(), - ); + ); - // Rate limit for search: 10 requests/min per IP - let search_governor = Arc::new( - GovernorConfigBuilder::default() + // Rate limit for search: 10 requests/min per IP + let search_governor = Arc::new( + GovernorConfigBuilder::default() .per_second(6) // replenish one every 6 seconds (10/min) .burst_size(10) .finish() .unwrap(), - ); + ); - // Rate limit for streaming: 5 requests per IP (very restrictive for concurrent streams) - let stream_governor = Arc::new( - GovernorConfigBuilder::default() + // Rate limit for streaming: 5 requests per IP (very restrictive for + // concurrent streams) + let stream_governor = Arc::new( + GovernorConfigBuilder::default() .per_second(60) // replenish slowly (one per minute) .burst_size(5) // max 5 concurrent connections .finish() .unwrap(), - ); + ); - // Login route with strict rate limiting - let login_route = Router::new() - .route("/auth/login", post(routes::auth::login)) - .layer(GovernorLayer::new(login_governor)); + // Login route with strict rate limiting + let login_route = Router::new() + .route("/auth/login", post(routes::auth::login)) + .layer(GovernorLayer::new(login_governor)); - // Public routes (no auth required) - let public_routes = Router::new() + // Public routes (no auth required) + let public_routes = Router::new() .route("/s/{token}", get(routes::social::access_shared_media)) // Enhanced sharing: public share access .route("/shared/{token}", get(routes::shares::access_shared)) @@ -76,19 +78,19 @@ pub fn create_router_with_tls( .route("/health/live", get(routes::health::liveness)) .route("/health/ready", get(routes::health::readiness)); - // Search routes with enhanced rate limiting (10 req/min) - let search_routes = Router::new() - .route("/search", get(routes::search::search)) - .route("/search", post(routes::search::search_post)) - .layer(GovernorLayer::new(search_governor)); + // Search routes with enhanced rate limiting (10 req/min) + let search_routes = Router::new() + .route("/search", get(routes::search::search)) + .route("/search", post(routes::search::search_post)) + .layer(GovernorLayer::new(search_governor)); - // Streaming routes with enhanced rate limiting (5 concurrent) - let streaming_routes = Router::new() - .route("/media/{id}/stream", get(routes::media::stream_media)) - .layer(GovernorLayer::new(stream_governor)); + // Streaming routes with enhanced rate limiting (5 concurrent) + let streaming_routes = Router::new() + .route("/media/{id}/stream", get(routes::media::stream_media)) + .layer(GovernorLayer::new(stream_governor)); - // Read-only routes: any authenticated user (Viewer+) - let viewer_routes = Router::new() + // Read-only routes: any authenticated user (Viewer+) + let viewer_routes = Router::new() .route("/health", get(routes::health::health)) .route("/health/detailed", get(routes::health::health_detailed)) .route("/media/count", get(routes::media::get_media_count)) @@ -240,8 +242,8 @@ pub fn create_router_with_tls( ) .nest("/notes", routes::notes::routes()); - // Write routes: Editor+ required - let editor_routes = Router::new() + // Write routes: Editor+ required + let editor_routes = Router::new() .route("/media/import", post(routes::media::import_media)) .route( "/media/import/options", @@ -456,8 +458,8 @@ pub fn create_router_with_tls( ) .layer(middleware::from_fn(auth::require_editor)); - // Admin-only routes: destructive/config operations - let admin_routes = Router::new() + // Admin-only routes: destructive/config operations + let admin_routes = Router::new() .route( "/config/scanning", put(routes::config::update_scanning_config), @@ -496,43 +498,43 @@ pub fn create_router_with_tls( .route("/auth/sessions", get(routes::auth::list_active_sessions)) .layer(middleware::from_fn(auth::require_admin)); - // CORS: allow same-origin by default, plus the desktop UI origin - let cors = CorsLayer::new() - .allow_origin([ - "http://localhost:3000".parse::().unwrap(), - "http://127.0.0.1:3000".parse::().unwrap(), - "tauri://localhost".parse::().unwrap(), - ]) - .allow_methods([ - Method::GET, - Method::POST, - Method::PUT, - Method::PATCH, - Method::DELETE, - ]) - .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION]) - .allow_credentials(true); + // CORS: allow same-origin by default, plus the desktop UI origin + let cors = CorsLayer::new() + .allow_origin([ + "http://localhost:3000".parse::().unwrap(), + "http://127.0.0.1:3000".parse::().unwrap(), + "tauri://localhost".parse::().unwrap(), + ]) + .allow_methods([ + Method::GET, + Method::POST, + Method::PUT, + Method::PATCH, + Method::DELETE, + ]) + .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION]) + .allow_credentials(true); - // Create protected routes with auth middleware - let protected_api = Router::new() - .merge(viewer_routes) - .merge(search_routes) - .merge(streaming_routes) - .merge(editor_routes) - .merge(admin_routes) - .layer(middleware::from_fn_with_state( - state.clone(), - auth::require_auth, - )); + // Create protected routes with auth middleware + let protected_api = Router::new() + .merge(viewer_routes) + .merge(search_routes) + .merge(streaming_routes) + .merge(editor_routes) + .merge(admin_routes) + .layer(middleware::from_fn_with_state( + state.clone(), + auth::require_auth, + )); - // Combine protected and public routes - let full_api = Router::new() - .merge(login_route) - .merge(public_routes) - .merge(protected_api); + // Combine protected and public routes + let full_api = Router::new() + .merge(login_route) + .merge(public_routes) + .merge(protected_api); - // Build security headers layer - let security_headers = ServiceBuilder::new() + // Build security headers layer + let security_headers = ServiceBuilder::new() // Prevent MIME type sniffing .layer(SetResponseHeaderLayer::overriding( header::X_CONTENT_TYPE_OPTIONS, @@ -564,32 +566,34 @@ pub fn create_router_with_tls( HeaderValue::from_static("default-src 'none'; frame-ancestors 'none'"), )); - let router = Router::new() - .nest("/api/v1", full_api) - .layer(DefaultBodyLimit::max(10 * 1024 * 1024)) - .layer(GovernorLayer::new(global_governor)) - .layer(TraceLayer::new_for_http()) - .layer(cors) - .layer(security_headers); + let router = Router::new() + .nest("/api/v1", full_api) + .layer(DefaultBodyLimit::max(10 * 1024 * 1024)) + .layer(GovernorLayer::new(global_governor)) + .layer(TraceLayer::new_for_http()) + .layer(cors) + .layer(security_headers); - // Add HSTS header when TLS is enabled - if let Some(tls) = tls_config { - if tls.enabled && tls.hsts_enabled { - let hsts_value = format!("max-age={}; includeSubDomains", tls.hsts_max_age); - let hsts_header = HeaderValue::from_str(&hsts_value).unwrap_or_else(|_| { - HeaderValue::from_static("max-age=31536000; includeSubDomains") - }); + // Add HSTS header when TLS is enabled + if let Some(tls) = tls_config { + if tls.enabled && tls.hsts_enabled { + let hsts_value = + format!("max-age={}; includeSubDomains", tls.hsts_max_age); + let hsts_header = + HeaderValue::from_str(&hsts_value).unwrap_or_else(|_| { + HeaderValue::from_static("max-age=31536000; includeSubDomains") + }); - router - .layer(SetResponseHeaderLayer::overriding( - header::STRICT_TRANSPORT_SECURITY, - hsts_header, - )) - .with_state(state) - } else { - router.with_state(state) - } + router + .layer(SetResponseHeaderLayer::overriding( + header::STRICT_TRANSPORT_SECURITY, + hsts_header, + )) + .with_state(state) } else { - router.with_state(state) + router.with_state(state) } + } else { + router.with_state(state) + } } diff --git a/crates/pinakes-server/src/auth.rs b/crates/pinakes-server/src/auth.rs index b2064f3..a6306b7 100644 --- a/crates/pinakes-server/src/auth.rs +++ b/crates/pinakes-server/src/auth.rs @@ -1,229 +1,237 @@ -use axum::extract::{Request, State}; -use axum::http::StatusCode; -use axum::middleware::Next; -use axum::response::{IntoResponse, Response}; - +use axum::{ + extract::{Request, State}, + http::StatusCode, + middleware::Next, + response::{IntoResponse, Response}, +}; use pinakes_core::config::UserRole; use crate::state::AppState; /// Constant-time string comparison to prevent timing attacks on API keys. fn constant_time_eq(a: &str, b: &str) -> bool { - if a.len() != b.len() { - return false; - } - a.as_bytes() - .iter() - .zip(b.as_bytes()) - .fold(0u8, |acc, (x, y)| acc | (x ^ y)) - == 0 + if a.len() != b.len() { + return false; + } + a.as_bytes() + .iter() + .zip(b.as_bytes()) + .fold(0u8, |acc, (x, y)| acc | (x ^ y)) + == 0 } /// Axum middleware that checks for a valid Bearer token. /// -/// If `accounts.enabled == true`: look up bearer token in database session store. -/// If `accounts.enabled == false`: use existing api_key logic (unchanged behavior). -/// Skips authentication for the `/health` and `/auth/login` path suffixes. +/// If `accounts.enabled == true`: look up bearer token in database session +/// store. If `accounts.enabled == false`: use existing api_key logic (unchanged +/// behavior). Skips authentication for the `/health` and `/auth/login` path +/// suffixes. pub async fn require_auth( - State(state): State, - mut request: Request, - next: Next, + State(state): State, + mut request: Request, + next: Next, ) -> Response { - let path = request.uri().path().to_string(); + let path = request.uri().path().to_string(); - // Always allow health and login endpoints - if path.ends_with("/health") || path.ends_with("/auth/login") { - return next.run(request).await; + // Always allow health and login endpoints + if path.ends_with("/health") || path.ends_with("/auth/login") { + return next.run(request).await; + } + + let config = state.config.read().await; + + // Check if authentication is explicitly disabled + if config.server.authentication_disabled { + drop(config); + tracing::warn!("authentication is disabled - allowing all requests"); + request.extensions_mut().insert(UserRole::Admin); + request.extensions_mut().insert("admin".to_string()); + return next.run(request).await; + } + + if config.accounts.enabled { + drop(config); + + // Session-based auth using database + let token = request + .headers() + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.strip_prefix("Bearer ")) + .map(|s| s.to_string()); + + let Some(token) = token else { + tracing::debug!(path = %path, "rejected: missing Authorization header"); + return unauthorized("missing Authorization header"); + }; + + // Look up session in database + let session_result = state.storage.get_session(&token).await; + let session = match session_result { + Ok(Some(session)) => session, + Ok(None) => { + tracing::debug!(path = %path, "rejected: invalid session token"); + return unauthorized("invalid or expired session token"); + }, + Err(e) => { + tracing::error!(error = %e, "failed to query session from database"); + return (StatusCode::INTERNAL_SERVER_ERROR, "database error") + .into_response(); + }, + }; + + // Check session expiry + let now = chrono::Utc::now(); + if session.expires_at < now { + let username = session.username.clone(); + // Delete expired session asynchronously (fire-and-forget) + let storage = state.storage.clone(); + let token_owned = token.clone(); + tokio::spawn(async move { + if let Err(e) = storage.delete_session(&token_owned).await { + tracing::error!(error = %e, "failed to delete expired session"); + } + }); + tracing::info!(username = %username, "session expired"); + return unauthorized("session expired"); } - let config = state.config.read().await; + // Update last_accessed timestamp asynchronously (fire-and-forget) + let storage = state.storage.clone(); + let token_owned = token.clone(); + tokio::spawn(async move { + if let Err(e) = storage.touch_session(&token_owned).await { + tracing::warn!(error = %e, "failed to update session last_accessed"); + } + }); - // Check if authentication is explicitly disabled - if config.server.authentication_disabled { - drop(config); - tracing::warn!("authentication is disabled - allowing all requests"); - request.extensions_mut().insert(UserRole::Admin); - request.extensions_mut().insert("admin".to_string()); - return next.run(request).await; + // Parse role from string + let role = match session.role.as_str() { + "admin" => UserRole::Admin, + "editor" => UserRole::Editor, + "viewer" => UserRole::Viewer, + _ => { + tracing::warn!(role = %session.role, "unknown role, defaulting to viewer"); + UserRole::Viewer + }, + }; + + // Inject role and username into request extensions + request.extensions_mut().insert(role); + request.extensions_mut().insert(session.username.clone()); + } else { + // Legacy API key auth + let api_key = std::env::var("PINAKES_API_KEY") + .ok() + .or_else(|| config.server.api_key.clone()); + drop(config); + + let Some(ref expected_key) = api_key else { + tracing::error!("no authentication configured"); + return unauthorized("authentication not configured"); + }; + + if expected_key.is_empty() { + // Empty key is not allowed - must use authentication_disabled flag + tracing::error!( + "empty api_key rejected, use authentication_disabled flag instead" + ); + return unauthorized("authentication not properly configured"); } - if config.accounts.enabled { - drop(config); + let auth_header = request + .headers() + .get("authorization") + .and_then(|v| v.to_str().ok()); - // Session-based auth using database - let token = request - .headers() - .get("authorization") - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.strip_prefix("Bearer ")) - .map(|s| s.to_string()); - - let Some(token) = token else { - tracing::debug!(path = %path, "rejected: missing Authorization header"); - return unauthorized("missing Authorization header"); - }; - - // Look up session in database - let session_result = state.storage.get_session(&token).await; - let session = match session_result { - Ok(Some(session)) => session, - Ok(None) => { - tracing::debug!(path = %path, "rejected: invalid session token"); - return unauthorized("invalid or expired session token"); - } - Err(e) => { - tracing::error!(error = %e, "failed to query session from database"); - return (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response(); - } - }; - - // Check session expiry - let now = chrono::Utc::now(); - if session.expires_at < now { - let username = session.username.clone(); - // Delete expired session asynchronously (fire-and-forget) - let storage = state.storage.clone(); - let token_owned = token.clone(); - tokio::spawn(async move { - if let Err(e) = storage.delete_session(&token_owned).await { - tracing::error!(error = %e, "failed to delete expired session"); - } - }); - tracing::info!(username = %username, "session expired"); - return unauthorized("session expired"); + match auth_header { + Some(header) if header.starts_with("Bearer ") => { + let token = &header[7..]; + if !constant_time_eq(token, expected_key.as_str()) { + tracing::warn!(path = %path, "rejected: invalid API key"); + return unauthorized("invalid api key"); } - - // Update last_accessed timestamp asynchronously (fire-and-forget) - let storage = state.storage.clone(); - let token_owned = token.clone(); - tokio::spawn(async move { - if let Err(e) = storage.touch_session(&token_owned).await { - tracing::warn!(error = %e, "failed to update session last_accessed"); - } - }); - - // Parse role from string - let role = match session.role.as_str() { - "admin" => UserRole::Admin, - "editor" => UserRole::Editor, - "viewer" => UserRole::Viewer, - _ => { - tracing::warn!(role = %session.role, "unknown role, defaulting to viewer"); - UserRole::Viewer - } - }; - - // Inject role and username into request extensions - request.extensions_mut().insert(role); - request.extensions_mut().insert(session.username.clone()); - } else { - // Legacy API key auth - let api_key = std::env::var("PINAKES_API_KEY") - .ok() - .or_else(|| config.server.api_key.clone()); - drop(config); - - let Some(ref expected_key) = api_key else { - tracing::error!("no authentication configured"); - return unauthorized("authentication not configured"); - }; - - if expected_key.is_empty() { - // Empty key is not allowed - must use authentication_disabled flag - tracing::error!("empty api_key rejected, use authentication_disabled flag instead"); - return unauthorized("authentication not properly configured"); - } - - let auth_header = request - .headers() - .get("authorization") - .and_then(|v| v.to_str().ok()); - - match auth_header { - Some(header) if header.starts_with("Bearer ") => { - let token = &header[7..]; - if !constant_time_eq(token, expected_key.as_str()) { - tracing::warn!(path = %path, "rejected: invalid API key"); - return unauthorized("invalid api key"); - } - } - _ => { - return unauthorized( - "missing or malformed Authorization header, expected: Bearer ", - ); - } - } - - // API key matches, grant admin - request.extensions_mut().insert(UserRole::Admin); - request.extensions_mut().insert("admin".to_string()); + }, + _ => { + return unauthorized( + "missing or malformed Authorization header, expected: Bearer \ + ", + ); + }, } - next.run(request).await + // API key matches, grant admin + request.extensions_mut().insert(UserRole::Admin); + request.extensions_mut().insert("admin".to_string()); + } + + next.run(request).await } /// Middleware: requires Editor or Admin role. pub async fn require_editor(request: Request, next: Next) -> Response { - let role = request - .extensions() - .get::() - .copied() - .unwrap_or(UserRole::Viewer); - if role.can_write() { - next.run(request).await - } else { - forbidden("editor role required") - } + let role = request + .extensions() + .get::() + .copied() + .unwrap_or(UserRole::Viewer); + if role.can_write() { + next.run(request).await + } else { + forbidden("editor role required") + } } /// Middleware: requires Admin role. pub async fn require_admin(request: Request, next: Next) -> Response { - let role = request - .extensions() - .get::() - .copied() - .unwrap_or(UserRole::Viewer); - if role.can_admin() { - next.run(request).await - } else { - forbidden("admin role required") - } + let role = request + .extensions() + .get::() + .copied() + .unwrap_or(UserRole::Viewer); + if role.can_admin() { + next.run(request).await + } else { + forbidden("admin role required") + } } /// Resolve the authenticated username (from request extensions) to a UserId. /// /// Returns an error if the user cannot be found. pub async fn resolve_user_id( - storage: &pinakes_core::storage::DynStorageBackend, - username: &str, + storage: &pinakes_core::storage::DynStorageBackend, + username: &str, ) -> Result { - match storage.get_user_by_username(username).await { - Ok(user) => Ok(user.id), - Err(e) => { - tracing::warn!(username = %username, error = ?e, "failed to resolve user"); - Err(crate::error::ApiError( - pinakes_core::error::PinakesError::Authentication("user not found".into()), - )) - } - } + match storage.get_user_by_username(username).await { + Ok(user) => Ok(user.id), + Err(e) => { + tracing::warn!(username = %username, error = ?e, "failed to resolve user"); + Err(crate::error::ApiError( + pinakes_core::error::PinakesError::Authentication( + "user not found".into(), + ), + )) + }, + } } fn unauthorized(message: &str) -> Response { - let body = format!(r#"{{"error":"{message}"}}"#); - ( - StatusCode::UNAUTHORIZED, - [("content-type", "application/json")], - body, - ) - .into_response() + let body = format!(r#"{{"error":"{message}"}}"#); + ( + StatusCode::UNAUTHORIZED, + [("content-type", "application/json")], + body, + ) + .into_response() } fn forbidden(message: &str) -> Response { - let body = format!(r#"{{"error":"{message}"}}"#); - ( - StatusCode::FORBIDDEN, - [("content-type", "application/json")], - body, - ) - .into_response() + let body = format!(r#"{{"error":"{message}"}}"#); + ( + StatusCode::FORBIDDEN, + [("content-type", "application/json")], + body, + ) + .into_response() } diff --git a/crates/pinakes-server/src/dto.rs b/crates/pinakes-server/src/dto.rs index c35cbf5..0b610d3 100644 --- a/crates/pinakes-server/src/dto.rs +++ b/crates/pinakes-server/src/dto.rs @@ -1,5 +1,4 @@ -use std::collections::HashMap; -use std::path::PathBuf; +use std::{collections::HashMap, path::PathBuf}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -8,1446 +7,1455 @@ use uuid::Uuid; // Media #[derive(Debug, Serialize)] pub struct MediaResponse { - pub id: String, - pub path: String, - pub file_name: String, - pub media_type: String, - pub content_hash: String, - pub file_size: u64, - pub title: Option, - pub artist: Option, - pub album: Option, - pub genre: Option, - pub year: Option, - pub duration_secs: Option, - pub description: Option, - pub has_thumbnail: bool, - pub custom_fields: HashMap, + pub id: String, + pub path: String, + pub file_name: String, + pub media_type: String, + pub content_hash: String, + pub file_size: u64, + pub title: Option, + pub artist: Option, + pub album: Option, + pub genre: Option, + pub year: Option, + pub duration_secs: Option, + pub description: Option, + pub has_thumbnail: bool, + pub custom_fields: HashMap, - // Photo-specific metadata - pub date_taken: Option>, - pub latitude: Option, - pub longitude: Option, - pub camera_make: Option, - pub camera_model: Option, - pub rating: Option, + // Photo-specific metadata + pub date_taken: Option>, + pub latitude: Option, + pub longitude: Option, + pub camera_make: Option, + pub camera_model: Option, + pub rating: Option, - pub created_at: DateTime, - pub updated_at: DateTime, + pub created_at: DateTime, + pub updated_at: DateTime, - // Markdown links - pub links_extracted_at: Option>, + // Markdown links + pub links_extracted_at: Option>, } #[derive(Debug, Serialize)] pub struct CustomFieldResponse { - pub field_type: String, - pub value: String, + pub field_type: String, + pub value: String, } #[derive(Debug, Deserialize)] pub struct ImportRequest { - pub path: PathBuf, + pub path: PathBuf, } #[derive(Debug, Serialize)] pub struct ImportResponse { - pub media_id: String, - pub was_duplicate: bool, + pub media_id: String, + pub was_duplicate: bool, } #[derive(Debug, Deserialize)] pub struct UpdateMediaRequest { - pub title: Option, - pub artist: Option, - pub album: Option, - pub genre: Option, - pub year: Option, - pub description: Option, + pub title: Option, + pub artist: Option, + pub album: Option, + pub genre: Option, + pub year: Option, + pub description: Option, } // File Management #[derive(Debug, Deserialize)] pub struct RenameMediaRequest { - pub new_name: String, + pub new_name: String, } #[derive(Debug, Deserialize)] pub struct MoveMediaRequest { - pub destination: PathBuf, + pub destination: PathBuf, } #[derive(Debug, Deserialize)] pub struct BatchMoveRequest { - pub media_ids: Vec, - pub destination: PathBuf, + pub media_ids: Vec, + pub destination: PathBuf, } #[derive(Debug, Serialize)] pub struct TrashResponse { - pub items: Vec, - pub total_count: u64, + pub items: Vec, + pub total_count: u64, } #[derive(Debug, Serialize)] pub struct TrashInfoResponse { - pub count: u64, + pub count: u64, } #[derive(Debug, Serialize)] pub struct EmptyTrashResponse { - pub deleted_count: u64, + pub deleted_count: u64, } // Tags #[derive(Debug, Serialize)] pub struct TagResponse { - pub id: String, - pub name: String, - pub parent_id: Option, - pub created_at: DateTime, + pub id: String, + pub name: String, + pub parent_id: Option, + pub created_at: DateTime, } #[derive(Debug, Deserialize)] pub struct CreateTagRequest { - pub name: String, - pub parent_id: Option, + pub name: String, + pub parent_id: Option, } #[derive(Debug, Deserialize)] pub struct TagMediaRequest { - pub tag_id: Uuid, + pub tag_id: Uuid, } // Collections #[derive(Debug, Serialize)] pub struct CollectionResponse { - pub id: String, - pub name: String, - pub description: Option, - pub kind: String, - pub filter_query: Option, - pub created_at: DateTime, - pub updated_at: DateTime, + pub id: String, + pub name: String, + pub description: Option, + pub kind: String, + pub filter_query: Option, + pub created_at: DateTime, + pub updated_at: DateTime, } #[derive(Debug, Deserialize)] pub struct CreateCollectionRequest { - pub name: String, - pub kind: String, - pub description: Option, - pub filter_query: Option, + pub name: String, + pub kind: String, + pub description: Option, + pub filter_query: Option, } #[derive(Debug, Deserialize)] pub struct AddMemberRequest { - pub media_id: Uuid, - pub position: Option, + pub media_id: Uuid, + pub position: Option, } // Search #[derive(Debug, Deserialize)] pub struct SearchParams { - pub q: String, - pub sort: Option, - pub offset: Option, - pub limit: Option, + pub q: String, + pub sort: Option, + pub offset: Option, + pub limit: Option, } #[derive(Debug, Serialize)] pub struct SearchResponse { - pub items: Vec, - pub total_count: u64, + pub items: Vec, + pub total_count: u64, } // Audit #[derive(Debug, Serialize)] pub struct AuditEntryResponse { - pub id: String, - pub media_id: Option, - pub action: String, - pub details: Option, - pub timestamp: DateTime, + pub id: String, + pub media_id: Option, + pub action: String, + pub details: Option, + pub timestamp: DateTime, } // Search (POST body) #[derive(Debug, Deserialize)] pub struct SearchRequestBody { - pub q: String, - pub sort: Option, - pub offset: Option, - pub limit: Option, + pub q: String, + pub sort: Option, + pub offset: Option, + pub limit: Option, } // Scan #[derive(Debug, Deserialize)] pub struct ScanRequest { - pub path: Option, + pub path: Option, } #[derive(Debug, Serialize)] pub struct ScanResponse { - pub files_found: usize, - pub files_processed: usize, - pub errors: Vec, + pub files_found: usize, + pub files_processed: usize, + pub errors: Vec, } #[derive(Debug, Serialize)] pub struct ScanJobResponse { - pub job_id: String, + pub job_id: String, } #[derive(Debug, Serialize)] pub struct ScanStatusResponse { - pub scanning: bool, - pub files_found: usize, - pub files_processed: usize, - pub error_count: usize, - pub errors: Vec, + pub scanning: bool, + pub files_found: usize, + pub files_processed: usize, + pub error_count: usize, + pub errors: Vec, } // Pagination #[derive(Debug, Deserialize)] pub struct PaginationParams { - pub offset: Option, - pub limit: Option, - pub sort: Option, + pub offset: Option, + pub limit: Option, + pub sort: Option, } // Open #[derive(Debug, Deserialize)] pub struct OpenRequest { - pub media_id: Uuid, + pub media_id: Uuid, } // Config #[derive(Debug, Serialize)] pub struct ConfigResponse { - pub backend: String, - pub database_path: Option, - pub roots: Vec, - pub scanning: ScanningConfigResponse, - pub server: ServerConfigResponse, - pub ui: UiConfigResponse, - pub config_path: Option, - pub config_writable: bool, + pub backend: String, + pub database_path: Option, + pub roots: Vec, + pub scanning: ScanningConfigResponse, + pub server: ServerConfigResponse, + pub ui: UiConfigResponse, + pub config_path: Option, + pub config_writable: bool, } #[derive(Debug, Serialize)] pub struct ScanningConfigResponse { - pub watch: bool, - pub poll_interval_secs: u64, - pub ignore_patterns: Vec, + pub watch: bool, + pub poll_interval_secs: u64, + pub ignore_patterns: Vec, } #[derive(Debug, Serialize)] pub struct ServerConfigResponse { - pub host: String, - pub port: u16, + pub host: String, + pub port: u16, } #[derive(Debug, Deserialize)] pub struct UpdateScanningRequest { - pub watch: Option, - pub poll_interval_secs: Option, - pub ignore_patterns: Option>, + pub watch: Option, + pub poll_interval_secs: Option, + pub ignore_patterns: Option>, } #[derive(Debug, Deserialize)] pub struct RootDirRequest { - pub path: String, + pub path: String, } // Enhanced Import #[derive(Debug, Deserialize)] pub struct ImportWithOptionsRequest { - pub path: PathBuf, - pub tag_ids: Option>, - pub new_tags: Option>, - pub collection_id: Option, + pub path: PathBuf, + pub tag_ids: Option>, + pub new_tags: Option>, + pub collection_id: Option, } #[derive(Debug, Deserialize)] pub struct BatchImportRequest { - pub paths: Vec, - pub tag_ids: Option>, - pub new_tags: Option>, - pub collection_id: Option, + pub paths: Vec, + pub tag_ids: Option>, + pub new_tags: Option>, + pub collection_id: Option, } #[derive(Debug, Serialize)] pub struct BatchImportResponse { - pub results: Vec, - pub total: usize, - pub imported: usize, - pub duplicates: usize, - pub errors: usize, + pub results: Vec, + pub total: usize, + pub imported: usize, + pub duplicates: usize, + pub errors: usize, } #[derive(Debug, Serialize)] pub struct BatchImportItemResult { - pub path: String, - pub media_id: Option, - pub was_duplicate: bool, - pub error: Option, + pub path: String, + pub media_id: Option, + pub was_duplicate: bool, + pub error: Option, } #[derive(Debug, Deserialize)] pub struct DirectoryImportRequest { - pub path: PathBuf, - pub tag_ids: Option>, - pub new_tags: Option>, - pub collection_id: Option, + pub path: PathBuf, + pub tag_ids: Option>, + pub new_tags: Option>, + pub collection_id: Option, } #[derive(Debug, Serialize)] pub struct DirectoryPreviewResponse { - pub files: Vec, - pub total_count: usize, - pub total_size: u64, + pub files: Vec, + pub total_count: usize, + pub total_size: u64, } #[derive(Debug, Serialize)] pub struct DirectoryPreviewFile { - pub path: String, - pub file_name: String, - pub media_type: String, - pub file_size: u64, + pub path: String, + pub file_name: String, + pub media_type: String, + pub file_size: u64, } // Custom Fields #[derive(Debug, Deserialize)] pub struct SetCustomFieldRequest { - pub name: String, - pub field_type: String, - pub value: String, + pub name: String, + pub field_type: String, + pub value: String, } // Media update extended #[derive(Debug, Deserialize)] pub struct UpdateMediaFullRequest { - pub title: Option, - pub artist: Option, - pub album: Option, - pub genre: Option, - pub year: Option, - pub description: Option, + pub title: Option, + pub artist: Option, + pub album: Option, + pub genre: Option, + pub year: Option, + pub description: Option, } // Batch operations #[derive(Debug, Deserialize)] pub struct BatchTagRequest { - pub media_ids: Vec, - pub tag_ids: Vec, + pub media_ids: Vec, + pub tag_ids: Vec, } #[derive(Debug, Deserialize)] pub struct BatchCollectionRequest { - pub media_ids: Vec, - pub collection_id: Uuid, + pub media_ids: Vec, + pub collection_id: Uuid, } #[derive(Debug, Deserialize)] pub struct BatchDeleteRequest { - pub media_ids: Vec, + pub media_ids: Vec, } #[derive(Debug, Deserialize)] pub struct BatchUpdateRequest { - pub media_ids: Vec, - pub title: Option, - pub artist: Option, - pub album: Option, - pub genre: Option, - pub year: Option, - pub description: Option, + pub media_ids: Vec, + pub title: Option, + pub artist: Option, + pub album: Option, + pub genre: Option, + pub year: Option, + pub description: Option, } #[derive(Debug, Serialize)] pub struct BatchOperationResponse { - pub processed: usize, - pub errors: Vec, + pub processed: usize, + pub errors: Vec, } // Search with sort #[derive(Debug, Serialize)] pub struct MediaCountResponse { - pub count: u64, + pub count: u64, } // Database management #[derive(Debug, Serialize)] pub struct DatabaseStatsResponse { - pub media_count: u64, - pub tag_count: u64, - pub collection_count: u64, - pub audit_count: u64, - pub database_size_bytes: u64, - pub backend_name: String, + pub media_count: u64, + pub tag_count: u64, + pub collection_count: u64, + pub audit_count: u64, + pub database_size_bytes: u64, + pub backend_name: String, } // UI Config #[derive(Debug, Serialize, Deserialize, Clone)] pub struct UiConfigResponse { - pub theme: String, - pub default_view: String, - pub default_page_size: usize, - pub default_view_mode: String, - pub auto_play_media: bool, - pub show_thumbnails: bool, - pub sidebar_collapsed: bool, + pub theme: String, + pub default_view: String, + pub default_page_size: usize, + pub default_view_mode: String, + pub auto_play_media: bool, + pub show_thumbnails: bool, + pub sidebar_collapsed: bool, } #[derive(Debug, Deserialize)] pub struct UpdateUiConfigRequest { - pub theme: Option, - pub default_view: Option, - pub default_page_size: Option, - pub default_view_mode: Option, - pub auto_play_media: Option, - pub show_thumbnails: Option, - pub sidebar_collapsed: Option, + pub theme: Option, + pub default_view: Option, + pub default_page_size: Option, + pub default_view_mode: Option, + pub auto_play_media: Option, + pub show_thumbnails: Option, + pub sidebar_collapsed: Option, } impl From<&pinakes_core::config::UiConfig> for UiConfigResponse { - fn from(ui: &pinakes_core::config::UiConfig) -> Self { - Self { - theme: ui.theme.clone(), - default_view: ui.default_view.clone(), - default_page_size: ui.default_page_size, - default_view_mode: ui.default_view_mode.clone(), - auto_play_media: ui.auto_play_media, - show_thumbnails: ui.show_thumbnails, - sidebar_collapsed: ui.sidebar_collapsed, - } + fn from(ui: &pinakes_core::config::UiConfig) -> Self { + Self { + theme: ui.theme.clone(), + default_view: ui.default_view.clone(), + default_page_size: ui.default_page_size, + default_view_mode: ui.default_view_mode.clone(), + auto_play_media: ui.auto_play_media, + show_thumbnails: ui.show_thumbnails, + sidebar_collapsed: ui.sidebar_collapsed, } + } } // Library Statistics #[derive(Debug, Serialize)] pub struct LibraryStatisticsResponse { - pub total_media: u64, - pub total_size_bytes: u64, - pub avg_file_size_bytes: u64, - pub media_by_type: Vec, - pub storage_by_type: Vec, - pub newest_item: Option, - pub oldest_item: Option, - pub top_tags: Vec, - pub top_collections: Vec, - pub total_tags: u64, - pub total_collections: u64, - pub total_duplicates: u64, + pub total_media: u64, + pub total_size_bytes: u64, + pub avg_file_size_bytes: u64, + pub media_by_type: Vec, + pub storage_by_type: Vec, + pub newest_item: Option, + pub oldest_item: Option, + pub top_tags: Vec, + pub top_collections: Vec, + pub total_tags: u64, + pub total_collections: u64, + pub total_duplicates: u64, } #[derive(Debug, Serialize)] pub struct TypeCountResponse { - pub name: String, - pub count: u64, + pub name: String, + pub count: u64, } -impl From for LibraryStatisticsResponse { - fn from(stats: pinakes_core::storage::LibraryStatistics) -> Self { - Self { - total_media: stats.total_media, - total_size_bytes: stats.total_size_bytes, - avg_file_size_bytes: stats.avg_file_size_bytes, - media_by_type: stats - .media_by_type - .into_iter() - .map(|(name, count)| TypeCountResponse { name, count }) - .collect(), - storage_by_type: stats - .storage_by_type - .into_iter() - .map(|(name, count)| TypeCountResponse { name, count }) - .collect(), - newest_item: stats.newest_item, - oldest_item: stats.oldest_item, - top_tags: stats - .top_tags - .into_iter() - .map(|(name, count)| TypeCountResponse { name, count }) - .collect(), - top_collections: stats - .top_collections - .into_iter() - .map(|(name, count)| TypeCountResponse { name, count }) - .collect(), - total_tags: stats.total_tags, - total_collections: stats.total_collections, - total_duplicates: stats.total_duplicates, - } +impl From + for LibraryStatisticsResponse +{ + fn from(stats: pinakes_core::storage::LibraryStatistics) -> Self { + Self { + total_media: stats.total_media, + total_size_bytes: stats.total_size_bytes, + avg_file_size_bytes: stats.avg_file_size_bytes, + media_by_type: stats + .media_by_type + .into_iter() + .map(|(name, count)| TypeCountResponse { name, count }) + .collect(), + storage_by_type: stats + .storage_by_type + .into_iter() + .map(|(name, count)| TypeCountResponse { name, count }) + .collect(), + newest_item: stats.newest_item, + oldest_item: stats.oldest_item, + top_tags: stats + .top_tags + .into_iter() + .map(|(name, count)| TypeCountResponse { name, count }) + .collect(), + top_collections: stats + .top_collections + .into_iter() + .map(|(name, count)| TypeCountResponse { name, count }) + .collect(), + total_tags: stats.total_tags, + total_collections: stats.total_collections, + total_duplicates: stats.total_duplicates, } + } } // Scheduled Tasks #[derive(Debug, Serialize)] pub struct ScheduledTaskResponse { - pub id: String, - pub name: String, - pub schedule: String, - pub enabled: bool, - pub last_run: Option, - pub next_run: Option, - pub last_status: Option, + pub id: String, + pub name: String, + pub schedule: String, + pub enabled: bool, + pub last_run: Option, + pub next_run: Option, + pub last_status: Option, } // Duplicates #[derive(Debug, Serialize)] pub struct DuplicateGroupResponse { - pub content_hash: String, - pub items: Vec, + pub content_hash: String, + pub items: Vec, } // Auth #[derive(Debug, Deserialize)] pub struct LoginRequest { - pub username: String, - pub password: String, + pub username: String, + pub password: String, } #[derive(Debug, Serialize)] pub struct LoginResponse { - pub token: String, - pub username: String, - pub role: String, + pub token: String, + pub username: String, + pub role: String, } #[derive(Debug, Serialize)] pub struct UserInfoResponse { - pub username: String, - pub role: String, + pub username: String, + pub role: String, } // Conversion helpers impl From for MediaResponse { - fn from(item: pinakes_core::model::MediaItem) -> Self { - Self { - id: item.id.0.to_string(), - path: item.path.to_string_lossy().to_string(), - file_name: item.file_name, - media_type: serde_json::to_value(item.media_type) - .ok() - .and_then(|v| v.as_str().map(String::from)) - .unwrap_or_default(), - content_hash: item.content_hash.0, - file_size: item.file_size, - title: item.title, - artist: item.artist, - album: item.album, - genre: item.genre, - year: item.year, - duration_secs: item.duration_secs, - description: item.description, - has_thumbnail: item.thumbnail_path.is_some(), - custom_fields: item - .custom_fields - .into_iter() - .map(|(k, v)| { - ( - k, - CustomFieldResponse { - field_type: format!("{:?}", v.field_type).to_lowercase(), - value: v.value, - }, - ) - }) - .collect(), + fn from(item: pinakes_core::model::MediaItem) -> Self { + Self { + id: item.id.0.to_string(), + path: item.path.to_string_lossy().to_string(), + file_name: item.file_name, + media_type: serde_json::to_value(item.media_type) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_default(), + content_hash: item.content_hash.0, + file_size: item.file_size, + title: item.title, + artist: item.artist, + album: item.album, + genre: item.genre, + year: item.year, + duration_secs: item.duration_secs, + description: item.description, + has_thumbnail: item.thumbnail_path.is_some(), + custom_fields: item + .custom_fields + .into_iter() + .map(|(k, v)| { + (k, CustomFieldResponse { + field_type: format!("{:?}", v.field_type).to_lowercase(), + value: v.value, + }) + }) + .collect(), - // Photo-specific metadata - date_taken: item.date_taken, - latitude: item.latitude, - longitude: item.longitude, - camera_make: item.camera_make, - camera_model: item.camera_model, - rating: item.rating, + // Photo-specific metadata + date_taken: item.date_taken, + latitude: item.latitude, + longitude: item.longitude, + camera_make: item.camera_make, + camera_model: item.camera_model, + rating: item.rating, - created_at: item.created_at, - updated_at: item.updated_at, + created_at: item.created_at, + updated_at: item.updated_at, - // Markdown links - links_extracted_at: item.links_extracted_at, - } + // Markdown links + links_extracted_at: item.links_extracted_at, } + } } impl From for TagResponse { - fn from(tag: pinakes_core::model::Tag) -> Self { - Self { - id: tag.id.to_string(), - name: tag.name, - parent_id: tag.parent_id.map(|id| id.to_string()), - created_at: tag.created_at, - } + fn from(tag: pinakes_core::model::Tag) -> Self { + Self { + id: tag.id.to_string(), + name: tag.name, + parent_id: tag.parent_id.map(|id| id.to_string()), + created_at: tag.created_at, } + } } impl From for CollectionResponse { - fn from(col: pinakes_core::model::Collection) -> Self { - Self { - id: col.id.to_string(), - name: col.name, - description: col.description, - kind: format!("{:?}", col.kind).to_lowercase(), - filter_query: col.filter_query, - created_at: col.created_at, - updated_at: col.updated_at, - } + fn from(col: pinakes_core::model::Collection) -> Self { + Self { + id: col.id.to_string(), + name: col.name, + description: col.description, + kind: format!("{:?}", col.kind).to_lowercase(), + filter_query: col.filter_query, + created_at: col.created_at, + updated_at: col.updated_at, } + } } impl From for AuditEntryResponse { - fn from(entry: pinakes_core::model::AuditEntry) -> Self { - Self { - id: entry.id.to_string(), - media_id: entry.media_id.map(|id| id.0.to_string()), - action: entry.action.to_string(), - details: entry.details, - timestamp: entry.timestamp, - } + fn from(entry: pinakes_core::model::AuditEntry) -> Self { + Self { + id: entry.id.to_string(), + media_id: entry.media_id.map(|id| id.0.to_string()), + action: entry.action.to_string(), + details: entry.details, + timestamp: entry.timestamp, } + } } // Plugins #[derive(Debug, Serialize)] pub struct PluginResponse { - pub id: String, - pub name: String, - pub version: String, - pub author: String, - pub description: String, - pub api_version: String, - pub enabled: bool, + pub id: String, + pub name: String, + pub version: String, + pub author: String, + pub description: String, + pub api_version: String, + pub enabled: bool, } #[derive(Debug, Deserialize)] pub struct InstallPluginRequest { - pub source: String, // URL or file path + pub source: String, // URL or file path } #[derive(Debug, Deserialize)] pub struct TogglePluginRequest { - pub enabled: bool, + pub enabled: bool, } impl PluginResponse { - pub fn new(meta: pinakes_plugin_api::PluginMetadata, enabled: bool) -> Self { - Self { - id: meta.id, - name: meta.name, - version: meta.version, - author: meta.author, - description: meta.description, - api_version: meta.api_version, - enabled, - } + pub fn new(meta: pinakes_plugin_api::PluginMetadata, enabled: bool) -> Self { + Self { + id: meta.id, + name: meta.name, + version: meta.version, + author: meta.author, + description: meta.description, + api_version: meta.api_version, + enabled, } + } } // Users #[derive(Debug, Serialize)] pub struct UserResponse { - pub id: String, - pub username: String, - pub role: String, - pub profile: UserProfileResponse, - pub created_at: DateTime, - pub updated_at: DateTime, + pub id: String, + pub username: String, + pub role: String, + pub profile: UserProfileResponse, + pub created_at: DateTime, + pub updated_at: DateTime, } #[derive(Debug, Serialize)] pub struct UserProfileResponse { - pub avatar_path: Option, - pub bio: Option, - pub preferences: UserPreferencesResponse, + pub avatar_path: Option, + pub bio: Option, + pub preferences: UserPreferencesResponse, } #[derive(Debug, Serialize)] pub struct UserPreferencesResponse { - pub theme: Option, - pub language: Option, - pub default_video_quality: Option, - pub auto_play: bool, + pub theme: Option, + pub language: Option, + pub default_video_quality: Option, + pub auto_play: bool, } #[derive(Debug, Serialize)] pub struct UserLibraryResponse { - pub user_id: String, - pub root_path: String, - pub permission: String, - pub granted_at: DateTime, + pub user_id: String, + pub root_path: String, + pub permission: String, + pub granted_at: DateTime, } #[derive(Debug, Deserialize)] pub struct GrantLibraryAccessRequest { - pub root_path: String, - pub permission: pinakes_core::users::LibraryPermission, + pub root_path: String, + pub permission: pinakes_core::users::LibraryPermission, } #[derive(Debug, Deserialize)] pub struct RevokeLibraryAccessRequest { - pub root_path: String, + pub root_path: String, } impl From for UserResponse { - fn from(user: pinakes_core::users::User) -> Self { - Self { - id: user.id.0.to_string(), - username: user.username, - role: user.role.to_string(), - profile: UserProfileResponse { - avatar_path: user.profile.avatar_path, - bio: user.profile.bio, - preferences: UserPreferencesResponse { - theme: user.profile.preferences.theme, - language: user.profile.preferences.language, - default_video_quality: user.profile.preferences.default_video_quality, - auto_play: user.profile.preferences.auto_play, - }, - }, - created_at: user.created_at, - updated_at: user.updated_at, - } + fn from(user: pinakes_core::users::User) -> Self { + Self { + id: user.id.0.to_string(), + username: user.username, + role: user.role.to_string(), + profile: UserProfileResponse { + avatar_path: user.profile.avatar_path, + bio: user.profile.bio, + preferences: UserPreferencesResponse { + theme: user.profile.preferences.theme, + language: user.profile.preferences.language, + default_video_quality: user.profile.preferences.default_video_quality, + auto_play: user.profile.preferences.auto_play, + }, + }, + created_at: user.created_at, + updated_at: user.updated_at, } + } } impl From for UserLibraryResponse { - fn from(access: pinakes_core::users::UserLibraryAccess) -> Self { - Self { - user_id: access.user_id.0.to_string(), - root_path: access.root_path, - permission: format!("{:?}", access.permission).to_lowercase(), - granted_at: access.granted_at, - } + fn from(access: pinakes_core::users::UserLibraryAccess) -> Self { + Self { + user_id: access.user_id.0.to_string(), + root_path: access.root_path, + permission: format!("{:?}", access.permission).to_lowercase(), + granted_at: access.granted_at, } + } } // ===== Social (Ratings, Comments, Favorites, Shares) ===== #[derive(Debug, Serialize)] pub struct RatingResponse { - pub id: String, - pub user_id: String, - pub media_id: String, - pub stars: u8, - pub review_text: Option, - pub created_at: DateTime, + pub id: String, + pub user_id: String, + pub media_id: String, + pub stars: u8, + pub review_text: Option, + pub created_at: DateTime, } impl From for RatingResponse { - fn from(r: pinakes_core::social::Rating) -> Self { - Self { - id: r.id.to_string(), - user_id: r.user_id.0.to_string(), - media_id: r.media_id.0.to_string(), - stars: r.stars, - review_text: r.review_text, - created_at: r.created_at, - } + fn from(r: pinakes_core::social::Rating) -> Self { + Self { + id: r.id.to_string(), + user_id: r.user_id.0.to_string(), + media_id: r.media_id.0.to_string(), + stars: r.stars, + review_text: r.review_text, + created_at: r.created_at, } + } } #[derive(Debug, Deserialize)] pub struct CreateRatingRequest { - pub stars: u8, - pub review_text: Option, + pub stars: u8, + pub review_text: Option, } #[derive(Debug, Serialize)] pub struct CommentResponse { - pub id: String, - pub user_id: String, - pub media_id: String, - pub parent_comment_id: Option, - pub text: String, - pub created_at: DateTime, + pub id: String, + pub user_id: String, + pub media_id: String, + pub parent_comment_id: Option, + pub text: String, + pub created_at: DateTime, } impl From for CommentResponse { - fn from(c: pinakes_core::social::Comment) -> Self { - Self { - id: c.id.to_string(), - user_id: c.user_id.0.to_string(), - media_id: c.media_id.0.to_string(), - parent_comment_id: c.parent_comment_id.map(|id| id.to_string()), - text: c.text, - created_at: c.created_at, - } + fn from(c: pinakes_core::social::Comment) -> Self { + Self { + id: c.id.to_string(), + user_id: c.user_id.0.to_string(), + media_id: c.media_id.0.to_string(), + parent_comment_id: c.parent_comment_id.map(|id| id.to_string()), + text: c.text, + created_at: c.created_at, } + } } #[derive(Debug, Deserialize)] pub struct CreateCommentRequest { - pub text: String, - pub parent_id: Option, + pub text: String, + pub parent_id: Option, } #[derive(Debug, Deserialize)] pub struct FavoriteRequest { - pub media_id: Uuid, + pub media_id: Uuid, } #[derive(Debug, Deserialize)] pub struct CreateShareLinkRequest { - pub media_id: Uuid, - pub password: Option, - pub expires_in_hours: Option, + pub media_id: Uuid, + pub password: Option, + pub expires_in_hours: Option, } #[derive(Debug, Serialize)] pub struct ShareLinkResponse { - pub id: String, - pub media_id: String, - pub token: String, - pub expires_at: Option>, - pub view_count: u64, - pub created_at: DateTime, + pub id: String, + pub media_id: String, + pub token: String, + pub expires_at: Option>, + pub view_count: u64, + pub created_at: DateTime, } impl From for ShareLinkResponse { - fn from(s: pinakes_core::social::ShareLink) -> Self { - Self { - id: s.id.to_string(), - media_id: s.media_id.0.to_string(), - token: s.token, - expires_at: s.expires_at, - view_count: s.view_count, - created_at: s.created_at, - } + fn from(s: pinakes_core::social::ShareLink) -> Self { + Self { + id: s.id.to_string(), + media_id: s.media_id.0.to_string(), + token: s.token, + expires_at: s.expires_at, + view_count: s.view_count, + created_at: s.created_at, } + } } // ===== Playlists ===== #[derive(Debug, Serialize)] pub struct PlaylistResponse { - pub id: String, - pub owner_id: String, - pub name: String, - pub description: Option, - pub is_public: bool, - pub is_smart: bool, - pub filter_query: Option, - pub created_at: DateTime, - pub updated_at: DateTime, + pub id: String, + pub owner_id: String, + pub name: String, + pub description: Option, + pub is_public: bool, + pub is_smart: bool, + pub filter_query: Option, + pub created_at: DateTime, + pub updated_at: DateTime, } impl From for PlaylistResponse { - fn from(p: pinakes_core::playlists::Playlist) -> Self { - Self { - id: p.id.to_string(), - owner_id: p.owner_id.0.to_string(), - name: p.name, - description: p.description, - is_public: p.is_public, - is_smart: p.is_smart, - filter_query: p.filter_query, - created_at: p.created_at, - updated_at: p.updated_at, - } + fn from(p: pinakes_core::playlists::Playlist) -> Self { + Self { + id: p.id.to_string(), + owner_id: p.owner_id.0.to_string(), + name: p.name, + description: p.description, + is_public: p.is_public, + is_smart: p.is_smart, + filter_query: p.filter_query, + created_at: p.created_at, + updated_at: p.updated_at, } + } } #[derive(Debug, Deserialize)] pub struct CreatePlaylistRequest { - pub name: String, - pub description: Option, - pub is_public: Option, - pub is_smart: Option, - pub filter_query: Option, + pub name: String, + pub description: Option, + pub is_public: Option, + pub is_smart: Option, + pub filter_query: Option, } #[derive(Debug, Deserialize)] pub struct UpdatePlaylistRequest { - pub name: Option, - pub description: Option, - pub is_public: Option, + pub name: Option, + pub description: Option, + pub is_public: Option, } #[derive(Debug, Deserialize)] pub struct PlaylistItemRequest { - pub media_id: Uuid, - pub position: Option, + pub media_id: Uuid, + pub position: Option, } #[derive(Debug, Deserialize)] pub struct ReorderPlaylistRequest { - pub media_id: Uuid, - pub new_position: i32, + pub media_id: Uuid, + pub new_position: i32, } // ===== Analytics ===== #[derive(Debug, Serialize)] pub struct UsageEventResponse { - pub id: String, - pub media_id: Option, - pub user_id: Option, - pub event_type: String, - pub timestamp: DateTime, - pub duration_secs: Option, + pub id: String, + pub media_id: Option, + pub user_id: Option, + pub event_type: String, + pub timestamp: DateTime, + pub duration_secs: Option, } impl From for UsageEventResponse { - fn from(e: pinakes_core::analytics::UsageEvent) -> Self { - Self { - id: e.id.to_string(), - media_id: e.media_id.map(|m| m.0.to_string()), - user_id: e.user_id.map(|u| u.0.to_string()), - event_type: e.event_type.to_string(), - timestamp: e.timestamp, - duration_secs: e.duration_secs, - } + fn from(e: pinakes_core::analytics::UsageEvent) -> Self { + Self { + id: e.id.to_string(), + media_id: e.media_id.map(|m| m.0.to_string()), + user_id: e.user_id.map(|u| u.0.to_string()), + event_type: e.event_type.to_string(), + timestamp: e.timestamp, + duration_secs: e.duration_secs, } + } } #[derive(Debug, Deserialize)] pub struct RecordUsageEventRequest { - pub media_id: Option, - pub event_type: String, - pub duration_secs: Option, - pub context: Option, + pub media_id: Option, + pub event_type: String, + pub duration_secs: Option, + pub context: Option, } #[derive(Debug, Serialize)] pub struct MostViewedResponse { - pub media: MediaResponse, - pub view_count: u64, + pub media: MediaResponse, + pub view_count: u64, } #[derive(Debug, Deserialize)] pub struct WatchProgressRequest { - pub progress_secs: f64, + pub progress_secs: f64, } #[derive(Debug, Serialize)] pub struct WatchProgressResponse { - pub progress_secs: f64, + pub progress_secs: f64, } // ===== Subtitles ===== #[derive(Debug, Serialize)] pub struct SubtitleResponse { - pub id: String, - pub media_id: String, - pub language: Option, - pub format: String, - pub is_embedded: bool, - pub track_index: Option, - pub offset_ms: i64, - pub created_at: DateTime, + pub id: String, + pub media_id: String, + pub language: Option, + pub format: String, + pub is_embedded: bool, + pub track_index: Option, + pub offset_ms: i64, + pub created_at: DateTime, } impl From for SubtitleResponse { - fn from(s: pinakes_core::subtitles::Subtitle) -> Self { - Self { - id: s.id.to_string(), - media_id: s.media_id.0.to_string(), - language: s.language, - format: s.format.to_string(), - is_embedded: s.is_embedded, - track_index: s.track_index, - offset_ms: s.offset_ms, - created_at: s.created_at, - } + fn from(s: pinakes_core::subtitles::Subtitle) -> Self { + Self { + id: s.id.to_string(), + media_id: s.media_id.0.to_string(), + language: s.language, + format: s.format.to_string(), + is_embedded: s.is_embedded, + track_index: s.track_index, + offset_ms: s.offset_ms, + created_at: s.created_at, } + } } #[derive(Debug, Deserialize)] pub struct AddSubtitleRequest { - pub language: Option, - pub format: String, - pub file_path: Option, - pub is_embedded: Option, - pub track_index: Option, - pub offset_ms: Option, + pub language: Option, + pub format: String, + pub file_path: Option, + pub is_embedded: Option, + pub track_index: Option, + pub offset_ms: Option, } #[derive(Debug, Deserialize)] pub struct UpdateSubtitleOffsetRequest { - pub offset_ms: i64, + pub offset_ms: i64, } // ===== Enrichment ===== #[derive(Debug, Serialize)] pub struct ExternalMetadataResponse { - pub id: String, - pub media_id: String, - pub source: String, - pub external_id: Option, - pub metadata: serde_json::Value, - pub confidence: f64, - pub last_updated: DateTime, + pub id: String, + pub media_id: String, + pub source: String, + pub external_id: Option, + pub metadata: serde_json::Value, + pub confidence: f64, + pub last_updated: DateTime, } -impl From for ExternalMetadataResponse { - fn from(m: pinakes_core::enrichment::ExternalMetadata) -> Self { - let metadata = serde_json::from_str(&m.metadata_json).unwrap_or_else(|e| { - tracing::warn!( - "failed to deserialize external metadata JSON for media {}: {}", - m.media_id.0, - e - ); - serde_json::Value::Null - }); - Self { - id: m.id.to_string(), - media_id: m.media_id.0.to_string(), - source: m.source.to_string(), - external_id: m.external_id, - metadata, - confidence: m.confidence, - last_updated: m.last_updated, - } +impl From + for ExternalMetadataResponse +{ + fn from(m: pinakes_core::enrichment::ExternalMetadata) -> Self { + let metadata = serde_json::from_str(&m.metadata_json).unwrap_or_else(|e| { + tracing::warn!( + "failed to deserialize external metadata JSON for media {}: {}", + m.media_id.0, + e + ); + serde_json::Value::Null + }); + Self { + id: m.id.to_string(), + media_id: m.media_id.0.to_string(), + source: m.source.to_string(), + external_id: m.external_id, + metadata, + confidence: m.confidence, + last_updated: m.last_updated, } + } } // ===== Transcode ===== #[derive(Debug, Serialize)] pub struct TranscodeSessionResponse { - pub id: String, - pub media_id: String, - pub profile: String, - pub status: String, - pub progress: f32, - pub created_at: DateTime, - pub expires_at: Option>, + pub id: String, + pub media_id: String, + pub profile: String, + pub status: String, + pub progress: f32, + pub created_at: DateTime, + pub expires_at: Option>, } -impl From for TranscodeSessionResponse { - fn from(s: pinakes_core::transcode::TranscodeSession) -> Self { - Self { - id: s.id.to_string(), - media_id: s.media_id.0.to_string(), - profile: s.profile, - status: s.status.as_str().to_string(), - progress: s.progress, - created_at: s.created_at, - expires_at: s.expires_at, - } +impl From + for TranscodeSessionResponse +{ + fn from(s: pinakes_core::transcode::TranscodeSession) -> Self { + Self { + id: s.id.to_string(), + media_id: s.media_id.0.to_string(), + profile: s.profile, + status: s.status.as_str().to_string(), + progress: s.progress, + created_at: s.created_at, + expires_at: s.expires_at, } + } } #[derive(Debug, Deserialize)] pub struct CreateTranscodeRequest { - pub profile: String, + pub profile: String, } // ===== Managed Storage / Upload ===== #[derive(Debug, Serialize)] pub struct UploadResponse { - pub media_id: String, - pub content_hash: String, - pub was_duplicate: bool, - pub file_size: u64, + pub media_id: String, + pub content_hash: String, + pub was_duplicate: bool, + pub file_size: u64, } impl From for UploadResponse { - fn from(result: pinakes_core::model::UploadResult) -> Self { - Self { - media_id: result.media_id.0.to_string(), - content_hash: result.content_hash.0, - was_duplicate: result.was_duplicate, - file_size: result.file_size, - } + fn from(result: pinakes_core::model::UploadResult) -> Self { + Self { + media_id: result.media_id.0.to_string(), + content_hash: result.content_hash.0, + was_duplicate: result.was_duplicate, + file_size: result.file_size, } + } } #[derive(Debug, Serialize)] pub struct ManagedStorageStatsResponse { - pub total_blobs: u64, - pub total_size_bytes: u64, - pub orphaned_blobs: u64, - pub deduplication_ratio: f64, + pub total_blobs: u64, + pub total_size_bytes: u64, + pub orphaned_blobs: u64, + pub deduplication_ratio: f64, } -impl From for ManagedStorageStatsResponse { - fn from(stats: pinakes_core::model::ManagedStorageStats) -> Self { - Self { - total_blobs: stats.total_blobs, - total_size_bytes: stats.total_size_bytes, - orphaned_blobs: stats.orphaned_blobs, - deduplication_ratio: stats.deduplication_ratio, - } +impl From + for ManagedStorageStatsResponse +{ + fn from(stats: pinakes_core::model::ManagedStorageStats) -> Self { + Self { + total_blobs: stats.total_blobs, + total_size_bytes: stats.total_size_bytes, + orphaned_blobs: stats.orphaned_blobs, + deduplication_ratio: stats.deduplication_ratio, } + } } // ===== Sync ===== #[derive(Debug, Deserialize)] pub struct RegisterDeviceRequest { - pub name: String, - pub device_type: String, - pub client_version: String, - pub os_info: Option, + pub name: String, + pub device_type: String, + pub client_version: String, + pub os_info: Option, } #[derive(Debug, Serialize)] pub struct DeviceResponse { - pub id: String, - pub name: String, - pub device_type: String, - pub client_version: String, - pub os_info: Option, - pub last_sync_at: Option>, - pub last_seen_at: DateTime, - pub sync_cursor: Option, - pub enabled: bool, - pub created_at: DateTime, + pub id: String, + pub name: String, + pub device_type: String, + pub client_version: String, + pub os_info: Option, + pub last_sync_at: Option>, + pub last_seen_at: DateTime, + pub sync_cursor: Option, + pub enabled: bool, + pub created_at: DateTime, } impl From for DeviceResponse { - fn from(d: pinakes_core::sync::SyncDevice) -> Self { - Self { - id: d.id.0.to_string(), - name: d.name, - device_type: d.device_type.to_string(), - client_version: d.client_version, - os_info: d.os_info, - last_sync_at: d.last_sync_at, - last_seen_at: d.last_seen_at, - sync_cursor: d.sync_cursor, - enabled: d.enabled, - created_at: d.created_at, - } + fn from(d: pinakes_core::sync::SyncDevice) -> Self { + Self { + id: d.id.0.to_string(), + name: d.name, + device_type: d.device_type.to_string(), + client_version: d.client_version, + os_info: d.os_info, + last_sync_at: d.last_sync_at, + last_seen_at: d.last_seen_at, + sync_cursor: d.sync_cursor, + enabled: d.enabled, + created_at: d.created_at, } + } } #[derive(Debug, Serialize)] pub struct DeviceRegistrationResponse { - pub device: DeviceResponse, - pub device_token: String, + pub device: DeviceResponse, + pub device_token: String, } #[derive(Debug, Deserialize)] pub struct UpdateDeviceRequest { - pub name: Option, - pub enabled: Option, + pub name: Option, + pub enabled: Option, } #[derive(Debug, Deserialize)] pub struct GetChangesParams { - pub cursor: Option, - pub limit: Option, + pub cursor: Option, + pub limit: Option, } #[derive(Debug, Serialize)] pub struct SyncChangeResponse { - pub id: String, - pub sequence: i64, - pub change_type: String, - pub media_id: Option, - pub path: String, - pub content_hash: Option, - pub file_size: Option, - pub timestamp: DateTime, + pub id: String, + pub sequence: i64, + pub change_type: String, + pub media_id: Option, + pub path: String, + pub content_hash: Option, + pub file_size: Option, + pub timestamp: DateTime, } impl From for SyncChangeResponse { - fn from(e: pinakes_core::sync::SyncLogEntry) -> Self { - Self { - id: e.id.to_string(), - sequence: e.sequence, - change_type: e.change_type.to_string(), - media_id: e.media_id.map(|id| id.0.to_string()), - path: e.path, - content_hash: e.content_hash.map(|h| h.0), - file_size: e.file_size, - timestamp: e.timestamp, - } + fn from(e: pinakes_core::sync::SyncLogEntry) -> Self { + Self { + id: e.id.to_string(), + sequence: e.sequence, + change_type: e.change_type.to_string(), + media_id: e.media_id.map(|id| id.0.to_string()), + path: e.path, + content_hash: e.content_hash.map(|h| h.0), + file_size: e.file_size, + timestamp: e.timestamp, } + } } #[derive(Debug, Serialize)] pub struct ChangesResponse { - pub changes: Vec, - pub cursor: i64, - pub has_more: bool, + pub changes: Vec, + pub cursor: i64, + pub has_more: bool, } #[derive(Debug, Deserialize)] pub struct ClientChangeReport { - pub path: String, - pub change_type: String, - pub content_hash: Option, - pub file_size: Option, - pub local_mtime: Option, + pub path: String, + pub change_type: String, + pub content_hash: Option, + pub file_size: Option, + pub local_mtime: Option, } #[derive(Debug, Deserialize)] pub struct ReportChangesRequest { - pub changes: Vec, + pub changes: Vec, } #[derive(Debug, Serialize)] pub struct ReportChangesResponse { - pub accepted: Vec, - pub conflicts: Vec, - pub upload_required: Vec, + pub accepted: Vec, + pub conflicts: Vec, + pub upload_required: Vec, } #[derive(Debug, Serialize)] pub struct ConflictResponse { - pub id: String, - pub path: String, - pub local_hash: String, - pub server_hash: String, - pub detected_at: DateTime, + pub id: String, + pub path: String, + pub local_hash: String, + pub server_hash: String, + pub detected_at: DateTime, } impl From for ConflictResponse { - fn from(c: pinakes_core::sync::SyncConflict) -> Self { - Self { - id: c.id.to_string(), - path: c.path, - local_hash: c.local_hash, - server_hash: c.server_hash, - detected_at: c.detected_at, - } + fn from(c: pinakes_core::sync::SyncConflict) -> Self { + Self { + id: c.id.to_string(), + path: c.path, + local_hash: c.local_hash, + server_hash: c.server_hash, + detected_at: c.detected_at, } + } } #[derive(Debug, Deserialize)] pub struct ResolveConflictRequest { - pub resolution: String, + pub resolution: String, } #[derive(Debug, Deserialize)] pub struct CreateUploadSessionRequest { - pub target_path: String, - pub expected_hash: String, - pub expected_size: u64, - pub chunk_size: Option, + pub target_path: String, + pub expected_hash: String, + pub expected_size: u64, + pub chunk_size: Option, } #[derive(Debug, Serialize)] pub struct UploadSessionResponse { - pub id: String, - pub target_path: String, - pub expected_hash: String, - pub expected_size: u64, - pub chunk_size: u64, - pub chunk_count: u64, - pub status: String, - pub created_at: DateTime, - pub expires_at: DateTime, + pub id: String, + pub target_path: String, + pub expected_hash: String, + pub expected_size: u64, + pub chunk_size: u64, + pub chunk_count: u64, + pub status: String, + pub created_at: DateTime, + pub expires_at: DateTime, } impl From for UploadSessionResponse { - fn from(s: pinakes_core::sync::UploadSession) -> Self { - Self { - id: s.id.to_string(), - target_path: s.target_path, - expected_hash: s.expected_hash.0, - expected_size: s.expected_size, - chunk_size: s.chunk_size, - chunk_count: s.chunk_count, - status: s.status.to_string(), - created_at: s.created_at, - expires_at: s.expires_at, - } + fn from(s: pinakes_core::sync::UploadSession) -> Self { + Self { + id: s.id.to_string(), + target_path: s.target_path, + expected_hash: s.expected_hash.0, + expected_size: s.expected_size, + chunk_size: s.chunk_size, + chunk_count: s.chunk_count, + status: s.status.to_string(), + created_at: s.created_at, + expires_at: s.expires_at, } + } } #[derive(Debug, Serialize)] pub struct ChunkUploadedResponse { - pub chunk_index: u64, - pub received: bool, + pub chunk_index: u64, + pub received: bool, } #[derive(Debug, Deserialize)] pub struct AcknowledgeChangesRequest { - pub cursor: i64, + pub cursor: i64, } // ===== Enhanced Sharing ===== #[derive(Debug, Deserialize)] pub struct CreateShareRequest { - pub target_type: String, - pub target_id: String, - pub recipient_type: String, - pub recipient_user_id: Option, - pub recipient_group_id: Option, - pub password: Option, - pub permissions: Option, - pub note: Option, - pub expires_in_hours: Option, - pub inherit_to_children: Option, + pub target_type: String, + pub target_id: String, + pub recipient_type: String, + pub recipient_user_id: Option, + pub recipient_group_id: Option, + pub password: Option, + pub permissions: Option, + pub note: Option, + pub expires_in_hours: Option, + pub inherit_to_children: Option, } #[derive(Debug, Deserialize)] pub struct SharePermissionsRequest { - pub can_view: Option, - pub can_download: Option, - pub can_edit: Option, - pub can_delete: Option, - pub can_reshare: Option, - pub can_add: Option, + pub can_view: Option, + pub can_download: Option, + pub can_edit: Option, + pub can_delete: Option, + pub can_reshare: Option, + pub can_add: Option, } #[derive(Debug, Serialize)] pub struct ShareResponse { - pub id: String, - pub target_type: String, - pub target_id: String, - pub owner_id: String, - pub recipient_type: String, - pub recipient_user_id: Option, - pub recipient_group_id: Option, - pub public_token: Option, - pub permissions: SharePermissionsResponse, - pub note: Option, - pub expires_at: Option>, - pub access_count: u64, - pub last_accessed: Option>, - pub inherit_to_children: bool, - pub created_at: DateTime, - pub updated_at: DateTime, + pub id: String, + pub target_type: String, + pub target_id: String, + pub owner_id: String, + pub recipient_type: String, + pub recipient_user_id: Option, + pub recipient_group_id: Option, + pub public_token: Option, + pub permissions: SharePermissionsResponse, + pub note: Option, + pub expires_at: Option>, + pub access_count: u64, + pub last_accessed: Option>, + pub inherit_to_children: bool, + pub created_at: DateTime, + pub updated_at: DateTime, } #[derive(Debug, Serialize)] pub struct SharePermissionsResponse { - pub can_view: bool, - pub can_download: bool, - pub can_edit: bool, - pub can_delete: bool, - pub can_reshare: bool, - pub can_add: bool, + pub can_view: bool, + pub can_download: bool, + pub can_edit: bool, + pub can_delete: bool, + pub can_reshare: bool, + pub can_add: bool, } -impl From for SharePermissionsResponse { - fn from(p: pinakes_core::sharing::SharePermissions) -> Self { - Self { - can_view: p.can_view, - can_download: p.can_download, - can_edit: p.can_edit, - can_delete: p.can_delete, - can_reshare: p.can_reshare, - can_add: p.can_add, - } +impl From + for SharePermissionsResponse +{ + fn from(p: pinakes_core::sharing::SharePermissions) -> Self { + Self { + can_view: p.can_view, + can_download: p.can_download, + can_edit: p.can_edit, + can_delete: p.can_delete, + can_reshare: p.can_reshare, + can_add: p.can_add, } + } } impl From for ShareResponse { - fn from(s: pinakes_core::sharing::Share) -> Self { - let (target_type, target_id) = match &s.target { - pinakes_core::sharing::ShareTarget::Media { media_id } => { - ("media".to_string(), media_id.0.to_string()) - } - pinakes_core::sharing::ShareTarget::Collection { collection_id } => { - ("collection".to_string(), collection_id.to_string()) - } - pinakes_core::sharing::ShareTarget::Tag { tag_id } => { - ("tag".to_string(), tag_id.to_string()) - } - pinakes_core::sharing::ShareTarget::SavedSearch { search_id } => { - ("saved_search".to_string(), search_id.to_string()) - } - }; + fn from(s: pinakes_core::sharing::Share) -> Self { + let (target_type, target_id) = match &s.target { + pinakes_core::sharing::ShareTarget::Media { media_id } => { + ("media".to_string(), media_id.0.to_string()) + }, + pinakes_core::sharing::ShareTarget::Collection { collection_id } => { + ("collection".to_string(), collection_id.to_string()) + }, + pinakes_core::sharing::ShareTarget::Tag { tag_id } => { + ("tag".to_string(), tag_id.to_string()) + }, + pinakes_core::sharing::ShareTarget::SavedSearch { search_id } => { + ("saved_search".to_string(), search_id.to_string()) + }, + }; - let (recipient_type, recipient_user_id, recipient_group_id, public_token) = - match &s.recipient { - pinakes_core::sharing::ShareRecipient::PublicLink { token, .. } => { - ("public_link".to_string(), None, None, Some(token.clone())) - } - pinakes_core::sharing::ShareRecipient::User { user_id } => { - ("user".to_string(), Some(user_id.0.to_string()), None, None) - } - pinakes_core::sharing::ShareRecipient::Group { group_id } => { - ("group".to_string(), None, Some(group_id.to_string()), None) - } - pinakes_core::sharing::ShareRecipient::Federated { .. } => { - ("federated".to_string(), None, None, None) - } - }; + let (recipient_type, recipient_user_id, recipient_group_id, public_token) = + match &s.recipient { + pinakes_core::sharing::ShareRecipient::PublicLink { token, .. } => { + ("public_link".to_string(), None, None, Some(token.clone())) + }, + pinakes_core::sharing::ShareRecipient::User { user_id } => { + ("user".to_string(), Some(user_id.0.to_string()), None, None) + }, + pinakes_core::sharing::ShareRecipient::Group { group_id } => { + ("group".to_string(), None, Some(group_id.to_string()), None) + }, + pinakes_core::sharing::ShareRecipient::Federated { .. } => { + ("federated".to_string(), None, None, None) + }, + }; - Self { - id: s.id.0.to_string(), - target_type, - target_id, - owner_id: s.owner_id.0.to_string(), - recipient_type, - recipient_user_id, - recipient_group_id, - public_token, - permissions: s.permissions.into(), - note: s.note, - expires_at: s.expires_at, - access_count: s.access_count, - last_accessed: s.last_accessed, - inherit_to_children: s.inherit_to_children, - created_at: s.created_at, - updated_at: s.updated_at, - } + Self { + id: s.id.0.to_string(), + target_type, + target_id, + owner_id: s.owner_id.0.to_string(), + recipient_type, + recipient_user_id, + recipient_group_id, + public_token, + permissions: s.permissions.into(), + note: s.note, + expires_at: s.expires_at, + access_count: s.access_count, + last_accessed: s.last_accessed, + inherit_to_children: s.inherit_to_children, + created_at: s.created_at, + updated_at: s.updated_at, } + } } #[derive(Debug, Deserialize)] pub struct UpdateShareRequest { - pub permissions: Option, - pub note: Option, - pub expires_at: Option>, - pub inherit_to_children: Option, + pub permissions: Option, + pub note: Option, + pub expires_at: Option>, + pub inherit_to_children: Option, } #[derive(Debug, Serialize)] pub struct ShareActivityResponse { - pub id: String, - pub share_id: String, - pub actor_id: Option, - pub actor_ip: Option, - pub action: String, - pub details: Option, - pub timestamp: DateTime, + pub id: String, + pub share_id: String, + pub actor_id: Option, + pub actor_ip: Option, + pub action: String, + pub details: Option, + pub timestamp: DateTime, } impl From for ShareActivityResponse { - fn from(a: pinakes_core::sharing::ShareActivity) -> Self { - Self { - id: a.id.to_string(), - share_id: a.share_id.0.to_string(), - actor_id: a.actor_id.map(|id| id.0.to_string()), - actor_ip: a.actor_ip, - action: a.action.to_string(), - details: a.details, - timestamp: a.timestamp, - } + fn from(a: pinakes_core::sharing::ShareActivity) -> Self { + Self { + id: a.id.to_string(), + share_id: a.share_id.0.to_string(), + actor_id: a.actor_id.map(|id| id.0.to_string()), + actor_ip: a.actor_ip, + action: a.action.to_string(), + details: a.details, + timestamp: a.timestamp, } + } } #[derive(Debug, Serialize)] pub struct ShareNotificationResponse { - pub id: String, - pub share_id: String, - pub notification_type: String, - pub is_read: bool, - pub created_at: DateTime, + pub id: String, + pub share_id: String, + pub notification_type: String, + pub is_read: bool, + pub created_at: DateTime, } -impl From for ShareNotificationResponse { - fn from(n: pinakes_core::sharing::ShareNotification) -> Self { - Self { - id: n.id.to_string(), - share_id: n.share_id.0.to_string(), - notification_type: n.notification_type.to_string(), - is_read: n.is_read, - created_at: n.created_at, - } +impl From + for ShareNotificationResponse +{ + fn from(n: pinakes_core::sharing::ShareNotification) -> Self { + Self { + id: n.id.to_string(), + share_id: n.share_id.0.to_string(), + notification_type: n.notification_type.to_string(), + is_read: n.is_read, + created_at: n.created_at, } + } } #[derive(Debug, Deserialize)] pub struct BatchDeleteSharesRequest { - pub share_ids: Vec, + pub share_ids: Vec, } #[derive(Debug, Deserialize)] pub struct AccessSharedRequest { - pub password: Option, + pub password: Option, } diff --git a/crates/pinakes-server/src/error.rs b/crates/pinakes-server/src/error.rs index eeade45..bb57191 100644 --- a/crates/pinakes-server/src/error.rs +++ b/crates/pinakes-server/src/error.rs @@ -1,99 +1,107 @@ -use axum::http::StatusCode; -use axum::response::{IntoResponse, Response}; +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, +}; use serde::Serialize; #[derive(Debug, Serialize)] struct ErrorResponse { - error: String, + error: String, } pub struct ApiError(pub pinakes_core::error::PinakesError); impl IntoResponse for ApiError { - fn into_response(self) -> Response { - use pinakes_core::error::PinakesError; - let (status, message) = match &self.0 { - PinakesError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), - PinakesError::FileNotFound(path) => { - // Only expose the file name, not the full path - let name = path - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| "unknown".to_string()); - tracing::debug!(path = %path.display(), "file not found"); - (StatusCode::NOT_FOUND, format!("file not found: {name}")) - } - PinakesError::TagNotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), - PinakesError::CollectionNotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), - PinakesError::DuplicateHash(msg) => (StatusCode::CONFLICT, msg.clone()), - PinakesError::UnsupportedMediaType(path) => { - let name = path - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| "unknown".to_string()); - ( - StatusCode::BAD_REQUEST, - format!("unsupported media type: {name}"), - ) - } - PinakesError::SearchParse(msg) => (StatusCode::BAD_REQUEST, msg.clone()), - PinakesError::InvalidOperation(msg) => (StatusCode::BAD_REQUEST, msg.clone()), - PinakesError::Authentication(msg) => (StatusCode::UNAUTHORIZED, msg.clone()), - PinakesError::Authorization(msg) => (StatusCode::FORBIDDEN, msg.clone()), - PinakesError::Config(_) => { - tracing::error!(error = %self.0, "configuration error"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - "internal configuration error".to_string(), - ) - } - _ => { - tracing::error!(error = %self.0, "internal server error"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - "internal server error".to_string(), - ) - } - }; + fn into_response(self) -> Response { + use pinakes_core::error::PinakesError; + let (status, message) = match &self.0 { + PinakesError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), + PinakesError::FileNotFound(path) => { + // Only expose the file name, not the full path + let name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + tracing::debug!(path = %path.display(), "file not found"); + (StatusCode::NOT_FOUND, format!("file not found: {name}")) + }, + PinakesError::TagNotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), + PinakesError::CollectionNotFound(msg) => { + (StatusCode::NOT_FOUND, msg.clone()) + }, + PinakesError::DuplicateHash(msg) => (StatusCode::CONFLICT, msg.clone()), + PinakesError::UnsupportedMediaType(path) => { + let name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + ( + StatusCode::BAD_REQUEST, + format!("unsupported media type: {name}"), + ) + }, + PinakesError::SearchParse(msg) => (StatusCode::BAD_REQUEST, msg.clone()), + PinakesError::InvalidOperation(msg) => { + (StatusCode::BAD_REQUEST, msg.clone()) + }, + PinakesError::Authentication(msg) => { + (StatusCode::UNAUTHORIZED, msg.clone()) + }, + PinakesError::Authorization(msg) => (StatusCode::FORBIDDEN, msg.clone()), + PinakesError::Config(_) => { + tracing::error!(error = %self.0, "configuration error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal configuration error".to_string(), + ) + }, + _ => { + tracing::error!(error = %self.0, "internal server error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal server error".to_string(), + ) + }, + }; - let body = serde_json::to_string(&ErrorResponse { - error: message.clone(), - }) - .unwrap_or_else(|_| format!(r#"{{"error":"{}"}}"#, message)); - (status, [("content-type", "application/json")], body).into_response() - } + let body = serde_json::to_string(&ErrorResponse { + error: message.clone(), + }) + .unwrap_or_else(|_| format!(r#"{{"error":"{}"}}"#, message)); + (status, [("content-type", "application/json")], body).into_response() + } } impl From for ApiError { - fn from(e: pinakes_core::error::PinakesError) -> Self { - Self(e) - } + fn from(e: pinakes_core::error::PinakesError) -> Self { + Self(e) + } } impl ApiError { - pub fn bad_request(msg: impl Into) -> Self { - Self(pinakes_core::error::PinakesError::InvalidOperation( - msg.into(), - )) - } + pub fn bad_request(msg: impl Into) -> Self { + Self(pinakes_core::error::PinakesError::InvalidOperation( + msg.into(), + )) + } - pub fn not_found(msg: impl Into) -> Self { - Self(pinakes_core::error::PinakesError::NotFound(msg.into())) - } + pub fn not_found(msg: impl Into) -> Self { + Self(pinakes_core::error::PinakesError::NotFound(msg.into())) + } - pub fn internal(msg: impl Into) -> Self { - Self(pinakes_core::error::PinakesError::Database(msg.into())) - } + pub fn internal(msg: impl Into) -> Self { + Self(pinakes_core::error::PinakesError::Database(msg.into())) + } - pub fn forbidden(msg: impl Into) -> Self { - Self(pinakes_core::error::PinakesError::Authorization(msg.into())) - } + pub fn forbidden(msg: impl Into) -> Self { + Self(pinakes_core::error::PinakesError::Authorization(msg.into())) + } - pub fn unauthorized(msg: impl Into) -> Self { - Self(pinakes_core::error::PinakesError::Authentication( - msg.into(), - )) - } + pub fn unauthorized(msg: impl Into) -> Self { + Self(pinakes_core::error::PinakesError::Authentication( + msg.into(), + )) + } } pub type ApiResult = Result; diff --git a/crates/pinakes-server/src/main.rs b/crates/pinakes-server/src/main.rs index d513fcd..54808e8 100644 --- a/crates/pinakes-server/src/main.rs +++ b/crates/pinakes-server/src/main.rs @@ -1,769 +1,798 @@ -use std::path::PathBuf; -use std::sync::Arc; +use std::{path::PathBuf, sync::Arc}; use anyhow::Result; -use axum::Router; -use axum::response::Redirect; -use axum::routing::any; +use axum::{Router, response::Redirect, routing::any}; use clap::Parser; +use pinakes_core::{config::Config, storage::StorageBackend}; +use pinakes_server::{app, state::AppState}; use tokio::sync::RwLock; use tracing::info; use tracing_subscriber::EnvFilter; -use pinakes_core::config::Config; -use pinakes_core::storage::StorageBackend; - -use pinakes_server::app; -use pinakes_server::state::AppState; - /// Pinakes media cataloging server #[derive(Parser)] #[command(name = "pinakes-server", version, about)] struct Cli { - /// Path to configuration file - #[arg(short, long, env = "PINAKES_CONFIG")] - config: Option, + /// Path to configuration file + #[arg(short, long, env = "PINAKES_CONFIG")] + config: Option, - /// Override listen host - #[arg(long)] - host: Option, + /// Override listen host + #[arg(long)] + host: Option, - /// Override listen port - #[arg(short, long)] - port: Option, + /// Override listen port + #[arg(short, long)] + port: Option, - /// Set log level (trace, debug, info, warn, error) - #[arg(long, default_value = "info")] - log_level: String, + /// Set log level (trace, debug, info, warn, error) + #[arg(long, default_value = "info")] + log_level: String, - /// Log output format (compact, full, pretty, json) - #[arg(long, default_value = "compact")] - log_format: String, + /// Log output format (compact, full, pretty, json) + #[arg(long, default_value = "compact")] + log_format: String, - /// Run database migrations only, then exit - #[arg(long)] - migrate_only: bool, + /// Run database migrations only, then exit + #[arg(long)] + migrate_only: bool, } /// Resolve the configuration file path. /// Returns (path, was_explicit) where was_explicit indicates if the path was /// explicitly provided by the user (vs discovered). fn resolve_config_path(explicit: Option<&std::path::Path>) -> (PathBuf, bool) { - if let Some(path) = explicit { - return (path.to_path_buf(), true); - } - // Check current directory - let local = PathBuf::from("pinakes.toml"); - if local.exists() { - return (local, false); - } - // XDG default - (Config::default_config_path(), false) + if let Some(path) = explicit { + return (path.to_path_buf(), true); + } + // Check current directory + let local = PathBuf::from("pinakes.toml"); + if local.exists() { + return (local, false); + } + // XDG default + (Config::default_config_path(), false) } #[tokio::main] async fn main() -> Result<()> { - let cli = Cli::parse(); + let cli = Cli::parse(); - // Initialize logging - let env_filter = EnvFilter::try_new(&cli.log_level).unwrap_or_else(|_| EnvFilter::new("info")); + // Initialize logging + let env_filter = EnvFilter::try_new(&cli.log_level) + .unwrap_or_else(|_| EnvFilter::new("info")); - match cli.log_format.as_str() { - "json" => { - tracing_subscriber::fmt() - .with_env_filter(env_filter) - .json() - .init(); - } - "pretty" => { - tracing_subscriber::fmt() - .with_env_filter(env_filter) - .pretty() - .init(); - } - "full" => { - tracing_subscriber::fmt().with_env_filter(env_filter).init(); - } - _ => { - tracing_subscriber::fmt() - .with_env_filter(env_filter) - .compact() - .init(); - } + match cli.log_format.as_str() { + "json" => { + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .json() + .init(); + }, + "pretty" => { + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .pretty() + .init(); + }, + "full" => { + tracing_subscriber::fmt().with_env_filter(env_filter).init(); + }, + _ => { + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .compact() + .init(); + }, + } + + let (config_path, was_explicit) = resolve_config_path(cli.config.as_deref()); + + let mut config = if config_path.exists() { + info!(path = %config_path.display(), "loading configuration from file"); + Config::from_file(&config_path)? + } else if was_explicit { + // User explicitly provided a config path that doesn't exist - this is an + // error + return Err(anyhow::anyhow!( + "configuration file not found: {}", + config_path.display() + )); + } else { + info!( + "using default configuration (no config file found at {})", + config_path.display() + ); + Config::default() + }; + config.ensure_dirs()?; + config + .validate() + .map_err(|e| anyhow::anyhow!("invalid configuration: {e}"))?; + + // Warn about authentication configuration + if config.server.authentication_disabled { + tracing::warn!( + "⚠️ AUTHENTICATION IS DISABLED - All requests will be allowed without \ + authentication!" + ); + tracing::warn!( + "⚠️ This is INSECURE and should only be used for development." + ); + } else { + let has_api_key = config + .server + .api_key + .as_ref() + .is_some_and(|k| !k.is_empty()); + let has_accounts = !config.accounts.users.is_empty(); + if !has_api_key && !has_accounts { + tracing::error!("⚠️ No authentication method configured!"); } + } - let (config_path, was_explicit) = resolve_config_path(cli.config.as_deref()); + // Apply CLI overrides + if let Some(host) = cli.host { + config.server.host = host; + } + if let Some(port) = cli.port { + config.server.port = port; + } - let mut config = if config_path.exists() { - info!(path = %config_path.display(), "loading configuration from file"); - Config::from_file(&config_path)? - } else if was_explicit { - // User explicitly provided a config path that doesn't exist - this is an error - return Err(anyhow::anyhow!( - "configuration file not found: {}", - config_path.display() - )); + // Storage backend initialization + let storage: pinakes_core::storage::DynStorageBackend = match config + .storage + .backend + { + pinakes_core::config::StorageBackendType::Sqlite => { + let sqlite_config = config.storage.sqlite.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "sqlite storage selected but [storage.sqlite] config section missing" + ) + })?; + info!(path = %sqlite_config.path.display(), "initializing sqlite storage"); + let backend = + pinakes_core::storage::sqlite::SqliteBackend::new(&sqlite_config.path)?; + backend.run_migrations().await?; + Arc::new(backend) + }, + pinakes_core::config::StorageBackendType::Postgres => { + let pg_config = config.storage.postgres.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "postgres storage selected but [storage.postgres] config section \ + missing" + ) + })?; + info!(host = %pg_config.host, port = pg_config.port, database = %pg_config.database, "initializing postgres storage"); + let backend = + pinakes_core::storage::postgres::PostgresBackend::new(pg_config) + .await?; + backend.run_migrations().await?; + Arc::new(backend) + }, + }; + + if cli.migrate_only { + info!("migrations complete, exiting"); + return Ok(()); + } + + // Register root directories + for root in &config.directories.roots { + if root.exists() { + storage.add_root_dir(root.clone()).await?; + info!(path = %root.display(), "registered root directory"); } else { - info!( - "using default configuration (no config file found at {})", - config_path.display() - ); - Config::default() - }; - config.ensure_dirs()?; - config - .validate() - .map_err(|e| anyhow::anyhow!("invalid configuration: {e}"))?; - - // Warn about authentication configuration - if config.server.authentication_disabled { - tracing::warn!( - "⚠️ AUTHENTICATION IS DISABLED - All requests will be allowed without authentication!" - ); - tracing::warn!("⚠️ This is INSECURE and should only be used for development."); - } else { - let has_api_key = config - .server - .api_key - .as_ref() - .is_some_and(|k| !k.is_empty()); - let has_accounts = !config.accounts.users.is_empty(); - if !has_api_key && !has_accounts { - tracing::error!("⚠️ No authentication method configured!"); - } + tracing::warn!(path = %root.display(), "root directory does not exist, skipping"); } + } - // Apply CLI overrides - if let Some(host) = cli.host { - config.server.host = host; - } - if let Some(port) = cli.port { - config.server.port = port; - } + // Start filesystem watcher if configured + if config.scanning.watch { + let watch_storage = storage.clone(); + let watch_dirs = config.directories.roots.clone(); + let watch_ignore = config.scanning.ignore_patterns.clone(); + tokio::spawn(async move { + if let Err(e) = pinakes_core::scan::watch_and_import( + watch_storage, + watch_dirs, + watch_ignore, + ) + .await + { + tracing::error!(error = %e, "filesystem watcher failed"); + } + }); + info!("filesystem watcher started"); + } - // Storage backend initialization - let storage: pinakes_core::storage::DynStorageBackend = match config.storage.backend { - pinakes_core::config::StorageBackendType::Sqlite => { - let sqlite_config = config.storage.sqlite.as_ref().ok_or_else(|| { - anyhow::anyhow!( - "sqlite storage selected but [storage.sqlite] config section missing" + let addr = format!("{}:{}", config.server.host, config.server.port); + + // Initialize transcode service early so the job queue can reference it + let transcode_service: Option< + Arc, + > = if config.transcoding.enabled { + Some(Arc::new(pinakes_core::transcode::TranscodeService::new( + config.transcoding.clone(), + ))) + } else { + None + }; + + // Initialize job queue with executor + let job_storage = storage.clone(); + let job_config = config.clone(); + let job_transcode = transcode_service.clone(); + let job_queue = pinakes_core::jobs::JobQueue::new( + config.jobs.worker_count, + move |job_id, kind, cancel, jobs| { + let storage = job_storage.clone(); + let config = job_config.clone(); + let transcode_svc = job_transcode.clone(); + tokio::spawn(async move { + use pinakes_core::jobs::{JobKind, JobQueue}; + match kind { + JobKind::Scan { path } => { + let ignore = config.scanning.ignore_patterns.clone(); + let res = if let Some(p) = path { + pinakes_core::scan::scan_directory(&storage, &p, &ignore).await + } else { + pinakes_core::scan::scan_all_roots(&storage, &ignore) + .await + .map(|statuses| { + let total_found: usize = + statuses.iter().map(|s| s.files_found).sum(); + let total_processed: usize = + statuses.iter().map(|s| s.files_processed).sum(); + let all_errors: Vec = + statuses.into_iter().flat_map(|s| s.errors).collect(); + pinakes_core::scan::ScanStatus { + scanning: false, + files_found: total_found, + files_processed: total_processed, + files_skipped: 0, + errors: all_errors, + } + }) + }; + match res { + Ok(status) => { + JobQueue::complete( + &jobs, + job_id, + serde_json::json!({ + "files_found": status.files_found, + "files_processed": status.files_processed, + "errors": status.errors, + }), ) - })?; - info!(path = %sqlite_config.path.display(), "initializing sqlite storage"); - let backend = pinakes_core::storage::sqlite::SqliteBackend::new(&sqlite_config.path)?; - backend.run_migrations().await?; - Arc::new(backend) - } - pinakes_core::config::StorageBackendType::Postgres => { - let pg_config = config.storage.postgres.as_ref().ok_or_else(|| { - anyhow::anyhow!( - "postgres storage selected but [storage.postgres] config section missing" - ) - })?; - info!(host = %pg_config.host, port = pg_config.port, database = %pg_config.database, "initializing postgres storage"); - let backend = pinakes_core::storage::postgres::PostgresBackend::new(pg_config).await?; - backend.run_migrations().await?; - Arc::new(backend) - } - }; - - if cli.migrate_only { - info!("migrations complete, exiting"); - return Ok(()); - } - - // Register root directories - for root in &config.directories.roots { - if root.exists() { - storage.add_root_dir(root.clone()).await?; - info!(path = %root.display(), "registered root directory"); - } else { - tracing::warn!(path = %root.display(), "root directory does not exist, skipping"); - } - } - - // Start filesystem watcher if configured - if config.scanning.watch { - let watch_storage = storage.clone(); - let watch_dirs = config.directories.roots.clone(); - let watch_ignore = config.scanning.ignore_patterns.clone(); - tokio::spawn(async move { - if let Err(e) = - pinakes_core::scan::watch_and_import(watch_storage, watch_dirs, watch_ignore).await - { - tracing::error!(error = %e, "filesystem watcher failed"); + .await; + }, + Err(e) => { + JobQueue::fail(&jobs, job_id, e.to_string()).await; + }, } - }); - info!("filesystem watcher started"); - } - - let addr = format!("{}:{}", config.server.host, config.server.port); - - // Initialize transcode service early so the job queue can reference it - let transcode_service: Option> = - if config.transcoding.enabled { - Some(Arc::new(pinakes_core::transcode::TranscodeService::new( - config.transcoding.clone(), - ))) - } else { - None - }; - - // Initialize job queue with executor - let job_storage = storage.clone(); - let job_config = config.clone(); - let job_transcode = transcode_service.clone(); - let job_queue = pinakes_core::jobs::JobQueue::new( - config.jobs.worker_count, - move |job_id, kind, cancel, jobs| { - let storage = job_storage.clone(); - let config = job_config.clone(); - let transcode_svc = job_transcode.clone(); - tokio::spawn(async move { - use pinakes_core::jobs::{JobKind, JobQueue}; - match kind { - JobKind::Scan { path } => { - let ignore = config.scanning.ignore_patterns.clone(); - let res = if let Some(p) = path { - pinakes_core::scan::scan_directory(&storage, &p, &ignore).await - } else { - pinakes_core::scan::scan_all_roots(&storage, &ignore) - .await - .map(|statuses| { - let total_found: usize = - statuses.iter().map(|s| s.files_found).sum(); - let total_processed: usize = - statuses.iter().map(|s| s.files_processed).sum(); - let all_errors: Vec = - statuses.into_iter().flat_map(|s| s.errors).collect(); - pinakes_core::scan::ScanStatus { - scanning: false, - files_found: total_found, - files_processed: total_processed, - files_skipped: 0, - errors: all_errors, - } - }) - }; - match res { - Ok(status) => { - JobQueue::complete( - &jobs, - job_id, - serde_json::json!({ - "files_found": status.files_found, - "files_processed": status.files_processed, - "errors": status.errors, - }), - ) - .await; - } - Err(e) => { - JobQueue::fail(&jobs, job_id, e.to_string()).await; - } - } - } - JobKind::GenerateThumbnails { media_ids } => { - let thumb_dir = pinakes_core::thumbnail::default_thumbnail_dir(); - let thumb_config = config.thumbnails.clone(); - let total = media_ids.len(); - let mut generated = 0usize; - let mut errors = Vec::new(); - for (i, mid) in media_ids.iter().enumerate() { - if cancel.is_cancelled() { - break; - } - JobQueue::update_progress( - &jobs, - job_id, - i as f32 / total as f32, - format!("{}/{}", i, total), - ) - .await; - match storage.get_media(*mid).await { - Ok(item) => { - let source = item.path.clone(); - let mt = item.media_type.clone(); - let id = item.id; - let td = thumb_dir.clone(); - let tc = thumb_config.clone(); - let res = tokio::task::spawn_blocking(move || { - pinakes_core::thumbnail::generate_thumbnail_with_config( - id, &source, mt, &td, &tc, - ) - }) - .await; - match res { - Ok(Ok(Some(path))) => { - let mut updated = item; - updated.thumbnail_path = Some(path); - let _ = storage.update_media(&updated).await; - generated += 1; - } - Ok(Ok(None)) => {} - Ok(Err(e)) => errors.push(format!("{}: {}", mid, e)), - Err(e) => errors.push(format!("{}: {}", mid, e)), - } - } - Err(e) => errors.push(format!("{}: {}", mid, e)), - } - } - JobQueue::complete( - &jobs, - job_id, - serde_json::json!({ - "generated": generated, "errors": errors - }), - ) - .await; - } - JobKind::VerifyIntegrity { media_ids } => { - let ids = if media_ids.is_empty() { - None - } else { - Some(media_ids.as_slice()) - }; - match pinakes_core::integrity::verify_integrity(&storage, ids).await { - Ok(report) => { - JobQueue::complete( - &jobs, - job_id, - serde_json::to_value(&report).unwrap_or_default(), - ) - .await; - } - Err(e) => JobQueue::fail(&jobs, job_id, e.to_string()).await, - } - } - JobKind::OrphanDetection => { - match pinakes_core::integrity::detect_orphans(&storage).await { - Ok(report) => { - JobQueue::complete( - &jobs, - job_id, - serde_json::to_value(&report).unwrap_or_default(), - ) - .await; - } - Err(e) => JobQueue::fail(&jobs, job_id, e.to_string()).await, - } - } - JobKind::CleanupThumbnails => { - let thumb_dir = pinakes_core::thumbnail::default_thumbnail_dir(); - match pinakes_core::integrity::cleanup_orphaned_thumbnails( - &storage, &thumb_dir, - ) - .await - { - Ok(removed) => { - JobQueue::complete( - &jobs, - job_id, - serde_json::json!({ "removed": removed }), - ) - .await; - } - Err(e) => JobQueue::fail(&jobs, job_id, e.to_string()).await, - } - } - JobKind::Export { - format, - destination, - } => { - match pinakes_core::export::export_library(&storage, &format, &destination) - .await - { - Ok(result) => { - JobQueue::complete( - &jobs, - job_id, - serde_json::to_value(&result).unwrap_or_default(), - ) - .await; - } - Err(e) => JobQueue::fail(&jobs, job_id, e.to_string()).await, - } - } - JobKind::Transcode { media_id, profile } => { - if let Some(ref svc) = transcode_svc { - match storage.get_media(media_id).await { - Ok(item) => { - match svc - .start_transcode( - media_id, - &item.path, - &profile, - item.duration_secs, - &storage, - ) - .await - { - Ok(session_id) => { - JobQueue::complete( + }, + JobKind::GenerateThumbnails { media_ids } => { + let thumb_dir = pinakes_core::thumbnail::default_thumbnail_dir(); + let thumb_config = config.thumbnails.clone(); + let total = media_ids.len(); + let mut generated = 0usize; + let mut errors = Vec::new(); + for (i, mid) in media_ids.iter().enumerate() { + if cancel.is_cancelled() { + break; + } + JobQueue::update_progress( + &jobs, + job_id, + i as f32 / total as f32, + format!("{}/{}", i, total), + ) + .await; + match storage.get_media(*mid).await { + Ok(item) => { + let source = item.path.clone(); + let mt = item.media_type.clone(); + let id = item.id; + let td = thumb_dir.clone(); + let tc = thumb_config.clone(); + let res = tokio::task::spawn_blocking(move || { + pinakes_core::thumbnail::generate_thumbnail_with_config( + id, &source, mt, &td, &tc, + ) + }) + .await; + match res { + Ok(Ok(Some(path))) => { + let mut updated = item; + updated.thumbnail_path = Some(path); + let _ = storage.update_media(&updated).await; + generated += 1; + }, + Ok(Ok(None)) => {}, + Ok(Err(e)) => errors.push(format!("{}: {}", mid, e)), + Err(e) => errors.push(format!("{}: {}", mid, e)), + } + }, + Err(e) => errors.push(format!("{}: {}", mid, e)), + } + } + JobQueue::complete( + &jobs, + job_id, + serde_json::json!({ + "generated": generated, "errors": errors + }), + ) + .await; + }, + JobKind::VerifyIntegrity { media_ids } => { + let ids = if media_ids.is_empty() { + None + } else { + Some(media_ids.as_slice()) + }; + match pinakes_core::integrity::verify_integrity(&storage, ids).await + { + Ok(report) => { + JobQueue::complete( + &jobs, + job_id, + serde_json::to_value(&report).unwrap_or_default(), + ) + .await; + }, + Err(e) => JobQueue::fail(&jobs, job_id, e.to_string()).await, + } + }, + JobKind::OrphanDetection => { + match pinakes_core::integrity::detect_orphans(&storage).await { + Ok(report) => { + JobQueue::complete( + &jobs, + job_id, + serde_json::to_value(&report).unwrap_or_default(), + ) + .await; + }, + Err(e) => JobQueue::fail(&jobs, job_id, e.to_string()).await, + } + }, + JobKind::CleanupThumbnails => { + let thumb_dir = pinakes_core::thumbnail::default_thumbnail_dir(); + match pinakes_core::integrity::cleanup_orphaned_thumbnails( + &storage, &thumb_dir, + ) + .await + { + Ok(removed) => { + JobQueue::complete( + &jobs, + job_id, + serde_json::json!({ "removed": removed }), + ) + .await; + }, + Err(e) => JobQueue::fail(&jobs, job_id, e.to_string()).await, + } + }, + JobKind::Export { + format, + destination, + } => { + match pinakes_core::export::export_library( + &storage, + &format, + &destination, + ) + .await + { + Ok(result) => { + JobQueue::complete( + &jobs, + job_id, + serde_json::to_value(&result).unwrap_or_default(), + ) + .await; + }, + Err(e) => JobQueue::fail(&jobs, job_id, e.to_string()).await, + } + }, + JobKind::Transcode { media_id, profile } => { + if let Some(ref svc) = transcode_svc { + match storage.get_media(media_id).await { + Ok(item) => { + match svc + .start_transcode( + media_id, + &item.path, + &profile, + item.duration_secs, + &storage, + ) + .await + { + Ok(session_id) => { + JobQueue::complete( &jobs, job_id, serde_json::json!({"session_id": session_id.to_string()}), ) .await; - } - Err(e) => { - JobQueue::fail(&jobs, job_id, e.to_string()).await - } - } - } - Err(e) => JobQueue::fail(&jobs, job_id, e.to_string()).await, - } - } else { - JobQueue::fail(&jobs, job_id, "transcoding is not enabled".to_string()) - .await; - } - } - JobKind::Enrich { media_ids } => { - // Enrichment job placeholder - JobQueue::complete( + }, + Err(e) => { + JobQueue::fail(&jobs, job_id, e.to_string()).await + }, + } + }, + Err(e) => JobQueue::fail(&jobs, job_id, e.to_string()).await, + } + } else { + JobQueue::fail( + &jobs, + job_id, + "transcoding is not enabled".to_string(), + ) + .await; + } + }, + JobKind::Enrich { media_ids } => { + // Enrichment job placeholder + JobQueue::complete( &jobs, job_id, serde_json::json!({"media_ids": media_ids.len(), "status": "not_implemented"}), ) .await; - } - JobKind::CleanupAnalytics => { - let before = chrono::Utc::now() - chrono::Duration::days(90); - match storage.cleanup_old_events(before).await { - Ok(count) => { - JobQueue::complete( - &jobs, - job_id, - serde_json::json!({"cleaned_up": count}), - ) - .await; - } - Err(e) => JobQueue::fail(&jobs, job_id, e.to_string()).await, - } - } - }; - drop(cancel); - }) - }, - ); - - // Initialize cache layer - let cache = std::sync::Arc::new(pinakes_core::cache::CacheLayer::new( - config.jobs.cache_ttl_secs, - )); - - // Initialize plugin manager if plugins are enabled (before moving config into Arc) - let plugin_manager = if config.plugins.enabled { - match pinakes_core::plugin::PluginManager::new( - config.plugins.data_dir.clone(), - config.plugins.cache_dir.clone(), - config.plugins.clone().into(), - ) { - Ok(pm) => { - tracing::info!("Plugin manager initialized"); - Some(Arc::new(pm)) + }, + JobKind::CleanupAnalytics => { + let before = chrono::Utc::now() - chrono::Duration::days(90); + match storage.cleanup_old_events(before).await { + Ok(count) => { + JobQueue::complete( + &jobs, + job_id, + serde_json::json!({"cleaned_up": count}), + ) + .await; + }, + Err(e) => JobQueue::fail(&jobs, job_id, e.to_string()).await, } - Err(e) => { - tracing::warn!("Failed to initialize plugin manager: {}", e); - None - } - } - } else { - tracing::info!("Plugins disabled in configuration"); + }, + }; + drop(cancel); + }) + }, + ); + + // Initialize cache layer + let cache = std::sync::Arc::new(pinakes_core::cache::CacheLayer::new( + config.jobs.cache_ttl_secs, + )); + + // Initialize plugin manager if plugins are enabled (before moving config into + // Arc) + let plugin_manager = if config.plugins.enabled { + match pinakes_core::plugin::PluginManager::new( + config.plugins.data_dir.clone(), + config.plugins.cache_dir.clone(), + config.plugins.clone().into(), + ) { + Ok(pm) => { + tracing::info!("Plugin manager initialized"); + Some(Arc::new(pm)) + }, + Err(e) => { + tracing::warn!("Failed to initialize plugin manager: {}", e); None - }; - - // Initialize scheduler with cancellation support - let shutdown_token = tokio_util::sync::CancellationToken::new(); - let config_arc = Arc::new(RwLock::new(config)); - let scheduler = pinakes_core::scheduler::TaskScheduler::new( - job_queue.clone(), - shutdown_token.clone(), - config_arc.clone(), - Some(config_path.clone()), - ); - let scheduler = Arc::new(scheduler); - - // Restore saved scheduler state from config - scheduler.restore_state().await; - - // Spawn scheduler background loop - { - let scheduler = scheduler.clone(); - tokio::spawn(async move { - scheduler.run().await; - }); + }, } + } else { + tracing::info!("Plugins disabled in configuration"); + None + }; - // Initialize managed storage service if enabled - let managed_storage = { - let config_read = config_arc.read().await; - if config_read.managed_storage.enabled { - let service = pinakes_core::managed_storage::ManagedStorageService::new( - config_read.managed_storage.storage_dir.clone(), - config_read.managed_storage.max_upload_size, - config_read.managed_storage.verify_on_read, - ); - match service.init().await { - Ok(()) => { - info!( - path = %config_read.managed_storage.storage_dir.display(), - "managed storage initialized" - ); - Some(Arc::new(service)) - } - Err(e) => { - tracing::error!(error = %e, "failed to initialize managed storage"); - None - } - } - } else { - tracing::info!("managed storage disabled in configuration"); - None - } - }; + // Initialize scheduler with cancellation support + let shutdown_token = tokio_util::sync::CancellationToken::new(); + let config_arc = Arc::new(RwLock::new(config)); + let scheduler = pinakes_core::scheduler::TaskScheduler::new( + job_queue.clone(), + shutdown_token.clone(), + config_arc.clone(), + Some(config_path.clone()), + ); + let scheduler = Arc::new(scheduler); - // Initialize chunked upload manager if sync is enabled - let chunked_upload_manager = { - let config_read = config_arc.read().await; - if config_read.sync.enabled { - let manager = pinakes_core::sync::ChunkedUploadManager::new( - config_read.sync.temp_upload_dir.clone(), - ); - match manager.init().await { - Ok(()) => { - info!( - path = %config_read.sync.temp_upload_dir.display(), - "chunked upload manager initialized" - ); - Some(Arc::new(manager)) - } - Err(e) => { - tracing::error!(error = %e, "failed to initialize chunked upload manager"); - None - } - } - } else { - tracing::info!("sync disabled, chunked upload manager not initialized"); - None - } - }; + // Restore saved scheduler state from config + scheduler.restore_state().await; - let state = AppState { - storage: storage.clone(), - config: config_arc.clone(), - config_path: Some(config_path), - scan_progress: pinakes_core::scan::ScanProgress::new(), - job_queue, - cache, - scheduler, - plugin_manager, - transcode_service, - managed_storage, - chunked_upload_manager, - }; - - // Periodic session cleanup (every 15 minutes) - { - let storage_clone = storage.clone(); - let cancel = shutdown_token.clone(); - tokio::spawn(async move { - let mut interval = tokio::time::interval(std::time::Duration::from_secs(15 * 60)); - loop { - tokio::select! { - _ = interval.tick() => { - match storage_clone.delete_expired_sessions().await { - Ok(count) if count > 0 => { - tracing::info!(count = count, "cleaned up expired sessions"); - } - Err(e) => { - tracing::error!(error = %e, "failed to cleanup expired sessions"); - } - _ => {} - } - } - _ = cancel.cancelled() => { - break; - } - } - } - }); - } - - // Periodic chunked upload cleanup (every hour) - if let Some(ref manager) = state.chunked_upload_manager { - let manager_clone = manager.clone(); - let cancel = shutdown_token.clone(); - tokio::spawn(async move { - let mut interval = tokio::time::interval(std::time::Duration::from_secs(60 * 60)); - loop { - tokio::select! { - _ = interval.tick() => { - match manager_clone.cleanup_expired(48).await { - Ok(count) if count > 0 => { - tracing::info!(count = count, "cleaned up expired upload temp files"); - } - Err(e) => { - tracing::error!(error = %e, "failed to cleanup expired upload temp files"); - } - _ => {} - } - } - _ = cancel.cancelled() => { - break; - } - } - } - }); - } + // Spawn scheduler background loop + { + let scheduler = scheduler.clone(); + tokio::spawn(async move { + scheduler.run().await; + }); + } + // Initialize managed storage service if enabled + let managed_storage = { let config_read = config_arc.read().await; - let tls_config = config_read.server.tls.clone(); - drop(config_read); - - // Create router with TLS config for HSTS headers - let router = if tls_config.enabled { - app::create_router_with_tls(state, Some(&tls_config)) + if config_read.managed_storage.enabled { + let service = pinakes_core::managed_storage::ManagedStorageService::new( + config_read.managed_storage.storage_dir.clone(), + config_read.managed_storage.max_upload_size, + config_read.managed_storage.verify_on_read, + ); + match service.init().await { + Ok(()) => { + info!( + path = %config_read.managed_storage.storage_dir.display(), + "managed storage initialized" + ); + Some(Arc::new(service)) + }, + Err(e) => { + tracing::error!(error = %e, "failed to initialize managed storage"); + None + }, + } } else { - app::create_router(state) - }; + tracing::info!("managed storage disabled in configuration"); + None + } + }; - if tls_config.enabled { - // TLS/HTTPS mode - let cert_path = tls_config - .cert_path - .as_ref() - .ok_or_else(|| anyhow::anyhow!("TLS enabled but cert_path not specified"))?; - let key_path = tls_config - .key_path - .as_ref() - .ok_or_else(|| anyhow::anyhow!("TLS enabled but key_path not specified"))?; + // Initialize chunked upload manager if sync is enabled + let chunked_upload_manager = { + let config_read = config_arc.read().await; + if config_read.sync.enabled { + let manager = pinakes_core::sync::ChunkedUploadManager::new( + config_read.sync.temp_upload_dir.clone(), + ); + match manager.init().await { + Ok(()) => { + info!( + path = %config_read.sync.temp_upload_dir.display(), + "chunked upload manager initialized" + ); + Some(Arc::new(manager)) + }, + Err(e) => { + tracing::error!(error = %e, "failed to initialize chunked upload manager"); + None + }, + } + } else { + tracing::info!("sync disabled, chunked upload manager not initialized"); + None + } + }; - info!(addr = %addr, cert = %cert_path.display(), "server listening with TLS"); + let state = AppState { + storage: storage.clone(), + config: config_arc.clone(), + config_path: Some(config_path), + scan_progress: pinakes_core::scan::ScanProgress::new(), + job_queue, + cache, + scheduler, + plugin_manager, + transcode_service, + managed_storage, + chunked_upload_manager, + }; - // Configure TLS - let tls_config_builder = - axum_server::tls_rustls::RustlsConfig::from_pem_file(cert_path, key_path).await?; - - // Start HTTP redirect server if configured - if tls_config.redirect_http { - let http_addr = format!( - "{}:{}", - config_arc.read().await.server.host, - tls_config.http_port - ); - let https_port = config_arc.read().await.server.port; - let https_host = config_arc.read().await.server.host.clone(); - - let redirect_router = create_https_redirect_router(https_host, https_port); - let shutdown = shutdown_token.clone(); - - tokio::spawn(async move { - let listener = match tokio::net::TcpListener::bind(&http_addr).await { - Ok(l) => l, + // Periodic session cleanup (every 15 minutes) + { + let storage_clone = storage.clone(); + let cancel = shutdown_token.clone(); + tokio::spawn(async move { + let mut interval = + tokio::time::interval(std::time::Duration::from_secs(15 * 60)); + loop { + tokio::select! { + _ = interval.tick() => { + match storage_clone.delete_expired_sessions().await { + Ok(count) if count > 0 => { + tracing::info!(count = count, "cleaned up expired sessions"); + } Err(e) => { - tracing::warn!(error = %e, addr = %http_addr, "failed to bind HTTP redirect listener"); - return; - } - }; - info!(addr = %http_addr, "HTTP redirect server listening"); - let server = axum::serve( - listener, - redirect_router.into_make_service_with_connect_info::(), - ); - tokio::select! { - result = server => { - if let Err(e) = result { - tracing::warn!(error = %e, "HTTP redirect server error"); - } - } - _ = shutdown.cancelled() => { - info!("HTTP redirect server shutting down"); + tracing::error!(error = %e, "failed to cleanup expired sessions"); } + _ => {} } - }); + } + _ = cancel.cancelled() => { + break; + } } + } + }); + } - // Start HTTPS server with graceful shutdown via Handle - let addr_parsed: std::net::SocketAddr = addr.parse()?; - let handle = axum_server::Handle::new(); - let shutdown_handle = handle.clone(); + // Periodic chunked upload cleanup (every hour) + if let Some(ref manager) = state.chunked_upload_manager { + let manager_clone = manager.clone(); + let cancel = shutdown_token.clone(); + tokio::spawn(async move { + let mut interval = + tokio::time::interval(std::time::Duration::from_secs(60 * 60)); + loop { + tokio::select! { + _ = interval.tick() => { + match manager_clone.cleanup_expired(48).await { + Ok(count) if count > 0 => { + tracing::info!(count = count, "cleaned up expired upload temp files"); + } + Err(e) => { + tracing::error!(error = %e, "failed to cleanup expired upload temp files"); + } + _ => {} + } + } + _ = cancel.cancelled() => { + break; + } + } + } + }); + } - // Spawn a task to trigger graceful shutdown - tokio::spawn(async move { - shutdown_signal().await; - shutdown_handle.graceful_shutdown(Some(std::time::Duration::from_secs(30))); - }); + let config_read = config_arc.read().await; + let tls_config = config_read.server.tls.clone(); + drop(config_read); - axum_server::bind_rustls(addr_parsed, tls_config_builder) - .handle(handle) - .serve(router.into_make_service_with_connect_info::()) - .await?; - } else { - // Plain HTTP mode - info!(addr = %addr, "server listening"); - let listener = tokio::net::TcpListener::bind(&addr).await?; + // Create router with TLS config for HSTS headers + let router = if tls_config.enabled { + app::create_router_with_tls(state, Some(&tls_config)) + } else { + app::create_router(state) + }; - axum::serve( - listener, - router.into_make_service_with_connect_info::(), - ) - .with_graceful_shutdown(shutdown_signal()) + if tls_config.enabled { + // TLS/HTTPS mode + let cert_path = tls_config.cert_path.as_ref().ok_or_else(|| { + anyhow::anyhow!("TLS enabled but cert_path not specified") + })?; + let key_path = tls_config.key_path.as_ref().ok_or_else(|| { + anyhow::anyhow!("TLS enabled but key_path not specified") + })?; + + info!(addr = %addr, cert = %cert_path.display(), "server listening with TLS"); + + // Configure TLS + let tls_config_builder = + axum_server::tls_rustls::RustlsConfig::from_pem_file(cert_path, key_path) .await?; + + // Start HTTP redirect server if configured + if tls_config.redirect_http { + let http_addr = format!( + "{}:{}", + config_arc.read().await.server.host, + tls_config.http_port + ); + let https_port = config_arc.read().await.server.port; + let https_host = config_arc.read().await.server.host.clone(); + + let redirect_router = + create_https_redirect_router(https_host, https_port); + let shutdown = shutdown_token.clone(); + + tokio::spawn(async move { + let listener = match tokio::net::TcpListener::bind(&http_addr).await { + Ok(l) => l, + Err(e) => { + tracing::warn!(error = %e, addr = %http_addr, "failed to bind HTTP redirect listener"); + return; + }, + }; + info!(addr = %http_addr, "HTTP redirect server listening"); + let server = axum::serve( + listener, + redirect_router + .into_make_service_with_connect_info::(), + ); + tokio::select! { + result = server => { + if let Err(e) = result { + tracing::warn!(error = %e, "HTTP redirect server error"); + } + } + _ = shutdown.cancelled() => { + info!("HTTP redirect server shutting down"); + } + } + }); } - shutdown_token.cancel(); - info!("server shut down"); - Ok(()) + // Start HTTPS server with graceful shutdown via Handle + let addr_parsed: std::net::SocketAddr = addr.parse()?; + let handle = axum_server::Handle::new(); + let shutdown_handle = handle.clone(); + + // Spawn a task to trigger graceful shutdown + tokio::spawn(async move { + shutdown_signal().await; + shutdown_handle + .graceful_shutdown(Some(std::time::Duration::from_secs(30))); + }); + + axum_server::bind_rustls(addr_parsed, tls_config_builder) + .handle(handle) + .serve( + router.into_make_service_with_connect_info::(), + ) + .await?; + } else { + // Plain HTTP mode + info!(addr = %addr, "server listening"); + let listener = tokio::net::TcpListener::bind(&addr).await?; + + axum::serve( + listener, + router.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(shutdown_signal()) + .await?; + } + + shutdown_token.cancel(); + info!("server shut down"); + Ok(()) } /// Create a router that redirects all HTTP requests to HTTPS fn create_https_redirect_router(https_host: String, https_port: u16) -> Router { - Router::new().fallback(any(move |uri: axum::http::Uri| { - let https_host = https_host.clone(); - async move { - let path_and_query = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or("/"); + Router::new().fallback(any(move |uri: axum::http::Uri| { + let https_host = https_host.clone(); + async move { + let path_and_query = + uri.path_and_query().map(|pq| pq.as_str()).unwrap_or("/"); - let https_url = if https_port == 443 { - format!("https://{}{}", https_host, path_and_query) - } else { - format!("https://{}:{}{}", https_host, https_port, path_and_query) - }; + let https_url = if https_port == 443 { + format!("https://{}{}", https_host, path_and_query) + } else { + format!("https://{}:{}{}", https_host, https_port, path_and_query) + }; - Redirect::permanent(&https_url) - } - })) + Redirect::permanent(&https_url) + } + })) } async fn shutdown_signal() { - let ctrl_c = async { - match tokio::signal::ctrl_c().await { - Ok(()) => {} - Err(e) => { - tracing::warn!(error = %e, "failed to install Ctrl+C handler"); - std::future::pending::<()>().await; - } - } - }; - - #[cfg(unix)] - let terminate = async { - match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { - Ok(mut signal) => { - signal.recv().await; - } - Err(e) => { - tracing::warn!(error = %e, "failed to install SIGTERM handler"); - std::future::pending::<()>().await; - } - } - }; - - #[cfg(not(unix))] - let terminate = std::future::pending::<()>(); - - tokio::select! { - _ = ctrl_c => info!("received Ctrl+C, shutting down"), - _ = terminate => info!("received SIGTERM, shutting down"), + let ctrl_c = async { + match tokio::signal::ctrl_c().await { + Ok(()) => {}, + Err(e) => { + tracing::warn!(error = %e, "failed to install Ctrl+C handler"); + std::future::pending::<()>().await; + }, } + }; + + #[cfg(unix)] + let terminate = async { + match tokio::signal::unix::signal( + tokio::signal::unix::SignalKind::terminate(), + ) { + Ok(mut signal) => { + signal.recv().await; + }, + Err(e) => { + tracing::warn!(error = %e, "failed to install SIGTERM handler"); + std::future::pending::<()>().await; + }, + } + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => info!("received Ctrl+C, shutting down"), + _ = terminate => info!("received SIGTERM, shutting down"), + } } diff --git a/crates/pinakes-server/src/routes/analytics.rs b/crates/pinakes-server/src/routes/analytics.rs index a9da3f3..360fef5 100644 --- a/crates/pinakes-server/src/routes/analytics.rs +++ b/crates/pinakes-server/src/routes/analytics.rs @@ -1,94 +1,96 @@ -use axum::Json; -use axum::extract::{Extension, Path, Query, State}; +use axum::{ + Json, + extract::{Extension, Path, Query, State}, +}; +use pinakes_core::{ + analytics::{UsageEvent, UsageEventType}, + model::MediaId, +}; use uuid::Uuid; -use crate::auth::resolve_user_id; -use crate::dto::*; -use crate::error::ApiError; -use crate::state::AppState; - -use pinakes_core::analytics::{UsageEvent, UsageEventType}; -use pinakes_core::model::MediaId; +use crate::{auth::resolve_user_id, dto::*, error::ApiError, state::AppState}; const MAX_LIMIT: u64 = 100; pub async fn get_most_viewed( - State(state): State, - Query(params): Query, + State(state): State, + Query(params): Query, ) -> Result>, ApiError> { - let limit = params.limit.unwrap_or(20).min(MAX_LIMIT); - let results = state.storage.get_most_viewed(limit).await?; - Ok(Json( - results - .into_iter() - .map(|(item, count)| MostViewedResponse { - media: MediaResponse::from(item), - view_count: count, - }) - .collect(), - )) + let limit = params.limit.unwrap_or(20).min(MAX_LIMIT); + let results = state.storage.get_most_viewed(limit).await?; + Ok(Json( + results + .into_iter() + .map(|(item, count)| { + MostViewedResponse { + media: MediaResponse::from(item), + view_count: count, + } + }) + .collect(), + )) } pub async fn get_recently_viewed( - State(state): State, - Extension(username): Extension, - Query(params): Query, + State(state): State, + Extension(username): Extension, + Query(params): Query, ) -> Result>, ApiError> { - let user_id = resolve_user_id(&state.storage, &username).await?; - let limit = params.limit.unwrap_or(20).min(MAX_LIMIT); - let items = state.storage.get_recently_viewed(user_id, limit).await?; - Ok(Json(items.into_iter().map(MediaResponse::from).collect())) + let user_id = resolve_user_id(&state.storage, &username).await?; + let limit = params.limit.unwrap_or(20).min(MAX_LIMIT); + let items = state.storage.get_recently_viewed(user_id, limit).await?; + Ok(Json(items.into_iter().map(MediaResponse::from).collect())) } pub async fn record_event( - State(state): State, - Extension(username): Extension, - Json(req): Json, + State(state): State, + Extension(username): Extension, + Json(req): Json, ) -> Result, ApiError> { - let event_type: UsageEventType = req - .event_type - .parse() - .map_err(|e: String| ApiError(pinakes_core::error::PinakesError::InvalidOperation(e)))?; - let user_id = resolve_user_id(&state.storage, &username).await?; - let event = UsageEvent { - id: Uuid::now_v7(), - media_id: req.media_id.map(MediaId), - user_id: Some(user_id), - event_type, - timestamp: chrono::Utc::now(), - duration_secs: req.duration_secs, - context_json: req.context.map(|v| v.to_string()), - }; - state.storage.record_usage_event(&event).await?; - Ok(Json(serde_json::json!({"recorded": true}))) + let event_type: UsageEventType = + req.event_type.parse().map_err(|e: String| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation(e)) + })?; + let user_id = resolve_user_id(&state.storage, &username).await?; + let event = UsageEvent { + id: Uuid::now_v7(), + media_id: req.media_id.map(MediaId), + user_id: Some(user_id), + event_type, + timestamp: chrono::Utc::now(), + duration_secs: req.duration_secs, + context_json: req.context.map(|v| v.to_string()), + }; + state.storage.record_usage_event(&event).await?; + Ok(Json(serde_json::json!({"recorded": true}))) } pub async fn get_watch_progress( - State(state): State, - Extension(username): Extension, - Path(id): Path, + State(state): State, + Extension(username): Extension, + Path(id): Path, ) -> Result, ApiError> { - let user_id = resolve_user_id(&state.storage, &username).await?; - let progress = state - .storage - .get_watch_progress(user_id, MediaId(id)) - .await? - .unwrap_or(0.0); - Ok(Json(WatchProgressResponse { - progress_secs: progress, - })) + let user_id = resolve_user_id(&state.storage, &username).await?; + let progress = state + .storage + .get_watch_progress(user_id, MediaId(id)) + .await? + .unwrap_or(0.0); + Ok(Json(WatchProgressResponse { + progress_secs: progress, + })) } pub async fn update_watch_progress( - State(state): State, - Extension(username): Extension, - Path(id): Path, - Json(req): Json, + State(state): State, + Extension(username): Extension, + Path(id): Path, + Json(req): Json, ) -> Result, ApiError> { - let user_id = resolve_user_id(&state.storage, &username).await?; - state - .storage - .update_watch_progress(user_id, MediaId(id), req.progress_secs) - .await?; - Ok(Json(serde_json::json!({"updated": true}))) + let user_id = resolve_user_id(&state.storage, &username).await?; + state + .storage + .update_watch_progress(user_id, MediaId(id), req.progress_secs) + .await?; + Ok(Json(serde_json::json!({"updated": true}))) } diff --git a/crates/pinakes-server/src/routes/audit.rs b/crates/pinakes-server/src/routes/audit.rs index 390f7bf..b39cf8f 100644 --- a/crates/pinakes-server/src/routes/audit.rs +++ b/crates/pinakes-server/src/routes/audit.rs @@ -1,23 +1,22 @@ -use axum::Json; -use axum::extract::{Query, State}; - -use crate::dto::*; -use crate::error::ApiError; -use crate::state::AppState; - +use axum::{ + Json, + extract::{Query, State}, +}; use pinakes_core::model::Pagination; +use crate::{dto::*, error::ApiError, state::AppState}; + pub async fn list_audit( - State(state): State, - Query(params): Query, + State(state): State, + Query(params): Query, ) -> Result>, ApiError> { - let pagination = Pagination::new( - params.offset.unwrap_or(0), - params.limit.unwrap_or(50).min(1000), - None, - ); - let entries = state.storage.list_audit_entries(None, &pagination).await?; - Ok(Json( - entries.into_iter().map(AuditEntryResponse::from).collect(), - )) + let pagination = Pagination::new( + params.offset.unwrap_or(0), + params.limit.unwrap_or(50).min(1000), + None, + ); + let entries = state.storage.list_audit_entries(None, &pagination).await?; + Ok(Json( + entries.into_iter().map(AuditEntryResponse::from).collect(), + )) } diff --git a/crates/pinakes-server/src/routes/auth.rs b/crates/pinakes-server/src/routes/auth.rs index 1ca67a4..7769d84 100644 --- a/crates/pinakes-server/src/routes/auth.rs +++ b/crates/pinakes-server/src/routes/auth.rs @@ -1,271 +1,286 @@ -use axum::Json; -use axum::extract::State; -use axum::http::{HeaderMap, StatusCode}; +use axum::{ + Json, + extract::State, + http::{HeaderMap, StatusCode}, +}; -use crate::dto::{LoginRequest, LoginResponse, UserInfoResponse}; -use crate::state::AppState; +use crate::{ + dto::{LoginRequest, LoginResponse, UserInfoResponse}, + state::AppState, +}; -/// Dummy password hash to use for timing-safe comparison when user doesn't exist. -/// This is a valid argon2 hash that will always fail verification but takes -/// similar time to verify as a real hash, preventing timing attacks that could -/// reveal whether a username exists. -const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$VGltaW5nU2FmZUR1bW15$c2ltdWxhdGVkX2hhc2hfZm9yX3RpbWluZ19zYWZldHk"; +/// Dummy password hash to use for timing-safe comparison when user doesn't +/// exist. This is a valid argon2 hash that will always fail verification but +/// takes similar time to verify as a real hash, preventing timing attacks that +/// could reveal whether a username exists. +const DUMMY_HASH: &str = + "$argon2id$v=19$m=19456,t=2,\ + p=1$VGltaW5nU2FmZUR1bW15$c2ltdWxhdGVkX2hhc2hfZm9yX3RpbWluZ19zYWZldHk"; pub async fn login( - State(state): State, - Json(req): Json, + State(state): State, + Json(req): Json, ) -> Result, StatusCode> { - // Limit input sizes to prevent DoS - if req.username.len() > 255 || req.password.len() > 1024 { - return Err(StatusCode::BAD_REQUEST); + // Limit input sizes to prevent DoS + if req.username.len() > 255 || req.password.len() > 1024 { + return Err(StatusCode::BAD_REQUEST); + } + + let config = state.config.read().await; + if !config.accounts.enabled { + return Err(StatusCode::NOT_FOUND); + } + + let user = config + .accounts + .users + .iter() + .find(|u| u.username == req.username); + + // Always perform password verification to prevent timing attacks. + // If the user doesn't exist, we verify against a dummy hash to ensure + // consistent response times regardless of whether the username exists. + use argon2::password_hash::PasswordVerifier; + + let (hash_to_verify, user_found) = match user { + Some(u) => (&u.password_hash as &str, true), + None => (DUMMY_HASH, false), + }; + + let parsed_hash = argon2::password_hash::PasswordHash::new(hash_to_verify) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let password_valid = argon2::Argon2::default() + .verify_password(req.password.as_bytes(), &parsed_hash) + .is_ok(); + + // Authentication fails if user wasn't found OR password was invalid + if !user_found || !password_valid { + // Log different messages for debugging but return same error + if !user_found { + tracing::warn!(username = %req.username, "login failed: unknown user"); + } else { + tracing::warn!(username = %req.username, "login failed: invalid password"); } - let config = state.config.read().await; - if !config.accounts.enabled { - return Err(StatusCode::NOT_FOUND); - } - - let user = config - .accounts - .users - .iter() - .find(|u| u.username == req.username); - - // Always perform password verification to prevent timing attacks. - // If the user doesn't exist, we verify against a dummy hash to ensure - // consistent response times regardless of whether the username exists. - use argon2::password_hash::PasswordVerifier; - - let (hash_to_verify, user_found) = match user { - Some(u) => (&u.password_hash as &str, true), - None => (DUMMY_HASH, false), - }; - - let parsed_hash = argon2::password_hash::PasswordHash::new(hash_to_verify) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let password_valid = argon2::Argon2::default() - .verify_password(req.password.as_bytes(), &parsed_hash) - .is_ok(); - - // Authentication fails if user wasn't found OR password was invalid - if !user_found || !password_valid { - // Log different messages for debugging but return same error - if !user_found { - tracing::warn!(username = %req.username, "login failed: unknown user"); - } else { - tracing::warn!(username = %req.username, "login failed: invalid password"); - } - - // Record failed login attempt in audit log - let _ = pinakes_core::audit::record_action( - &state.storage, - None, - pinakes_core::model::AuditAction::LoginFailed, - Some(format!("username: {}", req.username)), - ) - .await; - - return Err(StatusCode::UNAUTHORIZED); - } - - // At this point we know the user exists and password is valid - let user = user.expect("user should exist at this point"); - - // Generate session token - let token: String = (0..48) - .map(|_| { - const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\ - abcdefghijklmnopqrstuvwxyz\ - 0123456789"; - let idx = (rand::random::() as usize) % CHARSET.len(); - CHARSET[idx] as char - }) - .collect(); - - let role = user.role; - let username = user.username.clone(); - - // Create session in database - let now = chrono::Utc::now(); - let session_data = pinakes_core::storage::SessionData { - session_token: token.clone(), - user_id: None, // Could be set if we had user IDs - username: username.clone(), - role: role.to_string(), - created_at: now, - expires_at: now + chrono::Duration::hours(24), // 24 hour sessions - last_accessed: now, - }; - - if let Err(e) = state.storage.create_session(&session_data).await { - tracing::error!(error = %e, "failed to create session in database"); - return Err(StatusCode::INTERNAL_SERVER_ERROR); - } - - tracing::info!(username = %username, role = %role, "login successful"); - - // Record successful login in audit log + // Record failed login attempt in audit log let _ = pinakes_core::audit::record_action( - &state.storage, - None, - pinakes_core::model::AuditAction::LoginSuccess, - Some(format!("username: {}, role: {}", username, role)), + &state.storage, + None, + pinakes_core::model::AuditAction::LoginFailed, + Some(format!("username: {}", req.username)), ) .await; - Ok(Json(LoginResponse { - token, - username, - role: role.to_string(), - })) + return Err(StatusCode::UNAUTHORIZED); + } + + // At this point we know the user exists and password is valid + let user = user.expect("user should exist at this point"); + + // Generate session token + let token: String = (0..48) + .map(|_| { + const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\ + abcdefghijklmnopqrstuvwxyz\ + 0123456789"; + let idx = (rand::random::() as usize) % CHARSET.len(); + CHARSET[idx] as char + }) + .collect(); + + let role = user.role; + let username = user.username.clone(); + + // Create session in database + let now = chrono::Utc::now(); + let session_data = pinakes_core::storage::SessionData { + session_token: token.clone(), + user_id: None, // Could be set if we had user IDs + username: username.clone(), + role: role.to_string(), + created_at: now, + expires_at: now + chrono::Duration::hours(24), // 24 hour sessions + last_accessed: now, + }; + + if let Err(e) = state.storage.create_session(&session_data).await { + tracing::error!(error = %e, "failed to create session in database"); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + + tracing::info!(username = %username, role = %role, "login successful"); + + // Record successful login in audit log + let _ = pinakes_core::audit::record_action( + &state.storage, + None, + pinakes_core::model::AuditAction::LoginSuccess, + Some(format!("username: {}, role: {}", username, role)), + ) + .await; + + Ok(Json(LoginResponse { + token, + username, + role: role.to_string(), + })) } -pub async fn logout(State(state): State, headers: HeaderMap) -> StatusCode { - if let Some(token) = extract_bearer_token(&headers) { - // Get username before deleting session - let username = match state.storage.get_session(token).await { - Ok(Some(session)) => Some(session.username), - _ => None, - }; +pub async fn logout( + State(state): State, + headers: HeaderMap, +) -> StatusCode { + if let Some(token) = extract_bearer_token(&headers) { + // Get username before deleting session + let username = match state.storage.get_session(token).await { + Ok(Some(session)) => Some(session.username), + _ => None, + }; - // Delete session from database - if let Err(e) = state.storage.delete_session(token).await { - tracing::error!(error = %e, "failed to delete session from database"); - return StatusCode::INTERNAL_SERVER_ERROR; - } - - // Record logout in audit log - if let Some(user) = username { - let _ = pinakes_core::audit::record_action( - &state.storage, - None, - pinakes_core::model::AuditAction::Logout, - Some(format!("username: {}", user)), - ) - .await; - } + // Delete session from database + if let Err(e) = state.storage.delete_session(token).await { + tracing::error!(error = %e, "failed to delete session from database"); + return StatusCode::INTERNAL_SERVER_ERROR; } - StatusCode::OK + + // Record logout in audit log + if let Some(user) = username { + let _ = pinakes_core::audit::record_action( + &state.storage, + None, + pinakes_core::model::AuditAction::Logout, + Some(format!("username: {}", user)), + ) + .await; + } + } + StatusCode::OK } pub async fn me( - State(state): State, - headers: HeaderMap, + State(state): State, + headers: HeaderMap, ) -> Result, StatusCode> { - let config = state.config.read().await; - if !config.accounts.enabled { - // When accounts are not enabled, return a default admin user - return Ok(Json(UserInfoResponse { - username: "admin".to_string(), - role: "admin".to_string(), - })); - } - drop(config); + let config = state.config.read().await; + if !config.accounts.enabled { + // When accounts are not enabled, return a default admin user + return Ok(Json(UserInfoResponse { + username: "admin".to_string(), + role: "admin".to_string(), + })); + } + drop(config); - let token = extract_bearer_token(&headers).ok_or(StatusCode::UNAUTHORIZED)?; - let session = state - .storage - .get_session(token) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .ok_or(StatusCode::UNAUTHORIZED)?; + let token = extract_bearer_token(&headers).ok_or(StatusCode::UNAUTHORIZED)?; + let session = state + .storage + .get_session(token) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::UNAUTHORIZED)?; - Ok(Json(UserInfoResponse { - username: session.username.clone(), - role: session.role.clone(), - })) + Ok(Json(UserInfoResponse { + username: session.username.clone(), + role: session.role.clone(), + })) } fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> { - headers - .get("authorization") - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.strip_prefix("Bearer ")) + headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.strip_prefix("Bearer ")) } /// Revoke all sessions for the current user -pub async fn revoke_all_sessions(State(state): State, headers: HeaderMap) -> StatusCode { - let token = match extract_bearer_token(&headers) { - Some(t) => t, - None => return StatusCode::UNAUTHORIZED, - }; +pub async fn revoke_all_sessions( + State(state): State, + headers: HeaderMap, +) -> StatusCode { + let token = match extract_bearer_token(&headers) { + Some(t) => t, + None => return StatusCode::UNAUTHORIZED, + }; - // Get current session to find username - let session = match state.storage.get_session(token).await { - Ok(Some(s)) => s, - Ok(None) => return StatusCode::UNAUTHORIZED, - Err(e) => { - tracing::error!(error = %e, "failed to get session"); - return StatusCode::INTERNAL_SERVER_ERROR; - } - }; + // Get current session to find username + let session = match state.storage.get_session(token).await { + Ok(Some(s)) => s, + Ok(None) => return StatusCode::UNAUTHORIZED, + Err(e) => { + tracing::error!(error = %e, "failed to get session"); + return StatusCode::INTERNAL_SERVER_ERROR; + }, + }; - let username = session.username.clone(); + let username = session.username.clone(); - // Delete all sessions for this user - match state.storage.delete_user_sessions(&username).await { - Ok(count) => { - tracing::info!(username = %username, count = count, "revoked all user sessions"); + // Delete all sessions for this user + match state.storage.delete_user_sessions(&username).await { + Ok(count) => { + tracing::info!(username = %username, count = count, "revoked all user sessions"); - // Record in audit log - let _ = pinakes_core::audit::record_action( - &state.storage, - None, - pinakes_core::model::AuditAction::Logout, - Some(format!("revoked all sessions for username: {}", username)), - ) - .await; + // Record in audit log + let _ = pinakes_core::audit::record_action( + &state.storage, + None, + pinakes_core::model::AuditAction::Logout, + Some(format!("revoked all sessions for username: {}", username)), + ) + .await; - StatusCode::OK - } - Err(e) => { - tracing::error!(error = %e, "failed to revoke sessions"); - StatusCode::INTERNAL_SERVER_ERROR - } - } + StatusCode::OK + }, + Err(e) => { + tracing::error!(error = %e, "failed to revoke sessions"); + StatusCode::INTERNAL_SERVER_ERROR + }, + } } /// List all active sessions (admin only) #[derive(serde::Serialize)] pub struct SessionListResponse { - pub sessions: Vec, + pub sessions: Vec, } #[derive(serde::Serialize)] pub struct SessionInfo { - pub username: String, - pub role: String, - pub created_at: String, - pub last_accessed: String, - pub expires_at: String, + pub username: String, + pub role: String, + pub created_at: String, + pub last_accessed: String, + pub expires_at: String, } pub async fn list_active_sessions( - State(state): State, + State(state): State, ) -> Result, StatusCode> { - // Get all active sessions - let sessions = state - .storage - .list_active_sessions(None) - .await - .map_err(|e| { - tracing::error!(error = %e, "failed to list active sessions"); - StatusCode::INTERNAL_SERVER_ERROR - })?; + // Get all active sessions + let sessions = + state + .storage + .list_active_sessions(None) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to list active sessions"); + StatusCode::INTERNAL_SERVER_ERROR + })?; - let session_infos = sessions - .into_iter() - .map(|s| SessionInfo { - username: s.username, - role: s.role, - created_at: s.created_at.to_rfc3339(), - last_accessed: s.last_accessed.to_rfc3339(), - expires_at: s.expires_at.to_rfc3339(), - }) - .collect(); + let session_infos = sessions + .into_iter() + .map(|s| { + SessionInfo { + username: s.username, + role: s.role, + created_at: s.created_at.to_rfc3339(), + last_accessed: s.last_accessed.to_rfc3339(), + expires_at: s.expires_at.to_rfc3339(), + } + }) + .collect(); - Ok(Json(SessionListResponse { - sessions: session_infos, - })) + Ok(Json(SessionListResponse { + sessions: session_infos, + })) } diff --git a/crates/pinakes-server/src/routes/books.rs b/crates/pinakes-server/src/routes/books.rs index 8337d6c..d716326 100644 --- a/crates/pinakes-server/src/routes/books.rs +++ b/crates/pinakes-server/src/routes/books.rs @@ -1,305 +1,332 @@ use axum::{ - Json, Router, - extract::{Extension, Path, Query, State}, - http::StatusCode, - response::IntoResponse, - routing::{get, put}, + Json, + Router, + extract::{Extension, Path, Query, State}, + http::StatusCode, + response::IntoResponse, + routing::{get, put}, +}; +use pinakes_core::{ + error::PinakesError, + model::{ + AuthorInfo, + BookMetadata, + MediaId, + Pagination, + ReadingProgress, + ReadingStatus, + }, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use pinakes_core::{ - error::PinakesError, - model::{AuthorInfo, BookMetadata, MediaId, Pagination, ReadingProgress, ReadingStatus}, +use crate::{ + auth::resolve_user_id, + dto::MediaResponse, + error::ApiError, + state::AppState, }; -use crate::{auth::resolve_user_id, dto::MediaResponse, error::ApiError, state::AppState}; - /// Book metadata response DTO #[derive(Debug, Serialize, Deserialize)] pub struct BookMetadataResponse { - pub media_id: Uuid, - pub isbn: Option, - pub isbn13: Option, - pub publisher: Option, - pub language: Option, - pub page_count: Option, - pub publication_date: Option, - pub series_name: Option, - pub series_index: Option, - pub format: Option, - pub authors: Vec, - pub identifiers: std::collections::HashMap>, + pub media_id: Uuid, + pub isbn: Option, + pub isbn13: Option, + pub publisher: Option, + pub language: Option, + pub page_count: Option, + pub publication_date: Option, + pub series_name: Option, + pub series_index: Option, + pub format: Option, + pub authors: Vec, + pub identifiers: std::collections::HashMap>, } impl From for BookMetadataResponse { - fn from(meta: BookMetadata) -> Self { - Self { - media_id: meta.media_id.0, - isbn: meta.isbn, - isbn13: meta.isbn13, - publisher: meta.publisher, - language: meta.language, - page_count: meta.page_count, - publication_date: meta.publication_date.map(|d| d.to_string()), - series_name: meta.series_name, - series_index: meta.series_index, - format: meta.format, - authors: meta.authors.into_iter().map(AuthorResponse::from).collect(), - identifiers: meta.identifiers, - } + fn from(meta: BookMetadata) -> Self { + Self { + media_id: meta.media_id.0, + isbn: meta.isbn, + isbn13: meta.isbn13, + publisher: meta.publisher, + language: meta.language, + page_count: meta.page_count, + publication_date: meta.publication_date.map(|d| d.to_string()), + series_name: meta.series_name, + series_index: meta.series_index, + format: meta.format, + authors: meta + .authors + .into_iter() + .map(AuthorResponse::from) + .collect(), + identifiers: meta.identifiers, } + } } /// Author response DTO #[derive(Debug, Serialize, Deserialize)] pub struct AuthorResponse { - pub name: String, - pub role: String, - pub file_as: Option, - pub position: i32, + pub name: String, + pub role: String, + pub file_as: Option, + pub position: i32, } impl From for AuthorResponse { - fn from(author: AuthorInfo) -> Self { - Self { - name: author.name, - role: author.role, - file_as: author.file_as, - position: author.position, - } + fn from(author: AuthorInfo) -> Self { + Self { + name: author.name, + role: author.role, + file_as: author.file_as, + position: author.position, } + } } /// Reading progress response DTO #[derive(Debug, Serialize, Deserialize)] pub struct ReadingProgressResponse { - pub media_id: Uuid, - pub user_id: Uuid, - pub current_page: i32, - pub total_pages: Option, - pub progress_percent: f64, - pub last_read_at: String, + pub media_id: Uuid, + pub user_id: Uuid, + pub current_page: i32, + pub total_pages: Option, + pub progress_percent: f64, + pub last_read_at: String, } impl From for ReadingProgressResponse { - fn from(progress: ReadingProgress) -> Self { - Self { - media_id: progress.media_id.0, - user_id: progress.user_id, - current_page: progress.current_page, - total_pages: progress.total_pages, - progress_percent: progress.progress_percent, - last_read_at: progress.last_read_at.to_rfc3339(), - } + fn from(progress: ReadingProgress) -> Self { + Self { + media_id: progress.media_id.0, + user_id: progress.user_id, + current_page: progress.current_page, + total_pages: progress.total_pages, + progress_percent: progress.progress_percent, + last_read_at: progress.last_read_at.to_rfc3339(), } + } } /// Update reading progress request #[derive(Debug, Deserialize)] pub struct UpdateProgressRequest { - pub current_page: i32, + pub current_page: i32, } /// Search books query parameters #[derive(Debug, Deserialize)] pub struct SearchBooksQuery { - pub isbn: Option, - pub author: Option, - pub series: Option, - pub publisher: Option, - pub language: Option, - #[serde(default = "default_offset")] - pub offset: u64, - #[serde(default = "default_limit")] - pub limit: u64, + pub isbn: Option, + pub author: Option, + pub series: Option, + pub publisher: Option, + pub language: Option, + #[serde(default = "default_offset")] + pub offset: u64, + #[serde(default = "default_limit")] + pub limit: u64, } fn default_offset() -> u64 { - 0 + 0 } fn default_limit() -> u64 { - 50 + 50 } /// Series summary DTO #[derive(Debug, Serialize)] pub struct SeriesSummary { - pub name: String, - pub book_count: u64, + pub name: String, + pub book_count: u64, } /// Author summary DTO #[derive(Debug, Serialize)] pub struct AuthorSummary { - pub name: String, - pub book_count: u64, + pub name: String, + pub book_count: u64, } /// Get book metadata by media ID pub async fn get_book_metadata( - State(state): State, - Path(media_id): Path, + State(state): State, + Path(media_id): Path, ) -> Result { - let media_id = MediaId(media_id); - let metadata = state - .storage - .get_book_metadata(media_id) - .await? - .ok_or(ApiError(PinakesError::NotFound( - "Book metadata not found".to_string(), - )))?; + let media_id = MediaId(media_id); + let metadata = + state + .storage + .get_book_metadata(media_id) + .await? + .ok_or(ApiError(PinakesError::NotFound( + "Book metadata not found".to_string(), + )))?; - Ok(Json(BookMetadataResponse::from(metadata))) + Ok(Json(BookMetadataResponse::from(metadata))) } /// List all books with optional search filters pub async fn list_books( - State(state): State, - Query(query): Query, + State(state): State, + Query(query): Query, ) -> Result { - let pagination = Pagination { - offset: query.offset, - limit: query.limit, - sort: None, - }; + let pagination = Pagination { + offset: query.offset, + limit: query.limit, + sort: None, + }; - let items = state - .storage - .search_books( - query.isbn.as_deref(), - query.author.as_deref(), - query.series.as_deref(), - query.publisher.as_deref(), - query.language.as_deref(), - &pagination, - ) - .await?; + let items = state + .storage + .search_books( + query.isbn.as_deref(), + query.author.as_deref(), + query.series.as_deref(), + query.publisher.as_deref(), + query.language.as_deref(), + &pagination, + ) + .await?; - let response: Vec = items.into_iter().map(MediaResponse::from).collect(); - Ok(Json(response)) + let response: Vec = + items.into_iter().map(MediaResponse::from).collect(); + Ok(Json(response)) } /// List all series with book counts -pub async fn list_series(State(state): State) -> Result { - let series = state.storage.list_series().await?; - let response: Vec = series - .into_iter() - .map(|(name, count)| SeriesSummary { - name, - book_count: count, - }) - .collect(); +pub async fn list_series( + State(state): State, +) -> Result { + let series = state.storage.list_series().await?; + let response: Vec = series + .into_iter() + .map(|(name, count)| { + SeriesSummary { + name, + book_count: count, + } + }) + .collect(); - Ok(Json(response)) + Ok(Json(response)) } /// Get books in a specific series pub async fn get_series_books( - State(state): State, - Path(series_name): Path, + State(state): State, + Path(series_name): Path, ) -> Result { - let items = state.storage.get_series_books(&series_name).await?; - let response: Vec = items.into_iter().map(MediaResponse::from).collect(); - Ok(Json(response)) + let items = state.storage.get_series_books(&series_name).await?; + let response: Vec = + items.into_iter().map(MediaResponse::from).collect(); + Ok(Json(response)) } /// List all authors with book counts pub async fn list_authors( - State(state): State, - Query(pagination): Query, + State(state): State, + Query(pagination): Query, ) -> Result { - let authors = state.storage.list_all_authors(&pagination).await?; - let response: Vec = authors - .into_iter() - .map(|(name, count)| AuthorSummary { - name, - book_count: count, - }) - .collect(); + let authors = state.storage.list_all_authors(&pagination).await?; + let response: Vec = authors + .into_iter() + .map(|(name, count)| { + AuthorSummary { + name, + book_count: count, + } + }) + .collect(); - Ok(Json(response)) + Ok(Json(response)) } /// Get books by a specific author pub async fn get_author_books( - State(state): State, - Path(author_name): Path, - Query(pagination): Query, + State(state): State, + Path(author_name): Path, + Query(pagination): Query, ) -> Result { - let items = state - .storage - .search_books(None, Some(&author_name), None, None, None, &pagination) - .await?; + let items = state + .storage + .search_books(None, Some(&author_name), None, None, None, &pagination) + .await?; - let response: Vec = items.into_iter().map(MediaResponse::from).collect(); - Ok(Json(response)) + let response: Vec = + items.into_iter().map(MediaResponse::from).collect(); + Ok(Json(response)) } /// Get reading progress for a book pub async fn get_reading_progress( - State(state): State, - Extension(username): Extension, - Path(media_id): Path, + State(state): State, + Extension(username): Extension, + Path(media_id): Path, ) -> Result { - let user_id = resolve_user_id(&state.storage, &username).await?; - let media_id = MediaId(media_id); + let user_id = resolve_user_id(&state.storage, &username).await?; + let media_id = MediaId(media_id); - let progress = state - .storage - .get_reading_progress(user_id.0, media_id) - .await? - .ok_or(ApiError(PinakesError::NotFound( - "Reading progress not found".to_string(), - )))?; + let progress = state + .storage + .get_reading_progress(user_id.0, media_id) + .await? + .ok_or(ApiError(PinakesError::NotFound( + "Reading progress not found".to_string(), + )))?; - Ok(Json(ReadingProgressResponse::from(progress))) + Ok(Json(ReadingProgressResponse::from(progress))) } /// Update reading progress for a book pub async fn update_reading_progress( - State(state): State, - Extension(username): Extension, - Path(media_id): Path, - Json(req): Json, + State(state): State, + Extension(username): Extension, + Path(media_id): Path, + Json(req): Json, ) -> Result { - let user_id = resolve_user_id(&state.storage, &username).await?; - let media_id = MediaId(media_id); + let user_id = resolve_user_id(&state.storage, &username).await?; + let media_id = MediaId(media_id); - state - .storage - .update_reading_progress(user_id.0, media_id, req.current_page) - .await?; + state + .storage + .update_reading_progress(user_id.0, media_id, req.current_page) + .await?; - Ok(StatusCode::NO_CONTENT) + Ok(StatusCode::NO_CONTENT) } /// Get user's reading list pub async fn get_reading_list( - State(state): State, - Extension(username): Extension, - Query(params): Query, + State(state): State, + Extension(username): Extension, + Query(params): Query, ) -> Result { - let user_id = resolve_user_id(&state.storage, &username).await?; + let user_id = resolve_user_id(&state.storage, &username).await?; - let items = state - .storage - .get_reading_list(user_id.0, params.status) - .await?; + let items = state + .storage + .get_reading_list(user_id.0, params.status) + .await?; - let response: Vec = items.into_iter().map(MediaResponse::from).collect(); - Ok(Json(response)) + let response: Vec = + items.into_iter().map(MediaResponse::from).collect(); + Ok(Json(response)) } #[derive(Debug, Deserialize)] pub struct ReadingListQuery { - pub status: Option, + pub status: Option, } /// Build the books router pub fn routes() -> Router { - Router::new() + Router::new() // Metadata routes .route("/{id}/metadata", get(get_book_metadata)) // Browse routes diff --git a/crates/pinakes-server/src/routes/collections.rs b/crates/pinakes-server/src/routes/collections.rs index d7113b1..723c800 100644 --- a/crates/pinakes-server/src/routes/collections.rs +++ b/crates/pinakes-server/src/routes/collections.rs @@ -1,101 +1,106 @@ -use axum::Json; -use axum::extract::{Path, State}; +use axum::{ + Json, + extract::{Path, State}, +}; +use pinakes_core::model::{CollectionKind, MediaId}; use uuid::Uuid; -use crate::dto::*; -use crate::error::ApiError; -use crate::state::AppState; - -use pinakes_core::model::{CollectionKind, MediaId}; +use crate::{dto::*, error::ApiError, state::AppState}; pub async fn create_collection( - State(state): State, - Json(req): Json, + State(state): State, + Json(req): Json, ) -> Result, ApiError> { - if req.name.is_empty() || req.name.len() > 255 { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( - "collection name must be 1-255 characters".into(), - ), - )); - } - if let Some(ref desc) = req.description - && desc.len() > 10_000 - { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( - "description exceeds 10000 characters".into(), - ), - )); - } - let kind = match req.kind.as_str() { - "virtual" => CollectionKind::Virtual, - _ => CollectionKind::Manual, - }; - let col = pinakes_core::collections::create_collection( - &state.storage, - &req.name, - kind, - req.description.as_deref(), - req.filter_query.as_deref(), - ) - .await?; - Ok(Json(CollectionResponse::from(col))) + if req.name.is_empty() || req.name.len() > 255 { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "collection name must be 1-255 characters".into(), + ), + )); + } + if let Some(ref desc) = req.description + && desc.len() > 10_000 + { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "description exceeds 10000 characters".into(), + ), + )); + } + let kind = match req.kind.as_str() { + "virtual" => CollectionKind::Virtual, + _ => CollectionKind::Manual, + }; + let col = pinakes_core::collections::create_collection( + &state.storage, + &req.name, + kind, + req.description.as_deref(), + req.filter_query.as_deref(), + ) + .await?; + Ok(Json(CollectionResponse::from(col))) } pub async fn list_collections( - State(state): State, + State(state): State, ) -> Result>, ApiError> { - let cols = state.storage.list_collections().await?; - Ok(Json( - cols.into_iter().map(CollectionResponse::from).collect(), - )) + let cols = state.storage.list_collections().await?; + Ok(Json( + cols.into_iter().map(CollectionResponse::from).collect(), + )) } pub async fn get_collection( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result, ApiError> { - let col = state.storage.get_collection(id).await?; - Ok(Json(CollectionResponse::from(col))) + let col = state.storage.get_collection(id).await?; + Ok(Json(CollectionResponse::from(col))) } pub async fn delete_collection( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result, ApiError> { - state.storage.delete_collection(id).await?; - Ok(Json(serde_json::json!({"deleted": true}))) + state.storage.delete_collection(id).await?; + Ok(Json(serde_json::json!({"deleted": true}))) } pub async fn add_member( - State(state): State, - Path(collection_id): Path, - Json(req): Json, + State(state): State, + Path(collection_id): Path, + Json(req): Json, ) -> Result, ApiError> { - pinakes_core::collections::add_member( - &state.storage, - collection_id, - MediaId(req.media_id), - req.position.unwrap_or(0), - ) - .await?; - Ok(Json(serde_json::json!({"added": true}))) + pinakes_core::collections::add_member( + &state.storage, + collection_id, + MediaId(req.media_id), + req.position.unwrap_or(0), + ) + .await?; + Ok(Json(serde_json::json!({"added": true}))) } pub async fn remove_member( - State(state): State, - Path((collection_id, media_id)): Path<(Uuid, Uuid)>, + State(state): State, + Path((collection_id, media_id)): Path<(Uuid, Uuid)>, ) -> Result, ApiError> { - pinakes_core::collections::remove_member(&state.storage, collection_id, MediaId(media_id)) - .await?; - Ok(Json(serde_json::json!({"removed": true}))) + pinakes_core::collections::remove_member( + &state.storage, + collection_id, + MediaId(media_id), + ) + .await?; + Ok(Json(serde_json::json!({"removed": true}))) } pub async fn get_members( - State(state): State, - Path(collection_id): Path, + State(state): State, + Path(collection_id): Path, ) -> Result>, ApiError> { - let items = pinakes_core::collections::get_members(&state.storage, collection_id).await?; - Ok(Json(items.into_iter().map(MediaResponse::from).collect())) + let items = + pinakes_core::collections::get_members(&state.storage, collection_id) + .await?; + Ok(Json(items.into_iter().map(MediaResponse::from).collect())) } diff --git a/crates/pinakes-server/src/routes/config.rs b/crates/pinakes-server/src/routes/config.rs index f1e1279..e8f7956 100644 --- a/crates/pinakes-server/src/routes/config.rs +++ b/crates/pinakes-server/src/routes/config.rs @@ -1,217 +1,218 @@ -use axum::Json; -use axum::extract::State; +use axum::{Json, extract::State}; -use crate::dto::*; -use crate::error::ApiError; -use crate::state::AppState; +use crate::{dto::*, error::ApiError, state::AppState}; -pub async fn get_config(State(state): State) -> Result, ApiError> { - let config = state.config.read().await; - let roots = state.storage.list_root_dirs().await?; +pub async fn get_config( + State(state): State, +) -> Result, ApiError> { + let config = state.config.read().await; + let roots = state.storage.list_root_dirs().await?; - let config_path = state - .config_path - .as_ref() - .map(|p| p.to_string_lossy().to_string()); - let config_writable = match &state.config_path { - Some(path) => { - if path.exists() { - std::fs::metadata(path) - .map(|m| !m.permissions().readonly()) - .unwrap_or(false) - } else { - path.parent() - .map(|parent| { - std::fs::metadata(parent) - .map(|m| !m.permissions().readonly()) - .unwrap_or(false) - }) - .unwrap_or(false) - } - } - None => false, - }; + let config_path = state + .config_path + .as_ref() + .map(|p| p.to_string_lossy().to_string()); + let config_writable = match &state.config_path { + Some(path) => { + if path.exists() { + std::fs::metadata(path) + .map(|m| !m.permissions().readonly()) + .unwrap_or(false) + } else { + path + .parent() + .map(|parent| { + std::fs::metadata(parent) + .map(|m| !m.permissions().readonly()) + .unwrap_or(false) + }) + .unwrap_or(false) + } + }, + None => false, + }; - Ok(Json(ConfigResponse { - backend: format!("{:?}", config.storage.backend).to_lowercase(), - database_path: config - .storage - .sqlite - .as_ref() - .map(|s| s.path.to_string_lossy().to_string()), - roots: roots - .iter() - .map(|p| p.to_string_lossy().to_string()) - .collect(), - scanning: ScanningConfigResponse { - watch: config.scanning.watch, - poll_interval_secs: config.scanning.poll_interval_secs, - ignore_patterns: config.scanning.ignore_patterns.clone(), - }, - server: ServerConfigResponse { - host: config.server.host.clone(), - port: config.server.port, - }, - ui: UiConfigResponse::from(&config.ui), - config_path, - config_writable, - })) + Ok(Json(ConfigResponse { + backend: format!("{:?}", config.storage.backend).to_lowercase(), + database_path: config + .storage + .sqlite + .as_ref() + .map(|s| s.path.to_string_lossy().to_string()), + roots: roots + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(), + scanning: ScanningConfigResponse { + watch: config.scanning.watch, + poll_interval_secs: config.scanning.poll_interval_secs, + ignore_patterns: config.scanning.ignore_patterns.clone(), + }, + server: ServerConfigResponse { + host: config.server.host.clone(), + port: config.server.port, + }, + ui: UiConfigResponse::from(&config.ui), + config_path, + config_writable, + })) } pub async fn get_ui_config( - State(state): State, + State(state): State, ) -> Result, ApiError> { - let config = state.config.read().await; - Ok(Json(UiConfigResponse::from(&config.ui))) + let config = state.config.read().await; + Ok(Json(UiConfigResponse::from(&config.ui))) } pub async fn update_ui_config( - State(state): State, - Json(req): Json, + State(state): State, + Json(req): Json, ) -> Result, ApiError> { - let mut config = state.config.write().await; - if let Some(theme) = req.theme { - config.ui.theme = theme; - } - if let Some(default_view) = req.default_view { - config.ui.default_view = default_view; - } - if let Some(default_page_size) = req.default_page_size { - config.ui.default_page_size = default_page_size; - } - if let Some(default_view_mode) = req.default_view_mode { - config.ui.default_view_mode = default_view_mode; - } - if let Some(auto_play) = req.auto_play_media { - config.ui.auto_play_media = auto_play; - } - if let Some(show_thumbs) = req.show_thumbnails { - config.ui.show_thumbnails = show_thumbs; - } - if let Some(collapsed) = req.sidebar_collapsed { - config.ui.sidebar_collapsed = collapsed; - } + let mut config = state.config.write().await; + if let Some(theme) = req.theme { + config.ui.theme = theme; + } + if let Some(default_view) = req.default_view { + config.ui.default_view = default_view; + } + if let Some(default_page_size) = req.default_page_size { + config.ui.default_page_size = default_page_size; + } + if let Some(default_view_mode) = req.default_view_mode { + config.ui.default_view_mode = default_view_mode; + } + if let Some(auto_play) = req.auto_play_media { + config.ui.auto_play_media = auto_play; + } + if let Some(show_thumbs) = req.show_thumbnails { + config.ui.show_thumbnails = show_thumbs; + } + if let Some(collapsed) = req.sidebar_collapsed { + config.ui.sidebar_collapsed = collapsed; + } - if let Some(ref path) = state.config_path { - config.save_to_file(path).map_err(ApiError)?; - } + if let Some(ref path) = state.config_path { + config.save_to_file(path).map_err(ApiError)?; + } - Ok(Json(UiConfigResponse::from(&config.ui))) + Ok(Json(UiConfigResponse::from(&config.ui))) } pub async fn update_scanning_config( - State(state): State, - Json(req): Json, + State(state): State, + Json(req): Json, ) -> Result, ApiError> { - let mut config = state.config.write().await; - if let Some(watch) = req.watch { - config.scanning.watch = watch; - } - if let Some(interval) = req.poll_interval_secs { - config.scanning.poll_interval_secs = interval; - } - if let Some(patterns) = req.ignore_patterns { - config.scanning.ignore_patterns = patterns; - } + let mut config = state.config.write().await; + if let Some(watch) = req.watch { + config.scanning.watch = watch; + } + if let Some(interval) = req.poll_interval_secs { + config.scanning.poll_interval_secs = interval; + } + if let Some(patterns) = req.ignore_patterns { + config.scanning.ignore_patterns = patterns; + } - // Persist to disk if we have a config path - if let Some(ref path) = state.config_path { - config.save_to_file(path).map_err(ApiError)?; - } + // Persist to disk if we have a config path + if let Some(ref path) = state.config_path { + config.save_to_file(path).map_err(ApiError)?; + } - let roots = state.storage.list_root_dirs().await?; + let roots = state.storage.list_root_dirs().await?; - let config_path = state - .config_path - .as_ref() - .map(|p| p.to_string_lossy().to_string()); - let config_writable = match &state.config_path { - Some(path) => { - if path.exists() { - std::fs::metadata(path) - .map(|m| !m.permissions().readonly()) - .unwrap_or(false) - } else { - path.parent() - .map(|parent| { - std::fs::metadata(parent) - .map(|m| !m.permissions().readonly()) - .unwrap_or(false) - }) - .unwrap_or(false) - } - } - None => false, - }; + let config_path = state + .config_path + .as_ref() + .map(|p| p.to_string_lossy().to_string()); + let config_writable = match &state.config_path { + Some(path) => { + if path.exists() { + std::fs::metadata(path) + .map(|m| !m.permissions().readonly()) + .unwrap_or(false) + } else { + path + .parent() + .map(|parent| { + std::fs::metadata(parent) + .map(|m| !m.permissions().readonly()) + .unwrap_or(false) + }) + .unwrap_or(false) + } + }, + None => false, + }; - Ok(Json(ConfigResponse { - backend: format!("{:?}", config.storage.backend).to_lowercase(), - database_path: config - .storage - .sqlite - .as_ref() - .map(|s| s.path.to_string_lossy().to_string()), - roots: roots - .iter() - .map(|p| p.to_string_lossy().to_string()) - .collect(), - scanning: ScanningConfigResponse { - watch: config.scanning.watch, - poll_interval_secs: config.scanning.poll_interval_secs, - ignore_patterns: config.scanning.ignore_patterns.clone(), - }, - server: ServerConfigResponse { - host: config.server.host.clone(), - port: config.server.port, - }, - ui: UiConfigResponse::from(&config.ui), - config_path, - config_writable, - })) + Ok(Json(ConfigResponse { + backend: format!("{:?}", config.storage.backend).to_lowercase(), + database_path: config + .storage + .sqlite + .as_ref() + .map(|s| s.path.to_string_lossy().to_string()), + roots: roots + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(), + scanning: ScanningConfigResponse { + watch: config.scanning.watch, + poll_interval_secs: config.scanning.poll_interval_secs, + ignore_patterns: config.scanning.ignore_patterns.clone(), + }, + server: ServerConfigResponse { + host: config.server.host.clone(), + port: config.server.port, + }, + ui: UiConfigResponse::from(&config.ui), + config_path, + config_writable, + })) } pub async fn add_root( - State(state): State, - Json(req): Json, + State(state): State, + Json(req): Json, ) -> Result, ApiError> { - let path = std::path::PathBuf::from(&req.path); + let path = std::path::PathBuf::from(&req.path); - if !path.exists() { - return Err(ApiError(pinakes_core::error::PinakesError::FileNotFound( - path, - ))); + if !path.exists() { + return Err(ApiError(pinakes_core::error::PinakesError::FileNotFound( + path, + ))); + } + + state.storage.add_root_dir(path.clone()).await?; + + { + let mut config = state.config.write().await; + if !config.directories.roots.contains(&path) { + config.directories.roots.push(path); } - - state.storage.add_root_dir(path.clone()).await?; - - { - let mut config = state.config.write().await; - if !config.directories.roots.contains(&path) { - config.directories.roots.push(path); - } - if let Some(ref config_path) = state.config_path { - config.save_to_file(config_path).map_err(ApiError)?; - } + if let Some(ref config_path) = state.config_path { + config.save_to_file(config_path).map_err(ApiError)?; } + } - get_config(State(state)).await + get_config(State(state)).await } pub async fn remove_root( - State(state): State, - Json(req): Json, + State(state): State, + Json(req): Json, ) -> Result, ApiError> { - let path = std::path::PathBuf::from(&req.path); + let path = std::path::PathBuf::from(&req.path); - state.storage.remove_root_dir(&path).await?; + state.storage.remove_root_dir(&path).await?; - { - let mut config = state.config.write().await; - config.directories.roots.retain(|r| r != &path); - if let Some(ref config_path) = state.config_path { - config.save_to_file(config_path).map_err(ApiError)?; - } + { + let mut config = state.config.write().await; + config.directories.roots.retain(|r| r != &path); + if let Some(ref config_path) = state.config_path { + config.save_to_file(config_path).map_err(ApiError)?; } + } - get_config(State(state)).await + get_config(State(state)).await } diff --git a/crates/pinakes-server/src/routes/database.rs b/crates/pinakes-server/src/routes/database.rs index ad393bc..6338382 100644 --- a/crates/pinakes-server/src/routes/database.rs +++ b/crates/pinakes-server/src/routes/database.rs @@ -1,34 +1,31 @@ -use axum::Json; -use axum::extract::State; +use axum::{Json, extract::State}; -use crate::dto::DatabaseStatsResponse; -use crate::error::ApiError; -use crate::state::AppState; +use crate::{dto::DatabaseStatsResponse, error::ApiError, state::AppState}; pub async fn database_stats( - State(state): State, + State(state): State, ) -> Result, ApiError> { - let stats = state.storage.database_stats().await?; - Ok(Json(DatabaseStatsResponse { - media_count: stats.media_count, - tag_count: stats.tag_count, - collection_count: stats.collection_count, - audit_count: stats.audit_count, - database_size_bytes: stats.database_size_bytes, - backend_name: stats.backend_name, - })) + let stats = state.storage.database_stats().await?; + Ok(Json(DatabaseStatsResponse { + media_count: stats.media_count, + tag_count: stats.tag_count, + collection_count: stats.collection_count, + audit_count: stats.audit_count, + database_size_bytes: stats.database_size_bytes, + backend_name: stats.backend_name, + })) } pub async fn vacuum_database( - State(state): State, + State(state): State, ) -> Result, ApiError> { - state.storage.vacuum().await?; - Ok(Json(serde_json::json!({"status": "ok"}))) + state.storage.vacuum().await?; + Ok(Json(serde_json::json!({"status": "ok"}))) } pub async fn clear_database( - State(state): State, + State(state): State, ) -> Result, ApiError> { - state.storage.clear_all_data().await?; - Ok(Json(serde_json::json!({"status": "ok"}))) + state.storage.clear_all_data().await?; + Ok(Json(serde_json::json!({"status": "ok"}))) } diff --git a/crates/pinakes-server/src/routes/duplicates.rs b/crates/pinakes-server/src/routes/duplicates.rs index 5a4b238..4da2ac8 100644 --- a/crates/pinakes-server/src/routes/duplicates.rs +++ b/crates/pinakes-server/src/routes/duplicates.rs @@ -1,30 +1,31 @@ -use axum::Json; -use axum::extract::State; +use axum::{Json, extract::State}; -use crate::dto::{DuplicateGroupResponse, MediaResponse}; -use crate::error::ApiError; -use crate::state::AppState; +use crate::{ + dto::{DuplicateGroupResponse, MediaResponse}, + error::ApiError, + state::AppState, +}; pub async fn list_duplicates( - State(state): State, + State(state): State, ) -> Result>, ApiError> { - let groups = state.storage.find_duplicates().await?; + let groups = state.storage.find_duplicates().await?; - let response: Vec = groups - .into_iter() - .map(|items| { - let content_hash = items - .first() - .map(|i| i.content_hash.0.clone()) - .unwrap_or_default(); - let media_items: Vec = - items.into_iter().map(MediaResponse::from).collect(); - DuplicateGroupResponse { - content_hash, - items: media_items, - } - }) - .collect(); + let response: Vec = groups + .into_iter() + .map(|items| { + let content_hash = items + .first() + .map(|i| i.content_hash.0.clone()) + .unwrap_or_default(); + let media_items: Vec = + items.into_iter().map(MediaResponse::from).collect(); + DuplicateGroupResponse { + content_hash, + items: media_items, + } + }) + .collect(); - Ok(Json(response)) + Ok(Json(response)) } diff --git a/crates/pinakes-server/src/routes/enrichment.rs b/crates/pinakes-server/src/routes/enrichment.rs index c16751e..a7f3a91 100644 --- a/crates/pinakes-server/src/routes/enrichment.rs +++ b/crates/pinakes-server/src/routes/enrichment.rs @@ -1,48 +1,48 @@ -use axum::Json; -use axum::extract::{Path, State}; +use axum::{ + Json, + extract::{Path, State}, +}; +use pinakes_core::model::MediaId; use uuid::Uuid; -use crate::dto::*; -use crate::error::ApiError; -use crate::state::AppState; - -use pinakes_core::model::MediaId; +use crate::{dto::*, error::ApiError, state::AppState}; pub async fn trigger_enrichment( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result, ApiError> { - // Submit enrichment as a background job - let job_id = state - .job_queue - .submit(pinakes_core::jobs::JobKind::Enrich { - media_ids: vec![MediaId(id)], - }) - .await; - Ok(Json(serde_json::json!({"job_id": job_id.to_string()}))) + // Submit enrichment as a background job + let job_id = state + .job_queue + .submit(pinakes_core::jobs::JobKind::Enrich { + media_ids: vec![MediaId(id)], + }) + .await; + Ok(Json(serde_json::json!({"job_id": job_id.to_string()}))) } pub async fn get_external_metadata( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result>, ApiError> { - let metadata = state.storage.get_external_metadata(MediaId(id)).await?; - Ok(Json( - metadata - .into_iter() - .map(ExternalMetadataResponse::from) - .collect(), - )) + let metadata = state.storage.get_external_metadata(MediaId(id)).await?; + Ok(Json( + metadata + .into_iter() + .map(ExternalMetadataResponse::from) + .collect(), + )) } pub async fn batch_enrich( - State(state): State, - Json(req): Json, // Reuse: has media_ids field + State(state): State, + Json(req): Json, // Reuse: has media_ids field ) -> Result, ApiError> { - let media_ids: Vec = req.media_ids.into_iter().map(MediaId).collect(); - let job_id = state - .job_queue - .submit(pinakes_core::jobs::JobKind::Enrich { media_ids }) - .await; - Ok(Json(serde_json::json!({"job_id": job_id.to_string()}))) + let media_ids: Vec = + req.media_ids.into_iter().map(MediaId).collect(); + let job_id = state + .job_queue + .submit(pinakes_core::jobs::JobKind::Enrich { media_ids }) + .await; + Ok(Json(serde_json::json!({"job_id": job_id.to_string()}))) } diff --git a/crates/pinakes-server/src/routes/export.rs b/crates/pinakes-server/src/routes/export.rs index 97f4728..7b98b04 100644 --- a/crates/pinakes-server/src/routes/export.rs +++ b/crates/pinakes-server/src/routes/export.rs @@ -1,42 +1,42 @@ -use axum::Json; -use axum::extract::State; -use serde::Deserialize; use std::path::PathBuf; -use crate::error::ApiError; -use crate::state::AppState; +use axum::{Json, extract::State}; +use serde::Deserialize; + +use crate::{error::ApiError, state::AppState}; #[derive(Debug, Deserialize)] pub struct ExportRequest { - pub format: String, - pub destination: PathBuf, + pub format: String, + pub destination: PathBuf, } pub async fn trigger_export( - State(state): State, + State(state): State, ) -> Result, ApiError> { - // Default export to JSON in data dir - let dest = pinakes_core::config::Config::default_data_dir().join("export.json"); - let kind = pinakes_core::jobs::JobKind::Export { - format: pinakes_core::jobs::ExportFormat::Json, - destination: dest, - }; - let job_id = state.job_queue.submit(kind).await; - Ok(Json(serde_json::json!({ "job_id": job_id.to_string() }))) + // Default export to JSON in data dir + let dest = + pinakes_core::config::Config::default_data_dir().join("export.json"); + let kind = pinakes_core::jobs::JobKind::Export { + format: pinakes_core::jobs::ExportFormat::Json, + destination: dest, + }; + let job_id = state.job_queue.submit(kind).await; + Ok(Json(serde_json::json!({ "job_id": job_id.to_string() }))) } pub async fn trigger_export_with_options( - State(state): State, - Json(req): Json, + State(state): State, + Json(req): Json, ) -> Result, ApiError> { - let format = match req.format.as_str() { - "csv" => pinakes_core::jobs::ExportFormat::Csv, - _ => pinakes_core::jobs::ExportFormat::Json, - }; - let kind = pinakes_core::jobs::JobKind::Export { - format, - destination: req.destination, - }; - let job_id = state.job_queue.submit(kind).await; - Ok(Json(serde_json::json!({ "job_id": job_id.to_string() }))) + let format = match req.format.as_str() { + "csv" => pinakes_core::jobs::ExportFormat::Csv, + _ => pinakes_core::jobs::ExportFormat::Json, + }; + let kind = pinakes_core::jobs::JobKind::Export { + format, + destination: req.destination, + }; + let job_id = state.job_queue.submit(kind).await; + Ok(Json(serde_json::json!({ "job_id": job_id.to_string() }))) } diff --git a/crates/pinakes-server/src/routes/health.rs b/crates/pinakes-server/src/routes/health.rs index 291bc9a..9eb0fa8 100644 --- a/crates/pinakes-server/src/routes/health.rs +++ b/crates/pinakes-server/src/routes/health.rs @@ -1,9 +1,6 @@ use std::time::Instant; -use axum::Json; -use axum::extract::State; -use axum::http::StatusCode; -use axum::response::IntoResponse; +use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; use serde::{Deserialize, Serialize}; use crate::state::AppState; @@ -11,208 +8,215 @@ use crate::state::AppState; /// Basic health check response #[derive(Debug, Serialize, Deserialize)] pub struct HealthResponse { - pub status: String, - pub version: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub database: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub filesystem: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cache: Option, + pub status: String, + pub version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub database: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub filesystem: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cache: Option, } #[derive(Debug, Serialize, Deserialize)] pub struct DatabaseHealth { - pub status: String, - pub latency_ms: u64, - #[serde(skip_serializing_if = "Option::is_none")] - pub media_count: Option, + pub status: String, + pub latency_ms: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub media_count: Option, } #[derive(Debug, Serialize, Deserialize)] pub struct FilesystemHealth { - pub status: String, - pub roots_configured: usize, - pub roots_accessible: usize, + pub status: String, + pub roots_configured: usize, + pub roots_accessible: usize, } #[derive(Debug, Serialize, Deserialize)] pub struct CacheHealth { - pub hit_rate: f64, - pub total_entries: u64, - pub responses_size: u64, - pub queries_size: u64, - pub media_size: u64, + pub hit_rate: f64, + pub total_entries: u64, + pub responses_size: u64, + pub queries_size: u64, + pub media_size: u64, } /// Comprehensive health check - includes database, filesystem, and cache status pub async fn health(State(state): State) -> Json { - let mut response = HealthResponse { - status: "ok".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - database: None, - filesystem: None, - cache: None, - }; + let mut response = HealthResponse { + status: "ok".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + database: None, + filesystem: None, + cache: None, + }; - // Check database health - let db_start = Instant::now(); - let db_health = match state.storage.count_media().await { - Ok(count) => DatabaseHealth { - status: "ok".to_string(), - latency_ms: db_start.elapsed().as_millis() as u64, - media_count: Some(count), - }, - Err(e) => { - response.status = "degraded".to_string(); - DatabaseHealth { - status: format!("error: {}", e), - latency_ms: db_start.elapsed().as_millis() as u64, - media_count: None, - } - } - }; - response.database = Some(db_health); + // Check database health + let db_start = Instant::now(); + let db_health = match state.storage.count_media().await { + Ok(count) => { + DatabaseHealth { + status: "ok".to_string(), + latency_ms: db_start.elapsed().as_millis() as u64, + media_count: Some(count), + } + }, + Err(e) => { + response.status = "degraded".to_string(); + DatabaseHealth { + status: format!("error: {}", e), + latency_ms: db_start.elapsed().as_millis() as u64, + media_count: None, + } + }, + }; + response.database = Some(db_health); - // Check filesystem health (root directories) - let roots: Vec = state.storage.list_root_dirs().await.unwrap_or_default(); - let roots_accessible = roots.iter().filter(|r| r.exists()).count(); - if roots_accessible < roots.len() { - response.status = "degraded".to_string(); + // Check filesystem health (root directories) + let roots: Vec = + state.storage.list_root_dirs().await.unwrap_or_default(); + let roots_accessible = roots.iter().filter(|r| r.exists()).count(); + if roots_accessible < roots.len() { + response.status = "degraded".to_string(); + } + response.filesystem = Some(FilesystemHealth { + status: if roots_accessible == roots.len() { + "ok" + } else { + "degraded" } - response.filesystem = Some(FilesystemHealth { - status: if roots_accessible == roots.len() { - "ok" - } else { - "degraded" - } - .to_string(), - roots_configured: roots.len(), - roots_accessible, - }); + .to_string(), + roots_configured: roots.len(), + roots_accessible, + }); - // Get cache statistics - let cache_stats = state.cache.stats(); - response.cache = Some(CacheHealth { - hit_rate: cache_stats.overall_hit_rate(), - total_entries: cache_stats.total_entries(), - responses_size: cache_stats.responses.size, - queries_size: cache_stats.queries.size, - media_size: cache_stats.media.size, - }); + // Get cache statistics + let cache_stats = state.cache.stats(); + response.cache = Some(CacheHealth { + hit_rate: cache_stats.overall_hit_rate(), + total_entries: cache_stats.total_entries(), + responses_size: cache_stats.responses.size, + queries_size: cache_stats.queries.size, + media_size: cache_stats.media.size, + }); - Json(response) + Json(response) } /// Liveness probe - just checks if the server is running /// Returns 200 OK if the server process is alive pub async fn liveness() -> impl IntoResponse { - ( - StatusCode::OK, - Json(serde_json::json!({ - "status": "alive" - })), - ) + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "alive" + })), + ) } /// Readiness probe - checks if the server can serve requests /// Returns 200 OK if database is accessible pub async fn readiness(State(state): State) -> impl IntoResponse { - // Check database connectivity - let db_start = Instant::now(); - match state.storage.count_media().await { - Ok(_) => { - let latency = db_start.elapsed().as_millis() as u64; - ( - StatusCode::OK, - Json(serde_json::json!({ - "status": "ready", - "database_latency_ms": latency - })), - ) - } - Err(e) => ( - StatusCode::SERVICE_UNAVAILABLE, - Json(serde_json::json!({ - "status": "not_ready", - "reason": e.to_string() - })), - ), - } + // Check database connectivity + let db_start = Instant::now(); + match state.storage.count_media().await { + Ok(_) => { + let latency = db_start.elapsed().as_millis() as u64; + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "ready", + "database_latency_ms": latency + })), + ) + }, + Err(e) => { + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(serde_json::json!({ + "status": "not_ready", + "reason": e.to_string() + })), + ) + }, + } } /// Detailed health check for monitoring dashboards #[derive(Debug, Serialize, Deserialize)] pub struct DetailedHealthResponse { - pub status: String, - pub version: String, - pub uptime_seconds: u64, - pub database: DatabaseHealth, - pub filesystem: FilesystemHealth, - pub cache: CacheHealth, - pub jobs: JobsHealth, + pub status: String, + pub version: String, + pub uptime_seconds: u64, + pub database: DatabaseHealth, + pub filesystem: FilesystemHealth, + pub cache: CacheHealth, + pub jobs: JobsHealth, } #[derive(Debug, Serialize, Deserialize)] pub struct JobsHealth { - pub pending: usize, - pub running: usize, + pub pending: usize, + pub running: usize, } -pub async fn health_detailed(State(state): State) -> Json { - // Check database - let db_start = Instant::now(); - let (db_status, media_count) = match state.storage.count_media().await { - Ok(count) => ("ok".to_string(), Some(count)), - Err(e) => (format!("error: {}", e), None), - }; - let db_latency = db_start.elapsed().as_millis() as u64; +pub async fn health_detailed( + State(state): State, +) -> Json { + // Check database + let db_start = Instant::now(); + let (db_status, media_count) = match state.storage.count_media().await { + Ok(count) => ("ok".to_string(), Some(count)), + Err(e) => (format!("error: {}", e), None), + }; + let db_latency = db_start.elapsed().as_millis() as u64; - // Check filesystem - let roots = state.storage.list_root_dirs().await.unwrap_or_default(); - let roots_accessible = roots.iter().filter(|r| r.exists()).count(); + // Check filesystem + let roots = state.storage.list_root_dirs().await.unwrap_or_default(); + let roots_accessible = roots.iter().filter(|r| r.exists()).count(); - // Get cache stats - let cache_stats = state.cache.stats(); + // Get cache stats + let cache_stats = state.cache.stats(); - // Get job queue stats - let job_stats = state.job_queue.stats().await; + // Get job queue stats + let job_stats = state.job_queue.stats().await; - let overall_status = if db_status == "ok" && roots_accessible == roots.len() { + let overall_status = if db_status == "ok" && roots_accessible == roots.len() { + "ok" + } else { + "degraded" + }; + + Json(DetailedHealthResponse { + status: overall_status.to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + uptime_seconds: 0, // Could track server start time + database: DatabaseHealth { + status: db_status, + latency_ms: db_latency, + media_count, + }, + filesystem: FilesystemHealth { + status: if roots_accessible == roots.len() { "ok" - } else { + } else { "degraded" - }; - - Json(DetailedHealthResponse { - status: overall_status.to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - uptime_seconds: 0, // Could track server start time - database: DatabaseHealth { - status: db_status, - latency_ms: db_latency, - media_count, - }, - filesystem: FilesystemHealth { - status: if roots_accessible == roots.len() { - "ok" - } else { - "degraded" - } - .to_string(), - roots_configured: roots.len(), - roots_accessible, - }, - cache: CacheHealth { - hit_rate: cache_stats.overall_hit_rate(), - total_entries: cache_stats.total_entries(), - responses_size: cache_stats.responses.size, - queries_size: cache_stats.queries.size, - media_size: cache_stats.media.size, - }, - jobs: JobsHealth { - pending: job_stats.pending, - running: job_stats.running, - }, - }) + } + .to_string(), + roots_configured: roots.len(), + roots_accessible, + }, + cache: CacheHealth { + hit_rate: cache_stats.overall_hit_rate(), + total_entries: cache_stats.total_entries(), + responses_size: cache_stats.responses.size, + queries_size: cache_stats.queries.size, + media_size: cache_stats.media.size, + }, + jobs: JobsHealth { + pending: job_stats.pending, + running: job_stats.running, + }, + }) } diff --git a/crates/pinakes-server/src/routes/integrity.rs b/crates/pinakes-server/src/routes/integrity.rs index 8ab513b..bdf9f49 100644 --- a/crates/pinakes-server/src/routes/integrity.rs +++ b/crates/pinakes-server/src/routes/integrity.rs @@ -1,99 +1,98 @@ -use axum::Json; -use axum::extract::State; +use axum::{Json, extract::State}; use serde::Deserialize; -use crate::error::ApiError; -use crate::state::AppState; +use crate::{error::ApiError, state::AppState}; #[derive(Debug, Deserialize)] pub struct OrphanResolveRequest { - pub action: String, - pub ids: Vec, + pub action: String, + pub ids: Vec, } pub async fn trigger_orphan_detection( - State(state): State, + State(state): State, ) -> Result, ApiError> { - let kind = pinakes_core::jobs::JobKind::OrphanDetection; - let job_id = state.job_queue.submit(kind).await; - Ok(Json(serde_json::json!({ "job_id": job_id.to_string() }))) + let kind = pinakes_core::jobs::JobKind::OrphanDetection; + let job_id = state.job_queue.submit(kind).await; + Ok(Json(serde_json::json!({ "job_id": job_id.to_string() }))) } pub async fn trigger_verify_integrity( - State(state): State, - Json(req): Json, + State(state): State, + Json(req): Json, ) -> Result, ApiError> { - let media_ids = req - .media_ids - .into_iter() - .map(pinakes_core::model::MediaId) - .collect(); - let kind = pinakes_core::jobs::JobKind::VerifyIntegrity { media_ids }; - let job_id = state.job_queue.submit(kind).await; - Ok(Json(serde_json::json!({ "job_id": job_id.to_string() }))) + let media_ids = req + .media_ids + .into_iter() + .map(pinakes_core::model::MediaId) + .collect(); + let kind = pinakes_core::jobs::JobKind::VerifyIntegrity { media_ids }; + let job_id = state.job_queue.submit(kind).await; + Ok(Json(serde_json::json!({ "job_id": job_id.to_string() }))) } #[derive(Debug, Deserialize)] pub struct VerifyIntegrityRequest { - pub media_ids: Vec, + pub media_ids: Vec, } pub async fn trigger_cleanup_thumbnails( - State(state): State, + State(state): State, ) -> Result, ApiError> { - let kind = pinakes_core::jobs::JobKind::CleanupThumbnails; - let job_id = state.job_queue.submit(kind).await; - Ok(Json(serde_json::json!({ "job_id": job_id.to_string() }))) + let kind = pinakes_core::jobs::JobKind::CleanupThumbnails; + let job_id = state.job_queue.submit(kind).await; + Ok(Json(serde_json::json!({ "job_id": job_id.to_string() }))) } #[derive(Debug, Deserialize)] pub struct GenerateThumbnailsRequest { - /// When true, only generate thumbnails for items that don't have one yet. - /// When false (default), regenerate all thumbnails. - #[serde(default)] - pub only_missing: bool, + /// When true, only generate thumbnails for items that don't have one yet. + /// When false (default), regenerate all thumbnails. + #[serde(default)] + pub only_missing: bool, } pub async fn generate_all_thumbnails( - State(state): State, - body: Option>, + State(state): State, + body: Option>, ) -> Result, ApiError> { - let only_missing = body.map(|b| b.only_missing).unwrap_or(false); - let media_ids = state - .storage - .list_media_ids_for_thumbnails(only_missing) - .await?; - let count = media_ids.len(); - if count == 0 { - return Ok(Json(serde_json::json!({ - "job_id": null, - "media_count": 0, - "message": "no media items to process" - }))); - } - let kind = pinakes_core::jobs::JobKind::GenerateThumbnails { media_ids }; - let job_id = state.job_queue.submit(kind).await; - Ok(Json(serde_json::json!({ - "job_id": job_id.to_string(), - "media_count": count - }))) + let only_missing = body.map(|b| b.only_missing).unwrap_or(false); + let media_ids = state + .storage + .list_media_ids_for_thumbnails(only_missing) + .await?; + let count = media_ids.len(); + if count == 0 { + return Ok(Json(serde_json::json!({ + "job_id": null, + "media_count": 0, + "message": "no media items to process" + }))); + } + let kind = pinakes_core::jobs::JobKind::GenerateThumbnails { media_ids }; + let job_id = state.job_queue.submit(kind).await; + Ok(Json(serde_json::json!({ + "job_id": job_id.to_string(), + "media_count": count + }))) } pub async fn resolve_orphans( - State(state): State, - Json(req): Json, + State(state): State, + Json(req): Json, ) -> Result, ApiError> { - let action = match req.action.as_str() { - "delete" => pinakes_core::integrity::OrphanAction::Delete, - _ => pinakes_core::integrity::OrphanAction::Ignore, - }; - let ids: Vec = req - .ids - .into_iter() - .map(pinakes_core::model::MediaId) - .collect(); - let count = pinakes_core::integrity::resolve_orphans(&state.storage, action, &ids) - .await - .map_err(ApiError)?; - Ok(Json(serde_json::json!({ "resolved": count }))) + let action = match req.action.as_str() { + "delete" => pinakes_core::integrity::OrphanAction::Delete, + _ => pinakes_core::integrity::OrphanAction::Ignore, + }; + let ids: Vec = req + .ids + .into_iter() + .map(pinakes_core::model::MediaId) + .collect(); + let count = + pinakes_core::integrity::resolve_orphans(&state.storage, action, &ids) + .await + .map_err(ApiError)?; + Ok(Json(serde_json::json!({ "resolved": count }))) } diff --git a/crates/pinakes-server/src/routes/jobs.rs b/crates/pinakes-server/src/routes/jobs.rs index adb0599..6016c05 100644 --- a/crates/pinakes-server/src/routes/jobs.rs +++ b/crates/pinakes-server/src/routes/jobs.rs @@ -1,34 +1,38 @@ -use axum::Json; -use axum::extract::{Path, State}; - -use crate::error::ApiError; -use crate::state::AppState; +use axum::{ + Json, + extract::{Path, State}, +}; use pinakes_core::jobs::Job; +use crate::{error::ApiError, state::AppState}; + pub async fn list_jobs(State(state): State) -> Json> { - Json(state.job_queue.list().await) + Json(state.job_queue.list().await) } pub async fn get_job( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result, ApiError> { - state.job_queue.status(id).await.map(Json).ok_or_else(|| { - pinakes_core::error::PinakesError::NotFound(format!("job not found: {id}")).into() - }) + state.job_queue.status(id).await.map(Json).ok_or_else(|| { + pinakes_core::error::PinakesError::NotFound(format!("job not found: {id}")) + .into() + }) } pub async fn cancel_job( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result, ApiError> { - let cancelled = state.job_queue.cancel(id).await; - if cancelled { - Ok(Json(serde_json::json!({ "cancelled": true }))) - } else { - Err(pinakes_core::error::PinakesError::NotFound(format!( - "job not found or already finished: {id}" - )) - .into()) - } + let cancelled = state.job_queue.cancel(id).await; + if cancelled { + Ok(Json(serde_json::json!({ "cancelled": true }))) + } else { + Err( + pinakes_core::error::PinakesError::NotFound(format!( + "job not found or already finished: {id}" + )) + .into(), + ) + } } diff --git a/crates/pinakes-server/src/routes/media.rs b/crates/pinakes-server/src/routes/media.rs index e3a033c..9a809aa 100644 --- a/crates/pinakes-server/src/routes/media.rs +++ b/crates/pinakes-server/src/routes/media.rs @@ -1,81 +1,89 @@ -use axum::Json; -use axum::extract::{Path, Query, State}; +use axum::{ + Json, + extract::{Path, Query, State}, +}; +use pinakes_core::{ + model::{MediaId, Pagination}, + storage::DynStorageBackend, +}; use uuid::Uuid; -use crate::dto::*; -use crate::error::ApiError; -use crate::state::AppState; - -use pinakes_core::model::{MediaId, Pagination}; -use pinakes_core::storage::DynStorageBackend; +use crate::{dto::*, error::ApiError, state::AppState}; /// Apply tags and add to collection after a successful import. -/// Shared logic used by import_with_options, batch_import, and import_directory_endpoint. +/// Shared logic used by import_with_options, batch_import, and +/// import_directory_endpoint. async fn apply_import_post_processing( - storage: &DynStorageBackend, - media_id: MediaId, - tag_ids: Option<&[Uuid]>, - new_tags: Option<&[String]>, - collection_id: Option, + storage: &DynStorageBackend, + media_id: MediaId, + tag_ids: Option<&[Uuid]>, + new_tags: Option<&[String]>, + collection_id: Option, ) { - if let Some(tag_ids) = tag_ids { - for tid in tag_ids { - if let Err(e) = pinakes_core::tags::tag_media(storage, media_id, *tid).await { - tracing::warn!(error = %e, "failed to apply tag during import"); - } - } + if let Some(tag_ids) = tag_ids { + for tid in tag_ids { + if let Err(e) = + pinakes_core::tags::tag_media(storage, media_id, *tid).await + { + tracing::warn!(error = %e, "failed to apply tag during import"); + } } - if let Some(new_tags) = new_tags { - for name in new_tags { - match pinakes_core::tags::create_tag(storage, name, None).await { - Ok(tag) => { - if let Err(e) = pinakes_core::tags::tag_media(storage, media_id, tag.id).await { - tracing::warn!(error = %e, "failed to apply new tag during import"); - } - } - Err(e) => { - tracing::warn!(tag_name = %name, error = %e, "failed to create tag during import"); - } - } - } - } - if let Some(col_id) = collection_id - && let Err(e) = pinakes_core::collections::add_member(storage, col_id, media_id, 0).await - { - tracing::warn!(error = %e, "failed to add to collection during import"); + } + if let Some(new_tags) = new_tags { + for name in new_tags { + match pinakes_core::tags::create_tag(storage, name, None).await { + Ok(tag) => { + if let Err(e) = + pinakes_core::tags::tag_media(storage, media_id, tag.id).await + { + tracing::warn!(error = %e, "failed to apply new tag during import"); + } + }, + Err(e) => { + tracing::warn!(tag_name = %name, error = %e, "failed to create tag during import"); + }, + } } + } + if let Some(col_id) = collection_id + && let Err(e) = + pinakes_core::collections::add_member(storage, col_id, media_id, 0).await + { + tracing::warn!(error = %e, "failed to add to collection during import"); + } } pub async fn import_media( - State(state): State, - Json(req): Json, + State(state): State, + Json(req): Json, ) -> Result, ApiError> { - let result = pinakes_core::import::import_file(&state.storage, &req.path).await?; - Ok(Json(ImportResponse { - media_id: result.media_id.0.to_string(), - was_duplicate: result.was_duplicate, - })) + let result = + pinakes_core::import::import_file(&state.storage, &req.path).await?; + Ok(Json(ImportResponse { + media_id: result.media_id.0.to_string(), + was_duplicate: result.was_duplicate, + })) } pub async fn list_media( - State(state): State, - Query(params): Query, + State(state): State, + Query(params): Query, ) -> Result>, ApiError> { - let pagination = Pagination::new( - params.offset.unwrap_or(0), - params.limit.unwrap_or(50).min(1000), - params.sort, - ); - let items = state.storage.list_media(&pagination).await?; - Ok(Json(items.into_iter().map(MediaResponse::from).collect())) + let pagination = Pagination::new( + params.offset.unwrap_or(0), + params.limit.unwrap_or(50).min(1000), + params.sort, + ); + let items = state.storage.list_media(&pagination).await?; + Ok(Json(items.into_iter().map(MediaResponse::from).collect())) } pub async fn get_media( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result, ApiError> { - let item = state.storage.get_media(MediaId(id)).await?; - Ok(Json(MediaResponse::from(item))) + let item = state.storage.get_media(MediaId(id)).await?; + Ok(Json(MediaResponse::from(item))) } /// Maximum length for short text fields (title, artist, album, genre). @@ -83,1030 +91,1073 @@ const MAX_SHORT_TEXT: usize = 500; /// Maximum length for long text fields (description). const MAX_LONG_TEXT: usize = 10_000; -fn validate_optional_text(field: &Option, name: &str, max: usize) -> Result<(), ApiError> { - if let Some(v) = field - && v.len() > max - { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation(format!( - "{name} exceeds {max} characters" - )), - )); - } - Ok(()) +fn validate_optional_text( + field: &Option, + name: &str, + max: usize, +) -> Result<(), ApiError> { + if let Some(v) = field + && v.len() > max + { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation(format!( + "{name} exceeds {max} characters" + )), + )); + } + Ok(()) } pub async fn update_media( - State(state): State, - Path(id): Path, - Json(req): Json, + State(state): State, + Path(id): Path, + Json(req): Json, ) -> Result, ApiError> { - validate_optional_text(&req.title, "title", MAX_SHORT_TEXT)?; - validate_optional_text(&req.artist, "artist", MAX_SHORT_TEXT)?; - validate_optional_text(&req.album, "album", MAX_SHORT_TEXT)?; - validate_optional_text(&req.genre, "genre", MAX_SHORT_TEXT)?; - validate_optional_text(&req.description, "description", MAX_LONG_TEXT)?; + validate_optional_text(&req.title, "title", MAX_SHORT_TEXT)?; + validate_optional_text(&req.artist, "artist", MAX_SHORT_TEXT)?; + validate_optional_text(&req.album, "album", MAX_SHORT_TEXT)?; + validate_optional_text(&req.genre, "genre", MAX_SHORT_TEXT)?; + validate_optional_text(&req.description, "description", MAX_LONG_TEXT)?; - let mut item = state.storage.get_media(MediaId(id)).await?; + let mut item = state.storage.get_media(MediaId(id)).await?; - if let Some(title) = req.title { - item.title = Some(title); - } - if let Some(artist) = req.artist { - item.artist = Some(artist); - } - if let Some(album) = req.album { - item.album = Some(album); - } - if let Some(genre) = req.genre { - item.genre = Some(genre); - } - if let Some(year) = req.year { - item.year = Some(year); - } - if let Some(description) = req.description { - item.description = Some(description); - } - item.updated_at = chrono::Utc::now(); + if let Some(title) = req.title { + item.title = Some(title); + } + if let Some(artist) = req.artist { + item.artist = Some(artist); + } + if let Some(album) = req.album { + item.album = Some(album); + } + if let Some(genre) = req.genre { + item.genre = Some(genre); + } + if let Some(year) = req.year { + item.year = Some(year); + } + if let Some(description) = req.description { + item.description = Some(description); + } + item.updated_at = chrono::Utc::now(); - state.storage.update_media(&item).await?; - pinakes_core::audit::record_action( - &state.storage, - Some(item.id), - pinakes_core::model::AuditAction::Updated, - None, - ) - .await?; + state.storage.update_media(&item).await?; + pinakes_core::audit::record_action( + &state.storage, + Some(item.id), + pinakes_core::model::AuditAction::Updated, + None, + ) + .await?; - Ok(Json(MediaResponse::from(item))) + Ok(Json(MediaResponse::from(item))) } pub async fn delete_media( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result, ApiError> { - let media_id = MediaId(id); - // Fetch item first to get thumbnail path for cleanup - let item = state.storage.get_media(media_id).await?; + let media_id = MediaId(id); + // Fetch item first to get thumbnail path for cleanup + let item = state.storage.get_media(media_id).await?; - // Record audit BEFORE delete to avoid FK constraint violation - pinakes_core::audit::record_action( - &state.storage, - Some(media_id), - pinakes_core::model::AuditAction::Deleted, - None, - ) - .await?; + // Record audit BEFORE delete to avoid FK constraint violation + pinakes_core::audit::record_action( + &state.storage, + Some(media_id), + pinakes_core::model::AuditAction::Deleted, + None, + ) + .await?; - state.storage.delete_media(media_id).await?; + state.storage.delete_media(media_id).await?; - // Clean up thumbnail file if it exists - if let Some(ref thumb_path) = item.thumbnail_path - && let Err(e) = tokio::fs::remove_file(thumb_path).await - && e.kind() != std::io::ErrorKind::NotFound - { - tracing::warn!(path = %thumb_path.display(), error = %e, "failed to remove thumbnail"); - } + // Clean up thumbnail file if it exists + if let Some(ref thumb_path) = item.thumbnail_path + && let Err(e) = tokio::fs::remove_file(thumb_path).await + && e.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!(path = %thumb_path.display(), error = %e, "failed to remove thumbnail"); + } - Ok(Json(serde_json::json!({"deleted": true}))) + Ok(Json(serde_json::json!({"deleted": true}))) } pub async fn open_media( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result, ApiError> { - let item = state.storage.get_media(MediaId(id)).await?; - let opener = pinakes_core::opener::default_opener(); - opener.open(&item.path)?; - pinakes_core::audit::record_action( - &state.storage, - Some(item.id), - pinakes_core::model::AuditAction::Opened, - None, - ) - .await?; - Ok(Json(serde_json::json!({"opened": true}))) + let item = state.storage.get_media(MediaId(id)).await?; + let opener = pinakes_core::opener::default_opener(); + opener.open(&item.path)?; + pinakes_core::audit::record_action( + &state.storage, + Some(item.id), + pinakes_core::model::AuditAction::Opened, + None, + ) + .await?; + Ok(Json(serde_json::json!({"opened": true}))) } pub async fn stream_media( - State(state): State, - Path(id): Path, - headers: axum::http::HeaderMap, + State(state): State, + Path(id): Path, + headers: axum::http::HeaderMap, ) -> Result { - use axum::body::Body; - use axum::http::{StatusCode, header}; - use tokio::io::{AsyncReadExt, AsyncSeekExt}; - use tokio_util::io::ReaderStream; + use axum::{ + body::Body, + http::{StatusCode, header}, + }; + use tokio::io::{AsyncReadExt, AsyncSeekExt}; + use tokio_util::io::ReaderStream; - let item = state.storage.get_media(MediaId(id)).await?; + let item = state.storage.get_media(MediaId(id)).await?; - let file = tokio::fs::File::open(&item.path).await.map_err(|_e| { - ApiError(pinakes_core::error::PinakesError::FileNotFound( - item.path.clone(), - )) - })?; + let file = tokio::fs::File::open(&item.path).await.map_err(|_e| { + ApiError(pinakes_core::error::PinakesError::FileNotFound( + item.path.clone(), + )) + })?; - let metadata = file - .metadata() - .await - .map_err(|e| ApiError(pinakes_core::error::PinakesError::Io(e)))?; - let total_size = metadata.len(); - let content_type = item.media_type.mime_type(); + let metadata = file + .metadata() + .await + .map_err(|e| ApiError(pinakes_core::error::PinakesError::Io(e)))?; + let total_size = metadata.len(); + let content_type = item.media_type.mime_type(); - // Parse Range header - if let Some(range_header) = headers.get(header::RANGE) - && let Ok(range_str) = range_header.to_str() - && let Some(range) = parse_range(range_str, total_size) - { - let (start, end) = range; - let content_length = end - start + 1; + // Parse Range header + if let Some(range_header) = headers.get(header::RANGE) + && let Ok(range_str) = range_header.to_str() + && let Some(range) = parse_range(range_str, total_size) + { + let (start, end) = range; + let content_length = end - start + 1; - let mut file = file; - file.seek(std::io::SeekFrom::Start(start)) - .await - .map_err(|e| ApiError(pinakes_core::error::PinakesError::Io(e)))?; + let mut file = file; + file + .seek(std::io::SeekFrom::Start(start)) + .await + .map_err(|e| ApiError(pinakes_core::error::PinakesError::Io(e)))?; - let limited = file.take(content_length); - let stream = ReaderStream::new(limited); - let body = Body::from_stream(stream); - - return axum::response::Response::builder() - .status(StatusCode::PARTIAL_CONTENT) - .header(header::CONTENT_TYPE, content_type) - .header(header::CONTENT_LENGTH, content_length) - .header(header::ACCEPT_RANGES, "bytes") - .header( - header::CONTENT_RANGE, - format!("bytes {start}-{end}/{total_size}"), - ) - .header( - header::CONTENT_DISPOSITION, - format!("inline; filename=\"{}\"", item.file_name), - ) - .body(body) - .map_err(|e| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - format!("failed to build response: {e}"), - )) - }); - } - - // Full response (no Range header) - let stream = ReaderStream::new(file); + let limited = file.take(content_length); + let stream = ReaderStream::new(limited); let body = Body::from_stream(stream); - axum::response::Response::builder() - .header(header::CONTENT_TYPE, content_type) - .header(header::CONTENT_LENGTH, total_size) - .header(header::ACCEPT_RANGES, "bytes") - .header( - header::CONTENT_DISPOSITION, - format!("inline; filename=\"{}\"", item.file_name), - ) - .body(body) - .map_err(|e| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - format!("failed to build response: {e}"), - )) - }) + return axum::response::Response::builder() + .status(StatusCode::PARTIAL_CONTENT) + .header(header::CONTENT_TYPE, content_type) + .header(header::CONTENT_LENGTH, content_length) + .header(header::ACCEPT_RANGES, "bytes") + .header( + header::CONTENT_RANGE, + format!("bytes {start}-{end}/{total_size}"), + ) + .header( + header::CONTENT_DISPOSITION, + format!("inline; filename=\"{}\"", item.file_name), + ) + .body(body) + .map_err(|e| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + format!("failed to build response: {e}"), + )) + }); + } + + // Full response (no Range header) + let stream = ReaderStream::new(file); + let body = Body::from_stream(stream); + + axum::response::Response::builder() + .header(header::CONTENT_TYPE, content_type) + .header(header::CONTENT_LENGTH, total_size) + .header(header::ACCEPT_RANGES, "bytes") + .header( + header::CONTENT_DISPOSITION, + format!("inline; filename=\"{}\"", item.file_name), + ) + .body(body) + .map_err(|e| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + format!("failed to build response: {e}"), + )) + }) } /// Parse a `Range: bytes=START-END` header value. /// Returns `Some((start, end))` inclusive, or `None` if malformed. fn parse_range(header: &str, total_size: u64) -> Option<(u64, u64)> { - let bytes_prefix = header.strip_prefix("bytes=")?; - let (start_str, end_str) = bytes_prefix.split_once('-')?; + let bytes_prefix = header.strip_prefix("bytes=")?; + let (start_str, end_str) = bytes_prefix.split_once('-')?; - if start_str.is_empty() { - // Suffix range: bytes=-500 means last 500 bytes - let suffix_len: u64 = end_str.parse().ok()?; - let start = total_size.saturating_sub(suffix_len); - Some((start, total_size - 1)) + if start_str.is_empty() { + // Suffix range: bytes=-500 means last 500 bytes + let suffix_len: u64 = end_str.parse().ok()?; + let start = total_size.saturating_sub(suffix_len); + Some((start, total_size - 1)) + } else { + let start: u64 = start_str.parse().ok()?; + let end = if end_str.is_empty() { + total_size - 1 } else { - let start: u64 = start_str.parse().ok()?; - let end = if end_str.is_empty() { - total_size - 1 - } else { - end_str.parse::().ok()?.min(total_size - 1) - }; - if start > end || start >= total_size { - return None; - } - Some((start, end)) + end_str.parse::().ok()?.min(total_size - 1) + }; + if start > end || start >= total_size { + return None; } + Some((start, end)) + } } pub async fn import_with_options( - State(state): State, - Json(req): Json, + State(state): State, + Json(req): Json, ) -> Result, ApiError> { - let result = pinakes_core::import::import_file(&state.storage, &req.path).await?; + let result = + pinakes_core::import::import_file(&state.storage, &req.path).await?; - if !result.was_duplicate { - apply_import_post_processing( + if !result.was_duplicate { + apply_import_post_processing( + &state.storage, + result.media_id, + req.tag_ids.as_deref(), + req.new_tags.as_deref(), + req.collection_id, + ) + .await; + } + + Ok(Json(ImportResponse { + media_id: result.media_id.0.to_string(), + was_duplicate: result.was_duplicate, + })) +} + +pub async fn batch_import( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + if req.paths.len() > 10_000 { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "batch size exceeds limit of 10000".into(), + ), + )); + } + + let mut results = Vec::new(); + let mut imported = 0usize; + let mut duplicates = 0usize; + let mut errors = 0usize; + + for path in &req.paths { + match pinakes_core::import::import_file(&state.storage, path).await { + Ok(result) => { + if result.was_duplicate { + duplicates += 1; + } else { + imported += 1; + apply_import_post_processing( &state.storage, result.media_id, req.tag_ids.as_deref(), req.new_tags.as_deref(), req.collection_id, - ) - .await; - } - - Ok(Json(ImportResponse { - media_id: result.media_id.0.to_string(), - was_duplicate: result.was_duplicate, - })) -} - -pub async fn batch_import( - State(state): State, - Json(req): Json, -) -> Result, ApiError> { - if req.paths.len() > 10_000 { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( - "batch size exceeds limit of 10000".into(), - ), - )); - } - - let mut results = Vec::new(); - let mut imported = 0usize; - let mut duplicates = 0usize; - let mut errors = 0usize; - - for path in &req.paths { - match pinakes_core::import::import_file(&state.storage, path).await { - Ok(result) => { - if result.was_duplicate { - duplicates += 1; - } else { - imported += 1; - apply_import_post_processing( - &state.storage, - result.media_id, - req.tag_ids.as_deref(), - req.new_tags.as_deref(), - req.collection_id, - ) - .await; - } - results.push(BatchImportItemResult { - path: path.to_string_lossy().to_string(), - media_id: Some(result.media_id.0.to_string()), - was_duplicate: result.was_duplicate, - error: None, - }); - } - Err(e) => { - errors += 1; - results.push(BatchImportItemResult { - path: path.to_string_lossy().to_string(), - media_id: None, - was_duplicate: false, - error: Some(e.to_string()), - }); - } + ) + .await; } + results.push(BatchImportItemResult { + path: path.to_string_lossy().to_string(), + media_id: Some(result.media_id.0.to_string()), + was_duplicate: result.was_duplicate, + error: None, + }); + }, + Err(e) => { + errors += 1; + results.push(BatchImportItemResult { + path: path.to_string_lossy().to_string(), + media_id: None, + was_duplicate: false, + error: Some(e.to_string()), + }); + }, } + } - let total = results.len(); - Ok(Json(BatchImportResponse { - results, - total, - imported, - duplicates, - errors, - })) + let total = results.len(); + Ok(Json(BatchImportResponse { + results, + total, + imported, + duplicates, + errors, + })) } pub async fn import_directory_endpoint( - State(state): State, - Json(req): Json, + State(state): State, + Json(req): Json, ) -> Result, ApiError> { - let config = state.config.read().await; - let ignore_patterns = config.scanning.ignore_patterns.clone(); - let concurrency = config.scanning.import_concurrency; - drop(config); + let config = state.config.read().await; + let ignore_patterns = config.scanning.ignore_patterns.clone(); + let concurrency = config.scanning.import_concurrency; + drop(config); - let import_results = pinakes_core::import::import_directory_with_concurrency( - &state.storage, - &req.path, - &ignore_patterns, - concurrency, - ) - .await?; + let import_results = pinakes_core::import::import_directory_with_concurrency( + &state.storage, + &req.path, + &ignore_patterns, + concurrency, + ) + .await?; - let mut results = Vec::new(); - let mut imported = 0usize; - let mut duplicates = 0usize; - let mut errors = 0usize; + let mut results = Vec::new(); + let mut imported = 0usize; + let mut duplicates = 0usize; + let mut errors = 0usize; - for r in import_results { - match r { - Ok(result) => { - if result.was_duplicate { - duplicates += 1; - } else { - imported += 1; - apply_import_post_processing( - &state.storage, - result.media_id, - req.tag_ids.as_deref(), - req.new_tags.as_deref(), - req.collection_id, - ) - .await; - } - results.push(BatchImportItemResult { - path: result.path.to_string_lossy().to_string(), - media_id: Some(result.media_id.0.to_string()), - was_duplicate: result.was_duplicate, - error: None, - }); - } - Err(e) => { - errors += 1; - results.push(BatchImportItemResult { - path: String::new(), - media_id: None, - was_duplicate: false, - error: Some(e.to_string()), - }); - } + for r in import_results { + match r { + Ok(result) => { + if result.was_duplicate { + duplicates += 1; + } else { + imported += 1; + apply_import_post_processing( + &state.storage, + result.media_id, + req.tag_ids.as_deref(), + req.new_tags.as_deref(), + req.collection_id, + ) + .await; } + results.push(BatchImportItemResult { + path: result.path.to_string_lossy().to_string(), + media_id: Some(result.media_id.0.to_string()), + was_duplicate: result.was_duplicate, + error: None, + }); + }, + Err(e) => { + errors += 1; + results.push(BatchImportItemResult { + path: String::new(), + media_id: None, + was_duplicate: false, + error: Some(e.to_string()), + }); + }, } + } - let total = results.len(); - Ok(Json(BatchImportResponse { - results, - total, - imported, - duplicates, - errors, - })) + let total = results.len(); + Ok(Json(BatchImportResponse { + results, + total, + imported, + duplicates, + errors, + })) } pub async fn preview_directory( - State(state): State, - Json(req): Json, + State(state): State, + Json(req): Json, ) -> Result, ApiError> { - let path_str = req.get("path").and_then(|v| v.as_str()).ok_or_else(|| { - pinakes_core::error::PinakesError::InvalidOperation("path required".into()) + let path_str = req.get("path").and_then(|v| v.as_str()).ok_or_else(|| { + pinakes_core::error::PinakesError::InvalidOperation("path required".into()) + })?; + let recursive = req + .get("recursive") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + let dir = std::path::PathBuf::from(path_str); + if !dir.is_dir() { + return Err(pinakes_core::error::PinakesError::FileNotFound(dir).into()); + } + + // Validate the directory is under a configured root (if roots are configured) + let roots = state.storage.list_root_dirs().await?; + if !roots.is_empty() { + let canonical = dir.canonicalize().map_err(|_| { + pinakes_core::error::PinakesError::InvalidOperation( + "cannot resolve path".into(), + ) })?; - let recursive = req - .get("recursive") - .and_then(|v| v.as_bool()) - .unwrap_or(true); - let dir = std::path::PathBuf::from(path_str); - if !dir.is_dir() { - return Err(pinakes_core::error::PinakesError::FileNotFound(dir).into()); + let allowed = roots.iter().any(|root| canonical.starts_with(root)); + if !allowed { + return Err( + pinakes_core::error::PinakesError::InvalidOperation( + "path is not under a configured root directory".into(), + ) + .into(), + ); } + } - // Validate the directory is under a configured root (if roots are configured) - let roots = state.storage.list_root_dirs().await?; - if !roots.is_empty() { - let canonical = dir.canonicalize().map_err(|_| { - pinakes_core::error::PinakesError::InvalidOperation("cannot resolve path".into()) - })?; - let allowed = roots.iter().any(|root| canonical.starts_with(root)); - if !allowed { - return Err(pinakes_core::error::PinakesError::InvalidOperation( - "path is not under a configured root directory".into(), - ) - .into()); - } - } - - let files: Vec = tokio::task::spawn_blocking(move || { - let mut result = Vec::new(); - fn walk_dir( - dir: &std::path::Path, - recursive: bool, - result: &mut Vec, - ) { - let Ok(entries) = std::fs::read_dir(dir) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - // Skip hidden files/dirs - if path - .file_name() - .map(|n| n.to_string_lossy().starts_with('.')) - .unwrap_or(false) - { - continue; - } - if path.is_dir() { - if recursive { - walk_dir(&path, recursive, result); - } - } else if path.is_file() - && let Some(mt) = pinakes_core::media_type::MediaType::from_path(&path) - { - let size = entry.metadata().ok().map(|m| m.len()).unwrap_or(0); - let file_name = path - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_default(); - let media_type = serde_json::to_value(mt) - .ok() - .and_then(|v| v.as_str().map(String::from)) - .unwrap_or_default(); - result.push(DirectoryPreviewFile { - path: path.to_string_lossy().to_string(), - file_name, - media_type, - file_size: size, - }); - } + let files: Vec = + tokio::task::spawn_blocking(move || { + let mut result = Vec::new(); + fn walk_dir( + dir: &std::path::Path, + recursive: bool, + result: &mut Vec, + ) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + // Skip hidden files/dirs + if path + .file_name() + .map(|n| n.to_string_lossy().starts_with('.')) + .unwrap_or(false) + { + continue; + } + if path.is_dir() { + if recursive { + walk_dir(&path, recursive, result); } + } else if path.is_file() + && let Some(mt) = + pinakes_core::media_type::MediaType::from_path(&path) + { + let size = entry.metadata().ok().map(|m| m.len()).unwrap_or(0); + let file_name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + let media_type = serde_json::to_value(mt) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_default(); + result.push(DirectoryPreviewFile { + path: path.to_string_lossy().to_string(), + file_name, + media_type, + file_size: size, + }); + } } - walk_dir(&dir, recursive, &mut result); - result + } + walk_dir(&dir, recursive, &mut result); + result }) .await - .map_err(|e| pinakes_core::error::PinakesError::Io(std::io::Error::other(e)))?; + .map_err(|e| { + pinakes_core::error::PinakesError::Io(std::io::Error::other(e)) + })?; - let total_count = files.len(); - let total_size = files.iter().map(|f| f.file_size).sum(); + let total_count = files.len(); + let total_size = files.iter().map(|f| f.file_size).sum(); - Ok(Json(DirectoryPreviewResponse { - files, - total_count, - total_size, - })) + Ok(Json(DirectoryPreviewResponse { + files, + total_count, + total_size, + })) } pub async fn set_custom_field( - State(state): State, - Path(id): Path, - Json(req): Json, + State(state): State, + Path(id): Path, + Json(req): Json, ) -> Result, ApiError> { - if req.name.is_empty() || req.name.len() > 255 { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( - "field name must be 1-255 characters".into(), - ), - )); - } - if req.value.len() > MAX_LONG_TEXT { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation(format!( - "field value exceeds {} characters", - MAX_LONG_TEXT - )), - )); - } - use pinakes_core::model::{CustomField, CustomFieldType}; - let field_type = match req.field_type.as_str() { - "number" => CustomFieldType::Number, - "date" => CustomFieldType::Date, - "boolean" => CustomFieldType::Boolean, - _ => CustomFieldType::Text, - }; - let field = CustomField { - field_type, - value: req.value, - }; - state - .storage - .set_custom_field(MediaId(id), &req.name, &field) - .await?; - Ok(Json(serde_json::json!({"set": true}))) + if req.name.is_empty() || req.name.len() > 255 { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "field name must be 1-255 characters".into(), + ), + )); + } + if req.value.len() > MAX_LONG_TEXT { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation(format!( + "field value exceeds {} characters", + MAX_LONG_TEXT + )), + )); + } + use pinakes_core::model::{CustomField, CustomFieldType}; + let field_type = match req.field_type.as_str() { + "number" => CustomFieldType::Number, + "date" => CustomFieldType::Date, + "boolean" => CustomFieldType::Boolean, + _ => CustomFieldType::Text, + }; + let field = CustomField { + field_type, + value: req.value, + }; + state + .storage + .set_custom_field(MediaId(id), &req.name, &field) + .await?; + Ok(Json(serde_json::json!({"set": true}))) } pub async fn delete_custom_field( - State(state): State, - Path((id, name)): Path<(Uuid, String)>, + State(state): State, + Path((id, name)): Path<(Uuid, String)>, ) -> Result, ApiError> { - state - .storage - .delete_custom_field(MediaId(id), &name) - .await?; - Ok(Json(serde_json::json!({"deleted": true}))) + state + .storage + .delete_custom_field(MediaId(id), &name) + .await?; + Ok(Json(serde_json::json!({"deleted": true}))) } pub async fn batch_tag( - State(state): State, - Json(req): Json, + State(state): State, + Json(req): Json, ) -> Result, ApiError> { - if req.media_ids.len() > 10_000 { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( - "batch size exceeds limit of 10000".into(), - ), - )); - } + if req.media_ids.len() > 10_000 { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "batch size exceeds limit of 10000".into(), + ), + )); + } - let media_ids: Vec = req.media_ids.iter().map(|id| MediaId(*id)).collect(); - match state - .storage - .batch_tag_media(&media_ids, &req.tag_ids) - .await - { - Ok(count) => Ok(Json(BatchOperationResponse { - processed: count as usize, - errors: Vec::new(), - })), - Err(e) => Ok(Json(BatchOperationResponse { - processed: 0, - errors: vec![e.to_string()], - })), - } + let media_ids: Vec = + req.media_ids.iter().map(|id| MediaId(*id)).collect(); + match state + .storage + .batch_tag_media(&media_ids, &req.tag_ids) + .await + { + Ok(count) => { + Ok(Json(BatchOperationResponse { + processed: count as usize, + errors: Vec::new(), + })) + }, + Err(e) => { + Ok(Json(BatchOperationResponse { + processed: 0, + errors: vec![e.to_string()], + })) + }, + } } pub async fn delete_all_media( - State(state): State, + State(state): State, ) -> Result, ApiError> { - // Record audit entry before deletion - if let Err(e) = pinakes_core::audit::record_action( - &state.storage, - None, - pinakes_core::model::AuditAction::Deleted, - Some("delete all media".to_string()), - ) - .await - { - tracing::warn!(error = %e, "failed to record audit entry"); - } + // Record audit entry before deletion + if let Err(e) = pinakes_core::audit::record_action( + &state.storage, + None, + pinakes_core::model::AuditAction::Deleted, + Some("delete all media".to_string()), + ) + .await + { + tracing::warn!(error = %e, "failed to record audit entry"); + } - match state.storage.delete_all_media().await { - Ok(count) => Ok(Json(BatchOperationResponse { - processed: count as usize, - errors: Vec::new(), - })), - Err(e) => Ok(Json(BatchOperationResponse { - processed: 0, - errors: vec![e.to_string()], - })), - } + match state.storage.delete_all_media().await { + Ok(count) => { + Ok(Json(BatchOperationResponse { + processed: count as usize, + errors: Vec::new(), + })) + }, + Err(e) => { + Ok(Json(BatchOperationResponse { + processed: 0, + errors: vec![e.to_string()], + })) + }, + } } pub async fn batch_delete( - State(state): State, - Json(req): Json, + State(state): State, + Json(req): Json, ) -> Result, ApiError> { - if req.media_ids.len() > 10_000 { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( - "batch size exceeds limit of 10000".into(), - ), - )); - } + if req.media_ids.len() > 10_000 { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "batch size exceeds limit of 10000".into(), + ), + )); + } - let media_ids: Vec = req.media_ids.iter().map(|id| MediaId(*id)).collect(); + let media_ids: Vec = + req.media_ids.iter().map(|id| MediaId(*id)).collect(); - // Record audit entries BEFORE delete to avoid FK constraint violation. - // Use None for media_id since they'll be deleted; include ID in details. - for id in &media_ids { - if let Err(e) = pinakes_core::audit::record_action( - &state.storage, - None, - pinakes_core::model::AuditAction::Deleted, - Some(format!("batch delete: media_id={}", id.0)), - ) - .await - { - tracing::warn!(error = %e, "failed to record audit entry"); - } + // Record audit entries BEFORE delete to avoid FK constraint violation. + // Use None for media_id since they'll be deleted; include ID in details. + for id in &media_ids { + if let Err(e) = pinakes_core::audit::record_action( + &state.storage, + None, + pinakes_core::model::AuditAction::Deleted, + Some(format!("batch delete: media_id={}", id.0)), + ) + .await + { + tracing::warn!(error = %e, "failed to record audit entry"); } + } - match state.storage.batch_delete_media(&media_ids).await { - Ok(count) => Ok(Json(BatchOperationResponse { - processed: count as usize, - errors: Vec::new(), - })), - Err(e) => Ok(Json(BatchOperationResponse { - processed: 0, - errors: vec![e.to_string()], - })), - } + match state.storage.batch_delete_media(&media_ids).await { + Ok(count) => { + Ok(Json(BatchOperationResponse { + processed: count as usize, + errors: Vec::new(), + })) + }, + Err(e) => { + Ok(Json(BatchOperationResponse { + processed: 0, + errors: vec![e.to_string()], + })) + }, + } } pub async fn batch_add_to_collection( - State(state): State, - Json(req): Json, + State(state): State, + Json(req): Json, ) -> Result, ApiError> { - if req.media_ids.len() > 10_000 { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( - "batch size exceeds limit of 10000".into(), - ), - )); - } + if req.media_ids.len() > 10_000 { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "batch size exceeds limit of 10000".into(), + ), + )); + } - let mut processed = 0; - let mut errors = Vec::new(); - for (i, media_id) in req.media_ids.iter().enumerate() { - match pinakes_core::collections::add_member( - &state.storage, - req.collection_id, - MediaId(*media_id), - i as i32, - ) - .await - { - Ok(_) => processed += 1, - Err(e) => errors.push(format!("{media_id}: {e}")), - } + let mut processed = 0; + let mut errors = Vec::new(); + for (i, media_id) in req.media_ids.iter().enumerate() { + match pinakes_core::collections::add_member( + &state.storage, + req.collection_id, + MediaId(*media_id), + i as i32, + ) + .await + { + Ok(_) => processed += 1, + Err(e) => errors.push(format!("{media_id}: {e}")), } - Ok(Json(BatchOperationResponse { processed, errors })) + } + Ok(Json(BatchOperationResponse { processed, errors })) } pub async fn batch_update( - State(state): State, - Json(req): Json, + State(state): State, + Json(req): Json, ) -> Result, ApiError> { - if req.media_ids.len() > 10_000 { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( - "batch size exceeds limit of 10000".into(), - ), - )); - } + if req.media_ids.len() > 10_000 { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "batch size exceeds limit of 10000".into(), + ), + )); + } - let media_ids: Vec = req.media_ids.iter().map(|id| MediaId(*id)).collect(); - match state - .storage - .batch_update_media( - &media_ids, - req.title.as_deref(), - req.artist.as_deref(), - req.album.as_deref(), - req.genre.as_deref(), - req.year, - req.description.as_deref(), - ) - .await - { - Ok(count) => Ok(Json(BatchOperationResponse { - processed: count as usize, - errors: Vec::new(), - })), - Err(e) => Ok(Json(BatchOperationResponse { - processed: 0, - errors: vec![e.to_string()], - })), - } + let media_ids: Vec = + req.media_ids.iter().map(|id| MediaId(*id)).collect(); + match state + .storage + .batch_update_media( + &media_ids, + req.title.as_deref(), + req.artist.as_deref(), + req.album.as_deref(), + req.genre.as_deref(), + req.year, + req.description.as_deref(), + ) + .await + { + Ok(count) => { + Ok(Json(BatchOperationResponse { + processed: count as usize, + errors: Vec::new(), + })) + }, + Err(e) => { + Ok(Json(BatchOperationResponse { + processed: 0, + errors: vec![e.to_string()], + })) + }, + } } pub async fn get_thumbnail( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result { - use axum::body::Body; - use axum::http::header; - use tokio_util::io::ReaderStream; + use axum::{body::Body, http::header}; + use tokio_util::io::ReaderStream; - let item = state.storage.get_media(MediaId(id)).await?; + let item = state.storage.get_media(MediaId(id)).await?; - let thumb_path = item.thumbnail_path.ok_or_else(|| { - ApiError(pinakes_core::error::PinakesError::NotFound( - "no thumbnail available".into(), - )) - })?; + let thumb_path = item.thumbnail_path.ok_or_else(|| { + ApiError(pinakes_core::error::PinakesError::NotFound( + "no thumbnail available".into(), + )) + })?; - let file = tokio::fs::File::open(&thumb_path) - .await - .map_err(|_e| ApiError(pinakes_core::error::PinakesError::FileNotFound(thumb_path)))?; + let file = tokio::fs::File::open(&thumb_path).await.map_err(|_e| { + ApiError(pinakes_core::error::PinakesError::FileNotFound(thumb_path)) + })?; - let stream = ReaderStream::new(file); - let body = Body::from_stream(stream); + let stream = ReaderStream::new(file); + let body = Body::from_stream(stream); - axum::response::Response::builder() - .header(header::CONTENT_TYPE, "image/jpeg") - .header(header::CACHE_CONTROL, "public, max-age=86400") - .body(body) - .map_err(|e| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - format!("failed to build response: {e}"), - )) - }) + axum::response::Response::builder() + .header(header::CONTENT_TYPE, "image/jpeg") + .header(header::CACHE_CONTROL, "public, max-age=86400") + .body(body) + .map_err(|e| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + format!("failed to build response: {e}"), + )) + }) } pub async fn get_media_count( - State(state): State, + State(state): State, ) -> Result, ApiError> { - let count = state.storage.count_media().await?; - Ok(Json(MediaCountResponse { count })) + let count = state.storage.count_media().await?; + Ok(Json(MediaCountResponse { count })) } // ===== File Management Endpoints ===== pub async fn rename_media( - State(state): State, - Path(id): Path, - Json(req): Json, + State(state): State, + Path(id): Path, + Json(req): Json, ) -> Result, ApiError> { - let media_id = MediaId(id); + let media_id = MediaId(id); - // Perform the rename - let old_path = state.storage.rename_media(media_id, &req.new_name).await?; + // Perform the rename + let old_path = state.storage.rename_media(media_id, &req.new_name).await?; - // Record in sync log - let item = state.storage.get_media(media_id).await?; - let change = pinakes_core::sync::SyncLogEntry { - id: uuid::Uuid::now_v7(), - sequence: 0, - change_type: pinakes_core::sync::SyncChangeType::Moved, - media_id: Some(media_id), - path: item.path.to_string_lossy().to_string(), - content_hash: Some(item.content_hash.clone()), - file_size: Some(item.file_size), - metadata_json: Some(serde_json::json!({ "old_path": old_path }).to_string()), - changed_by_device: None, - timestamp: chrono::Utc::now(), - }; - let _ = state.storage.record_sync_change(&change).await; + // Record in sync log + let item = state.storage.get_media(media_id).await?; + let change = pinakes_core::sync::SyncLogEntry { + id: uuid::Uuid::now_v7(), + sequence: 0, + change_type: pinakes_core::sync::SyncChangeType::Moved, + media_id: Some(media_id), + path: item.path.to_string_lossy().to_string(), + content_hash: Some(item.content_hash.clone()), + file_size: Some(item.file_size), + metadata_json: Some( + serde_json::json!({ "old_path": old_path }).to_string(), + ), + changed_by_device: None, + timestamp: chrono::Utc::now(), + }; + let _ = state.storage.record_sync_change(&change).await; - // Record audit - pinakes_core::audit::record_action( - &state.storage, - Some(media_id), - pinakes_core::model::AuditAction::Updated, - Some(format!("renamed from {} to {}", old_path, req.new_name)), - ) - .await?; + // Record audit + pinakes_core::audit::record_action( + &state.storage, + Some(media_id), + pinakes_core::model::AuditAction::Updated, + Some(format!("renamed from {} to {}", old_path, req.new_name)), + ) + .await?; - Ok(Json(MediaResponse::from(item))) + Ok(Json(MediaResponse::from(item))) } pub async fn move_media_endpoint( - State(state): State, - Path(id): Path, - Json(req): Json, + State(state): State, + Path(id): Path, + Json(req): Json, ) -> Result, ApiError> { - let media_id = MediaId(id); + let media_id = MediaId(id); - // Perform the move - let old_path = state.storage.move_media(media_id, &req.destination).await?; + // Perform the move + let old_path = state.storage.move_media(media_id, &req.destination).await?; - // Record in sync log - let item = state.storage.get_media(media_id).await?; - let change = pinakes_core::sync::SyncLogEntry { - id: uuid::Uuid::now_v7(), - sequence: 0, - change_type: pinakes_core::sync::SyncChangeType::Moved, - media_id: Some(media_id), - path: item.path.to_string_lossy().to_string(), - content_hash: Some(item.content_hash.clone()), - file_size: Some(item.file_size), - metadata_json: Some(serde_json::json!({ "old_path": old_path }).to_string()), - changed_by_device: None, - timestamp: chrono::Utc::now(), - }; - let _ = state.storage.record_sync_change(&change).await; + // Record in sync log + let item = state.storage.get_media(media_id).await?; + let change = pinakes_core::sync::SyncLogEntry { + id: uuid::Uuid::now_v7(), + sequence: 0, + change_type: pinakes_core::sync::SyncChangeType::Moved, + media_id: Some(media_id), + path: item.path.to_string_lossy().to_string(), + content_hash: Some(item.content_hash.clone()), + file_size: Some(item.file_size), + metadata_json: Some( + serde_json::json!({ "old_path": old_path }).to_string(), + ), + changed_by_device: None, + timestamp: chrono::Utc::now(), + }; + let _ = state.storage.record_sync_change(&change).await; - // Record audit - pinakes_core::audit::record_action( - &state.storage, - Some(media_id), - pinakes_core::model::AuditAction::Updated, - Some(format!( - "moved from {} to {}", - old_path, - req.destination.display() - )), - ) - .await?; + // Record audit + pinakes_core::audit::record_action( + &state.storage, + Some(media_id), + pinakes_core::model::AuditAction::Updated, + Some(format!( + "moved from {} to {}", + old_path, + req.destination.display() + )), + ) + .await?; - Ok(Json(MediaResponse::from(item))) + Ok(Json(MediaResponse::from(item))) } pub async fn batch_move_media( - State(state): State, - Json(req): Json, + State(state): State, + Json(req): Json, ) -> Result, ApiError> { - if req.media_ids.len() > 10_000 { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( - "batch size exceeds limit of 10000".into(), + if req.media_ids.len() > 10_000 { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "batch size exceeds limit of 10000".into(), + ), + )); + } + + let media_ids: Vec = + req.media_ids.iter().map(|id| MediaId(*id)).collect(); + + match state + .storage + .batch_move_media(&media_ids, &req.destination) + .await + { + Ok(results) => { + // Record sync changes for each moved item + for (media_id, old_path) in &results { + if let Ok(item) = state.storage.get_media(*media_id).await { + let change = pinakes_core::sync::SyncLogEntry { + id: uuid::Uuid::now_v7(), + sequence: 0, + change_type: pinakes_core::sync::SyncChangeType::Moved, + media_id: Some(*media_id), + path: item.path.to_string_lossy().to_string(), + content_hash: Some(item.content_hash.clone()), + file_size: Some(item.file_size), + metadata_json: Some( + serde_json::json!({ "old_path": old_path }).to_string(), ), - )); - } - - let media_ids: Vec = req.media_ids.iter().map(|id| MediaId(*id)).collect(); - - match state - .storage - .batch_move_media(&media_ids, &req.destination) - .await - { - Ok(results) => { - // Record sync changes for each moved item - for (media_id, old_path) in &results { - if let Ok(item) = state.storage.get_media(*media_id).await { - let change = pinakes_core::sync::SyncLogEntry { - id: uuid::Uuid::now_v7(), - sequence: 0, - change_type: pinakes_core::sync::SyncChangeType::Moved, - media_id: Some(*media_id), - path: item.path.to_string_lossy().to_string(), - content_hash: Some(item.content_hash.clone()), - file_size: Some(item.file_size), - metadata_json: Some( - serde_json::json!({ "old_path": old_path }).to_string(), - ), - changed_by_device: None, - timestamp: chrono::Utc::now(), - }; - let _ = state.storage.record_sync_change(&change).await; - } - } - - Ok(Json(BatchOperationResponse { - processed: results.len(), - errors: Vec::new(), - })) + changed_by_device: None, + timestamp: chrono::Utc::now(), + }; + let _ = state.storage.record_sync_change(&change).await; } - Err(e) => Ok(Json(BatchOperationResponse { - processed: 0, - errors: vec![e.to_string()], - })), - } + } + + Ok(Json(BatchOperationResponse { + processed: results.len(), + errors: Vec::new(), + })) + }, + Err(e) => { + Ok(Json(BatchOperationResponse { + processed: 0, + errors: vec![e.to_string()], + })) + }, + } } // ===== Trash Endpoints ===== pub async fn soft_delete_media( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result, ApiError> { - let media_id = MediaId(id); + let media_id = MediaId(id); - // Get item info before soft delete - let item = state.storage.get_media(media_id).await?; + // Get item info before soft delete + let item = state.storage.get_media(media_id).await?; - // Perform soft delete - state.storage.soft_delete_media(media_id).await?; + // Perform soft delete + state.storage.soft_delete_media(media_id).await?; - // Record in sync log - let change = pinakes_core::sync::SyncLogEntry { - id: uuid::Uuid::now_v7(), - sequence: 0, - change_type: pinakes_core::sync::SyncChangeType::Deleted, - media_id: Some(media_id), - path: item.path.to_string_lossy().to_string(), - content_hash: Some(item.content_hash.clone()), - file_size: Some(item.file_size), - metadata_json: None, - changed_by_device: None, - timestamp: chrono::Utc::now(), - }; - let _ = state.storage.record_sync_change(&change).await; + // Record in sync log + let change = pinakes_core::sync::SyncLogEntry { + id: uuid::Uuid::now_v7(), + sequence: 0, + change_type: pinakes_core::sync::SyncChangeType::Deleted, + media_id: Some(media_id), + path: item.path.to_string_lossy().to_string(), + content_hash: Some(item.content_hash.clone()), + file_size: Some(item.file_size), + metadata_json: None, + changed_by_device: None, + timestamp: chrono::Utc::now(), + }; + let _ = state.storage.record_sync_change(&change).await; - // Record audit - pinakes_core::audit::record_action( - &state.storage, - Some(media_id), - pinakes_core::model::AuditAction::Deleted, - Some("moved to trash".to_string()), - ) - .await?; + // Record audit + pinakes_core::audit::record_action( + &state.storage, + Some(media_id), + pinakes_core::model::AuditAction::Deleted, + Some("moved to trash".to_string()), + ) + .await?; - Ok(Json(serde_json::json!({"deleted": true, "trashed": true}))) + Ok(Json(serde_json::json!({"deleted": true, "trashed": true}))) } pub async fn restore_media( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result, ApiError> { - let media_id = MediaId(id); + let media_id = MediaId(id); - // Perform restore - state.storage.restore_media(media_id).await?; + // Perform restore + state.storage.restore_media(media_id).await?; - // Get updated item - let item = state.storage.get_media(media_id).await?; + // Get updated item + let item = state.storage.get_media(media_id).await?; - // Record in sync log - let change = pinakes_core::sync::SyncLogEntry { - id: uuid::Uuid::now_v7(), - sequence: 0, - change_type: pinakes_core::sync::SyncChangeType::Created, - media_id: Some(media_id), - path: item.path.to_string_lossy().to_string(), - content_hash: Some(item.content_hash.clone()), - file_size: Some(item.file_size), - metadata_json: None, - changed_by_device: None, - timestamp: chrono::Utc::now(), - }; - let _ = state.storage.record_sync_change(&change).await; + // Record in sync log + let change = pinakes_core::sync::SyncLogEntry { + id: uuid::Uuid::now_v7(), + sequence: 0, + change_type: pinakes_core::sync::SyncChangeType::Created, + media_id: Some(media_id), + path: item.path.to_string_lossy().to_string(), + content_hash: Some(item.content_hash.clone()), + file_size: Some(item.file_size), + metadata_json: None, + changed_by_device: None, + timestamp: chrono::Utc::now(), + }; + let _ = state.storage.record_sync_change(&change).await; - // Record audit - pinakes_core::audit::record_action( - &state.storage, - Some(media_id), - pinakes_core::model::AuditAction::Updated, - Some("restored from trash".to_string()), - ) - .await?; + // Record audit + pinakes_core::audit::record_action( + &state.storage, + Some(media_id), + pinakes_core::model::AuditAction::Updated, + Some("restored from trash".to_string()), + ) + .await?; - Ok(Json(MediaResponse::from(item))) + Ok(Json(MediaResponse::from(item))) } pub async fn list_trash( - State(state): State, - Query(params): Query, + State(state): State, + Query(params): Query, ) -> Result, ApiError> { - let pagination = Pagination::new( - params.offset.unwrap_or(0), - params.limit.unwrap_or(50).min(1000), - params.sort, - ); + let pagination = Pagination::new( + params.offset.unwrap_or(0), + params.limit.unwrap_or(50).min(1000), + params.sort, + ); - let items = state.storage.list_trash(&pagination).await?; - let count = state.storage.count_trash().await?; + let items = state.storage.list_trash(&pagination).await?; + let count = state.storage.count_trash().await?; - Ok(Json(TrashResponse { - items: items.into_iter().map(MediaResponse::from).collect(), - total_count: count, - })) + Ok(Json(TrashResponse { + items: items.into_iter().map(MediaResponse::from).collect(), + total_count: count, + })) } pub async fn trash_info( - State(state): State, + State(state): State, ) -> Result, ApiError> { - let count = state.storage.count_trash().await?; - Ok(Json(TrashInfoResponse { count })) + let count = state.storage.count_trash().await?; + Ok(Json(TrashInfoResponse { count })) } pub async fn empty_trash( - State(state): State, + State(state): State, ) -> Result, ApiError> { - // Record audit before emptying - pinakes_core::audit::record_action( - &state.storage, - None, - pinakes_core::model::AuditAction::Deleted, - Some("emptied trash".to_string()), - ) - .await?; + // Record audit before emptying + pinakes_core::audit::record_action( + &state.storage, + None, + pinakes_core::model::AuditAction::Deleted, + Some("emptied trash".to_string()), + ) + .await?; - let deleted_count = state.storage.empty_trash().await?; + let deleted_count = state.storage.empty_trash().await?; - Ok(Json(EmptyTrashResponse { deleted_count })) + Ok(Json(EmptyTrashResponse { deleted_count })) } pub async fn permanent_delete_media( - State(state): State, - Path(id): Path, - Query(params): Query>, + State(state): State, + Path(id): Path, + Query(params): Query>, ) -> Result, ApiError> { - let media_id = MediaId(id); - let permanent = params - .get("permanent") - .map(|v| v == "true") - .unwrap_or(false); + let media_id = MediaId(id); + let permanent = params + .get("permanent") + .map(|v| v == "true") + .unwrap_or(false); - if permanent { - // Get item info before delete - let item = state.storage.get_media(media_id).await?; + if permanent { + // Get item info before delete + let item = state.storage.get_media(media_id).await?; - // Record audit BEFORE delete - pinakes_core::audit::record_action( - &state.storage, - Some(media_id), - pinakes_core::model::AuditAction::Deleted, - Some("permanently deleted".to_string()), - ) - .await?; + // Record audit BEFORE delete + pinakes_core::audit::record_action( + &state.storage, + Some(media_id), + pinakes_core::model::AuditAction::Deleted, + Some("permanently deleted".to_string()), + ) + .await?; - // Perform hard delete - state.storage.delete_media(media_id).await?; + // Perform hard delete + state.storage.delete_media(media_id).await?; - // Record in sync log - let change = pinakes_core::sync::SyncLogEntry { - id: uuid::Uuid::now_v7(), - sequence: 0, - change_type: pinakes_core::sync::SyncChangeType::Deleted, - media_id: Some(media_id), - path: item.path.to_string_lossy().to_string(), - content_hash: Some(item.content_hash.clone()), - file_size: Some(item.file_size), - metadata_json: Some(serde_json::json!({"permanent": true}).to_string()), - changed_by_device: None, - timestamp: chrono::Utc::now(), - }; - let _ = state.storage.record_sync_change(&change).await; + // Record in sync log + let change = pinakes_core::sync::SyncLogEntry { + id: uuid::Uuid::now_v7(), + sequence: 0, + change_type: pinakes_core::sync::SyncChangeType::Deleted, + media_id: Some(media_id), + path: item.path.to_string_lossy().to_string(), + content_hash: Some(item.content_hash.clone()), + file_size: Some(item.file_size), + metadata_json: Some( + serde_json::json!({"permanent": true}).to_string(), + ), + changed_by_device: None, + timestamp: chrono::Utc::now(), + }; + let _ = state.storage.record_sync_change(&change).await; - // Clean up thumbnail - if let Some(ref thumb_path) = item.thumbnail_path - && let Err(e) = tokio::fs::remove_file(thumb_path).await - && e.kind() != std::io::ErrorKind::NotFound - { - tracing::warn!(path = %thumb_path.display(), error = %e, "failed to remove thumbnail"); - } - - Ok(Json( - serde_json::json!({"deleted": true, "permanent": true}), - )) - } else { - // Soft delete (move to trash) - soft_delete_media(State(state), Path(id)).await + // Clean up thumbnail + if let Some(ref thumb_path) = item.thumbnail_path + && let Err(e) = tokio::fs::remove_file(thumb_path).await + && e.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!(path = %thumb_path.display(), error = %e, "failed to remove thumbnail"); } + + Ok(Json( + serde_json::json!({"deleted": true, "permanent": true}), + )) + } else { + // Soft delete (move to trash) + soft_delete_media(State(state), Path(id)).await + } } diff --git a/crates/pinakes-server/src/routes/notes.rs b/crates/pinakes-server/src/routes/notes.rs index 114ff30..291a208 100644 --- a/crates/pinakes-server/src/routes/notes.rs +++ b/crates/pinakes-server/src/routes/notes.rs @@ -7,15 +7,22 @@ //! - Link reindexing use axum::{ - Json, Router, - extract::{Path, Query, State}, - routing::{get, post}, + Json, + Router, + extract::{Path, Query, State}, + routing::{get, post}, +}; +use pinakes_core::model::{ + BacklinkInfo, + GraphData, + GraphEdge, + GraphNode, + MarkdownLink, + MediaId, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use pinakes_core::model::{BacklinkInfo, GraphData, GraphEdge, GraphNode, MarkdownLink, MediaId}; - use crate::{error::ApiError, state::AppState}; // ===== Response DTOs ===== @@ -23,174 +30,174 @@ use crate::{error::ApiError, state::AppState}; /// Response for backlinks query #[derive(Debug, Serialize)] pub struct BacklinksResponse { - pub backlinks: Vec, - pub count: usize, + pub backlinks: Vec, + pub count: usize, } /// Individual backlink item #[derive(Debug, Serialize)] pub struct BacklinkItem { - pub link_id: Uuid, - pub source_id: Uuid, - pub source_title: Option, - pub source_path: String, - pub link_text: Option, - pub line_number: Option, - pub context: Option, - pub link_type: String, + pub link_id: Uuid, + pub source_id: Uuid, + pub source_title: Option, + pub source_path: String, + pub link_text: Option, + pub line_number: Option, + pub context: Option, + pub link_type: String, } impl From for BacklinkItem { - fn from(info: BacklinkInfo) -> Self { - Self { - link_id: info.link_id, - source_id: info.source_id.0, - source_title: info.source_title, - source_path: info.source_path, - link_text: info.link_text, - line_number: info.line_number, - context: info.context, - link_type: info.link_type.to_string(), - } + fn from(info: BacklinkInfo) -> Self { + Self { + link_id: info.link_id, + source_id: info.source_id.0, + source_title: info.source_title, + source_path: info.source_path, + link_text: info.link_text, + line_number: info.line_number, + context: info.context, + link_type: info.link_type.to_string(), } + } } /// Response for outgoing links query #[derive(Debug, Serialize)] pub struct OutgoingLinksResponse { - pub links: Vec, - pub count: usize, + pub links: Vec, + pub count: usize, } /// Individual outgoing link item #[derive(Debug, Serialize)] pub struct OutgoingLinkItem { - pub id: Uuid, - pub target_path: String, - pub target_id: Option, - pub link_text: Option, - pub line_number: Option, - pub link_type: String, - pub is_resolved: bool, + pub id: Uuid, + pub target_path: String, + pub target_id: Option, + pub link_text: Option, + pub line_number: Option, + pub link_type: String, + pub is_resolved: bool, } impl From for OutgoingLinkItem { - fn from(link: MarkdownLink) -> Self { - Self { - id: link.id, - target_path: link.target_path, - target_id: link.target_media_id.map(|id| id.0), - link_text: link.link_text, - line_number: link.line_number, - link_type: link.link_type.to_string(), - is_resolved: link.target_media_id.is_some(), - } + fn from(link: MarkdownLink) -> Self { + Self { + id: link.id, + target_path: link.target_path, + target_id: link.target_media_id.map(|id| id.0), + link_text: link.link_text, + line_number: link.line_number, + link_type: link.link_type.to_string(), + is_resolved: link.target_media_id.is_some(), } + } } /// Response for graph visualization #[derive(Debug, Serialize)] pub struct GraphResponse { - pub nodes: Vec, - pub edges: Vec, - pub node_count: usize, - pub edge_count: usize, + pub nodes: Vec, + pub edges: Vec, + pub node_count: usize, + pub edge_count: usize, } /// Graph node for visualization #[derive(Debug, Serialize)] pub struct GraphNodeResponse { - pub id: String, - pub label: String, - pub title: Option, - pub media_type: String, - pub link_count: u32, - pub backlink_count: u32, + pub id: String, + pub label: String, + pub title: Option, + pub media_type: String, + pub link_count: u32, + pub backlink_count: u32, } impl From for GraphNodeResponse { - fn from(node: GraphNode) -> Self { - Self { - id: node.id, - label: node.label, - title: node.title, - media_type: node.media_type, - link_count: node.link_count, - backlink_count: node.backlink_count, - } + fn from(node: GraphNode) -> Self { + Self { + id: node.id, + label: node.label, + title: node.title, + media_type: node.media_type, + link_count: node.link_count, + backlink_count: node.backlink_count, } + } } /// Graph edge for visualization #[derive(Debug, Serialize)] pub struct GraphEdgeResponse { - pub source: String, - pub target: String, - pub link_type: String, + pub source: String, + pub target: String, + pub link_type: String, } impl From for GraphEdgeResponse { - fn from(edge: GraphEdge) -> Self { - Self { - source: edge.source, - target: edge.target, - link_type: edge.link_type.to_string(), - } + fn from(edge: GraphEdge) -> Self { + Self { + source: edge.source, + target: edge.target, + link_type: edge.link_type.to_string(), } + } } impl From for GraphResponse { - fn from(data: GraphData) -> Self { - let node_count = data.nodes.len(); - let edge_count = data.edges.len(); - Self { - nodes: data - .nodes - .into_iter() - .map(GraphNodeResponse::from) - .collect(), - edges: data - .edges - .into_iter() - .map(GraphEdgeResponse::from) - .collect(), - node_count, - edge_count, - } + fn from(data: GraphData) -> Self { + let node_count = data.nodes.len(); + let edge_count = data.edges.len(); + Self { + nodes: data + .nodes + .into_iter() + .map(GraphNodeResponse::from) + .collect(), + edges: data + .edges + .into_iter() + .map(GraphEdgeResponse::from) + .collect(), + node_count, + edge_count, } + } } /// Query parameters for graph endpoint #[derive(Debug, Deserialize)] pub struct GraphQuery { - /// Center node ID (optional, if not provided returns entire graph) - pub center: Option, - /// Depth of traversal from center (default: 2, max: 5) - #[serde(default = "default_depth")] - pub depth: u32, + /// Center node ID (optional, if not provided returns entire graph) + pub center: Option, + /// Depth of traversal from center (default: 2, max: 5) + #[serde(default = "default_depth")] + pub depth: u32, } fn default_depth() -> u32 { - 2 + 2 } /// Response for reindex operation #[derive(Debug, Serialize)] pub struct ReindexResponse { - pub message: String, - pub links_extracted: usize, + pub message: String, + pub links_extracted: usize, } /// Response for link resolution #[derive(Debug, Serialize)] pub struct ResolveLinksResponse { - pub resolved_count: u64, + pub resolved_count: u64, } /// Response for unresolved links count #[derive(Debug, Serialize)] pub struct UnresolvedLinksResponse { - pub count: u64, + pub count: u64, } // ===== Handlers ===== @@ -199,129 +206,131 @@ pub struct UnresolvedLinksResponse { /// /// GET /api/v1/media/{id}/backlinks pub async fn get_backlinks( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result, ApiError> { - let media_id = MediaId(id); - let backlinks = state.storage.get_backlinks(media_id).await?; + let media_id = MediaId(id); + let backlinks = state.storage.get_backlinks(media_id).await?; - let items: Vec = backlinks.into_iter().map(BacklinkItem::from).collect(); - let count = items.len(); + let items: Vec = + backlinks.into_iter().map(BacklinkItem::from).collect(); + let count = items.len(); - Ok(Json(BacklinksResponse { - backlinks: items, - count, - })) + Ok(Json(BacklinksResponse { + backlinks: items, + count, + })) } /// Get outgoing links from a media item. /// /// GET /api/v1/media/{id}/outgoing-links pub async fn get_outgoing_links( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result, ApiError> { - let media_id = MediaId(id); - let links = state.storage.get_outgoing_links(media_id).await?; + let media_id = MediaId(id); + let links = state.storage.get_outgoing_links(media_id).await?; - let items: Vec = links.into_iter().map(OutgoingLinkItem::from).collect(); - let count = items.len(); + let items: Vec = + links.into_iter().map(OutgoingLinkItem::from).collect(); + let count = items.len(); - Ok(Json(OutgoingLinksResponse { - links: items, - count, - })) + Ok(Json(OutgoingLinksResponse { + links: items, + count, + })) } /// Get graph data for visualization. /// /// GET /api/v1/notes/graph?center={uuid}&depth={n} pub async fn get_graph( - State(state): State, - Query(params): Query, + State(state): State, + Query(params): Query, ) -> Result, ApiError> { - let center_id = params.center.map(MediaId); - let depth = params.depth.min(5); // Enforce max depth + let center_id = params.center.map(MediaId); + let depth = params.depth.min(5); // Enforce max depth - let graph_data = state.storage.get_graph_data(center_id, depth).await?; + let graph_data = state.storage.get_graph_data(center_id, depth).await?; - Ok(Json(GraphResponse::from(graph_data))) + Ok(Json(GraphResponse::from(graph_data))) } /// Re-extract links from a media item. /// /// POST /api/v1/media/{id}/reindex-links pub async fn reindex_links( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result, ApiError> { - let media_id = MediaId(id); + let media_id = MediaId(id); - // Get the media item to read its content - let media = state.storage.get_media(media_id).await?; + // Get the media item to read its content + let media = state.storage.get_media(media_id).await?; - // Only process markdown files - use pinakes_core::media_type::{BuiltinMediaType, MediaType}; - match &media.media_type { - MediaType::Builtin(BuiltinMediaType::Markdown) => {} - _ => { - return Ok(Json(ReindexResponse { - message: "Skipped: not a markdown file".to_string(), - links_extracted: 0, - })); - } - } + // Only process markdown files + use pinakes_core::media_type::{BuiltinMediaType, MediaType}; + match &media.media_type { + MediaType::Builtin(BuiltinMediaType::Markdown) => {}, + _ => { + return Ok(Json(ReindexResponse { + message: "Skipped: not a markdown file".to_string(), + links_extracted: 0, + })); + }, + } - // Read the file content - let content = tokio::fs::read_to_string(&media.path) - .await - .map_err(|e| ApiError::internal(format!("Failed to read file: {}", e)))?; + // Read the file content + let content = tokio::fs::read_to_string(&media.path) + .await + .map_err(|e| ApiError::internal(format!("Failed to read file: {}", e)))?; - // Extract links - let links = pinakes_core::links::extract_links(media_id, &content); - let links_count = links.len(); + // Extract links + let links = pinakes_core::links::extract_links(media_id, &content); + let links_count = links.len(); - // Save links to database - state.storage.save_markdown_links(media_id, &links).await?; + // Save links to database + state.storage.save_markdown_links(media_id, &links).await?; - // Mark as extracted - state.storage.mark_links_extracted(media_id).await?; + // Mark as extracted + state.storage.mark_links_extracted(media_id).await?; - // Try to resolve any unresolved links - state.storage.resolve_links().await?; + // Try to resolve any unresolved links + state.storage.resolve_links().await?; - Ok(Json(ReindexResponse { - message: "Links extracted successfully".to_string(), - links_extracted: links_count, - })) + Ok(Json(ReindexResponse { + message: "Links extracted successfully".to_string(), + links_extracted: links_count, + })) } /// Resolve all unresolved links in the database. /// /// POST /api/v1/notes/resolve-links pub async fn resolve_links( - State(state): State, + State(state): State, ) -> Result, ApiError> { - let resolved_count = state.storage.resolve_links().await?; + let resolved_count = state.storage.resolve_links().await?; - Ok(Json(ResolveLinksResponse { resolved_count })) + Ok(Json(ResolveLinksResponse { resolved_count })) } /// Get count of unresolved links. /// /// GET /api/v1/notes/unresolved-count pub async fn get_unresolved_count( - State(state): State, + State(state): State, ) -> Result, ApiError> { - let count = state.storage.count_unresolved_links().await?; + let count = state.storage.count_unresolved_links().await?; - Ok(Json(UnresolvedLinksResponse { count })) + Ok(Json(UnresolvedLinksResponse { count })) } /// Create the routes for notes/links functionality. pub fn routes() -> Router { - Router::new() - .route("/graph", get(get_graph)) - .route("/resolve-links", post(resolve_links)) - .route("/unresolved-count", get(get_unresolved_count)) + Router::new() + .route("/graph", get(get_graph)) + .route("/resolve-links", post(resolve_links)) + .route("/unresolved-count", get(get_unresolved_count)) } diff --git a/crates/pinakes-server/src/routes/photos.rs b/crates/pinakes-server/src/routes/photos.rs index b078527..02f7a50 100644 --- a/crates/pinakes-server/src/routes/photos.rs +++ b/crates/pinakes-server/src/routes/photos.rs @@ -1,12 +1,14 @@ +use std::collections::HashMap; + use axum::{ - Json, Router, - extract::{Query, State}, - response::IntoResponse, - routing::get, + Json, + Router, + extract::{Query, State}, + response::IntoResponse, + routing::get, }; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; use crate::{dto::MediaResponse, error::ApiError, state::AppState}; @@ -14,182 +16,186 @@ use crate::{dto::MediaResponse, error::ApiError, state::AppState}; #[derive(Debug, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum GroupBy { - #[default] - Day, - Month, - Year, + #[default] + Day, + Month, + Year, } /// Timeline query parameters #[derive(Debug, Deserialize)] pub struct TimelineQuery { - #[serde(default)] - pub group_by: GroupBy, - pub year: Option, - pub month: Option, - #[serde(default = "default_timeline_limit")] - pub limit: u64, + #[serde(default)] + pub group_by: GroupBy, + pub year: Option, + pub month: Option, + #[serde(default = "default_timeline_limit")] + pub limit: u64, } fn default_timeline_limit() -> u64 { - 10000 + 10000 } /// Timeline group response #[derive(Debug, Serialize)] pub struct TimelineGroup { - pub date: String, - pub count: usize, - pub cover_id: Option, - pub items: Vec, + pub date: String, + pub count: usize, + pub cover_id: Option, + pub items: Vec, } /// Map query parameters #[derive(Debug, Deserialize)] pub struct MapQuery { - pub lat1: f64, - pub lon1: f64, - pub lat2: f64, - pub lon2: f64, + pub lat1: f64, + pub lon1: f64, + pub lat2: f64, + pub lon2: f64, } /// Map marker response #[derive(Debug, Serialize)] pub struct MapMarker { - pub id: String, - pub latitude: f64, - pub longitude: f64, - pub thumbnail_url: Option, - pub date_taken: Option>, + pub id: String, + pub latitude: f64, + pub longitude: f64, + pub thumbnail_url: Option, + pub date_taken: Option>, } /// Get timeline of photos grouped by date pub async fn get_timeline( - State(state): State, - Query(query): Query, + State(state): State, + Query(query): Query, ) -> Result { - // Query photos with date_taken (limit is configurable, defaults to 10000) - let all_media = state - .storage - .list_media(&pinakes_core::model::Pagination { - offset: 0, - limit: query.limit.min(50000), // Cap at 50000 for safety - sort: Some("date_taken DESC".to_string()), - }) - .await?; + // Query photos with date_taken (limit is configurable, defaults to 10000) + let all_media = state + .storage + .list_media(&pinakes_core::model::Pagination { + offset: 0, + limit: query.limit.min(50000), // Cap at 50000 for safety + sort: Some("date_taken DESC".to_string()), + }) + .await?; - // Filter to only photos with date_taken - let photos: Vec<_> = all_media - .into_iter() - .filter(|item| { - item.date_taken.is_some() - && item.media_type.category() == pinakes_core::media_type::MediaCategory::Image - }) - .collect(); + // Filter to only photos with date_taken + let photos: Vec<_> = all_media + .into_iter() + .filter(|item| { + item.date_taken.is_some() + && item.media_type.category() + == pinakes_core::media_type::MediaCategory::Image + }) + .collect(); - // Group by the requested period - let mut groups: HashMap> = HashMap::new(); + // Group by the requested period + let mut groups: HashMap> = + HashMap::new(); - for photo in photos { - if let Some(date_taken) = photo.date_taken { - use chrono::Datelike; + for photo in photos { + if let Some(date_taken) = photo.date_taken { + use chrono::Datelike; - // Filter by year/month if specified - if let Some(y) = query.year - && date_taken.year() != y - { - continue; - } - if let Some(m) = query.month - && date_taken.month() != m - { - continue; - } + // Filter by year/month if specified + if let Some(y) = query.year + && date_taken.year() != y + { + continue; + } + if let Some(m) = query.month + && date_taken.month() != m + { + continue; + } - let key = match query.group_by { - GroupBy::Day => date_taken.format("%Y-%m-%d").to_string(), - GroupBy::Month => date_taken.format("%Y-%m").to_string(), - GroupBy::Year => date_taken.format("%Y").to_string(), - }; + let key = match query.group_by { + GroupBy::Day => date_taken.format("%Y-%m-%d").to_string(), + GroupBy::Month => date_taken.format("%Y-%m").to_string(), + GroupBy::Year => date_taken.format("%Y").to_string(), + }; - groups.entry(key).or_default().push(photo); - } + groups.entry(key).or_default().push(photo); } + } - // Convert to response format - let mut timeline: Vec = groups - .into_iter() - .map(|(date, items)| { - let cover_id = items.first().map(|i| i.id.0.to_string()); - let count = items.len(); - let items: Vec = items.into_iter().map(MediaResponse::from).collect(); + // Convert to response format + let mut timeline: Vec = groups + .into_iter() + .map(|(date, items)| { + let cover_id = items.first().map(|i| i.id.0.to_string()); + let count = items.len(); + let items: Vec = + items.into_iter().map(MediaResponse::from).collect(); - TimelineGroup { - date, - count, - cover_id, - items, - } - }) - .collect(); + TimelineGroup { + date, + count, + cover_id, + items, + } + }) + .collect(); - // Sort by date descending - timeline.sort_by(|a, b| b.date.cmp(&a.date)); + // Sort by date descending + timeline.sort_by(|a, b| b.date.cmp(&a.date)); - Ok(Json(timeline)) + Ok(Json(timeline)) } /// Get photos in a bounding box for map view pub async fn get_map_photos( - State(state): State, - Query(query): Query, + State(state): State, + Query(query): Query, ) -> Result { - // Validate bounding box - let min_lat = query.lat1.min(query.lat2); - let max_lat = query.lat1.max(query.lat2); - let min_lon = query.lon1.min(query.lon2); - let max_lon = query.lon1.max(query.lon2); + // Validate bounding box + let min_lat = query.lat1.min(query.lat2); + let max_lat = query.lat1.max(query.lat2); + let min_lon = query.lon1.min(query.lon2); + let max_lon = query.lon1.max(query.lon2); - // Query all media (we'll filter in-memory for now - could optimize with DB query) - let all_media = state - .storage - .list_media(&pinakes_core::model::Pagination { - offset: 0, - limit: 10000, - sort: None, - }) - .await?; + // Query all media (we'll filter in-memory for now - could optimize with DB + // query) + let all_media = state + .storage + .list_media(&pinakes_core::model::Pagination { + offset: 0, + limit: 10000, + sort: None, + }) + .await?; - // Filter to photos with GPS coordinates in the bounding box - let markers: Vec = all_media - .into_iter() - .filter_map(|item| { - if let (Some(lat), Some(lon)) = (item.latitude, item.longitude) - && lat >= min_lat - && lat <= max_lat - && lon >= min_lon - && lon <= max_lon - { - return Some(MapMarker { - id: item.id.0.to_string(), - latitude: lat, - longitude: lon, - thumbnail_url: item - .thumbnail_path - .map(|_p| format!("/api/v1/media/{}/thumbnail", item.id.0)), - date_taken: item.date_taken, - }); - } - None - }) - .collect(); + // Filter to photos with GPS coordinates in the bounding box + let markers: Vec = all_media + .into_iter() + .filter_map(|item| { + if let (Some(lat), Some(lon)) = (item.latitude, item.longitude) + && lat >= min_lat + && lat <= max_lat + && lon >= min_lon + && lon <= max_lon + { + return Some(MapMarker { + id: item.id.0.to_string(), + latitude: lat, + longitude: lon, + thumbnail_url: item + .thumbnail_path + .map(|_p| format!("/api/v1/media/{}/thumbnail", item.id.0)), + date_taken: item.date_taken, + }); + } + None + }) + .collect(); - Ok(Json(markers)) + Ok(Json(markers)) } /// Photo routes pub fn routes() -> Router { - Router::new() - .route("/timeline", get(get_timeline)) - .route("/map", get(get_map_photos)) + Router::new() + .route("/timeline", get(get_timeline)) + .route("/map", get(get_map_photos)) } diff --git a/crates/pinakes-server/src/routes/playlists.rs b/crates/pinakes-server/src/routes/playlists.rs index e539053..b36a441 100644 --- a/crates/pinakes-server/src/routes/playlists.rs +++ b/crates/pinakes-server/src/routes/playlists.rs @@ -1,15 +1,11 @@ -use axum::Json; -use axum::extract::{Extension, Path, State}; +use axum::{ + Json, + extract::{Extension, Path, State}, +}; +use pinakes_core::{model::MediaId, playlists::Playlist, users::UserId}; use uuid::Uuid; -use crate::auth::resolve_user_id; -use crate::dto::*; -use crate::error::ApiError; -use crate::state::AppState; - -use pinakes_core::model::MediaId; -use pinakes_core::playlists::Playlist; -use pinakes_core::users::UserId; +use crate::{auth::resolve_user_id, dto::*, error::ApiError, state::AppState}; /// Check whether a user has access to a playlist. /// @@ -17,193 +13,194 @@ use pinakes_core::users::UserId; /// mutations such as update, delete, add/remove/reorder items). When `false` /// the playlist must either be public or owned by the requesting user. async fn check_playlist_access( - storage: &pinakes_core::storage::DynStorageBackend, - playlist_id: Uuid, - user_id: UserId, - require_write: bool, + storage: &pinakes_core::storage::DynStorageBackend, + playlist_id: Uuid, + user_id: UserId, + require_write: bool, ) -> Result { - let playlist = storage.get_playlist(playlist_id).await.map_err(ApiError)?; - if require_write { - // Write operations require ownership - if playlist.owner_id != user_id { - return Err(ApiError(pinakes_core::error::PinakesError::Authorization( - "only the playlist owner can modify this playlist".into(), - ))); - } - } else { - // Read operations: allow if public or owner - if !playlist.is_public && playlist.owner_id != user_id { - return Err(ApiError(pinakes_core::error::PinakesError::Authorization( - "playlist is private".into(), - ))); - } + let playlist = storage.get_playlist(playlist_id).await.map_err(ApiError)?; + if require_write { + // Write operations require ownership + if playlist.owner_id != user_id { + return Err(ApiError(pinakes_core::error::PinakesError::Authorization( + "only the playlist owner can modify this playlist".into(), + ))); } - Ok(playlist) + } else { + // Read operations: allow if public or owner + if !playlist.is_public && playlist.owner_id != user_id { + return Err(ApiError(pinakes_core::error::PinakesError::Authorization( + "playlist is private".into(), + ))); + } + } + Ok(playlist) } pub async fn create_playlist( - State(state): State, - Extension(username): Extension, - Json(req): Json, + State(state): State, + Extension(username): Extension, + Json(req): Json, ) -> Result, ApiError> { - if req.name.is_empty() || req.name.chars().count() > 255 { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( - "playlist name must be 1-255 characters".into(), - ), - )); - } - let owner_id = resolve_user_id(&state.storage, &username).await?; - let playlist = state - .storage - .create_playlist( - owner_id, - &req.name, - req.description.as_deref(), - req.is_public.unwrap_or(false), - req.is_smart.unwrap_or(false), - req.filter_query.as_deref(), - ) - .await?; - Ok(Json(PlaylistResponse::from(playlist))) + if req.name.is_empty() || req.name.chars().count() > 255 { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "playlist name must be 1-255 characters".into(), + ), + )); + } + let owner_id = resolve_user_id(&state.storage, &username).await?; + let playlist = state + .storage + .create_playlist( + owner_id, + &req.name, + req.description.as_deref(), + req.is_public.unwrap_or(false), + req.is_smart.unwrap_or(false), + req.filter_query.as_deref(), + ) + .await?; + Ok(Json(PlaylistResponse::from(playlist))) } pub async fn list_playlists( - State(state): State, - Extension(username): Extension, + State(state): State, + Extension(username): Extension, ) -> Result>, ApiError> { - let user_id = resolve_user_id(&state.storage, &username).await?; - // Fetch all playlists and filter to only public ones plus the user's own - let playlists = state.storage.list_playlists(None).await?; - let visible: Vec = playlists - .into_iter() - .filter(|p| p.is_public || p.owner_id == user_id) - .map(PlaylistResponse::from) - .collect(); - Ok(Json(visible)) + let user_id = resolve_user_id(&state.storage, &username).await?; + // Fetch all playlists and filter to only public ones plus the user's own + let playlists = state.storage.list_playlists(None).await?; + let visible: Vec = playlists + .into_iter() + .filter(|p| p.is_public || p.owner_id == user_id) + .map(PlaylistResponse::from) + .collect(); + Ok(Json(visible)) } pub async fn get_playlist( - State(state): State, - Extension(username): Extension, - Path(id): Path, + State(state): State, + Extension(username): Extension, + Path(id): Path, ) -> Result, ApiError> { - let user_id = resolve_user_id(&state.storage, &username).await?; - let playlist = check_playlist_access(&state.storage, id, user_id, false).await?; - Ok(Json(PlaylistResponse::from(playlist))) + let user_id = resolve_user_id(&state.storage, &username).await?; + let playlist = + check_playlist_access(&state.storage, id, user_id, false).await?; + Ok(Json(PlaylistResponse::from(playlist))) } pub async fn update_playlist( - State(state): State, - Extension(username): Extension, - Path(id): Path, - Json(req): Json, + State(state): State, + Extension(username): Extension, + Path(id): Path, + Json(req): Json, ) -> Result, ApiError> { - if let Some(ref name) = req.name - && (name.is_empty() || name.chars().count() > 255) - { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( - "playlist name must be 1-255 characters".into(), - ), - )); - } - let user_id = resolve_user_id(&state.storage, &username).await?; - check_playlist_access(&state.storage, id, user_id, true).await?; - let playlist = state - .storage - .update_playlist( - id, - req.name.as_deref(), - req.description.as_deref(), - req.is_public, - ) - .await?; - Ok(Json(PlaylistResponse::from(playlist))) + if let Some(ref name) = req.name + && (name.is_empty() || name.chars().count() > 255) + { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "playlist name must be 1-255 characters".into(), + ), + )); + } + let user_id = resolve_user_id(&state.storage, &username).await?; + check_playlist_access(&state.storage, id, user_id, true).await?; + let playlist = state + .storage + .update_playlist( + id, + req.name.as_deref(), + req.description.as_deref(), + req.is_public, + ) + .await?; + Ok(Json(PlaylistResponse::from(playlist))) } pub async fn delete_playlist( - State(state): State, - Extension(username): Extension, - Path(id): Path, + State(state): State, + Extension(username): Extension, + Path(id): Path, ) -> Result, ApiError> { - let user_id = resolve_user_id(&state.storage, &username).await?; - check_playlist_access(&state.storage, id, user_id, true).await?; - state.storage.delete_playlist(id).await?; - Ok(Json(serde_json::json!({"deleted": true}))) + let user_id = resolve_user_id(&state.storage, &username).await?; + check_playlist_access(&state.storage, id, user_id, true).await?; + state.storage.delete_playlist(id).await?; + Ok(Json(serde_json::json!({"deleted": true}))) } pub async fn add_item( - State(state): State, - Extension(username): Extension, - Path(id): Path, - Json(req): Json, + State(state): State, + Extension(username): Extension, + Path(id): Path, + Json(req): Json, ) -> Result, ApiError> { - let user_id = resolve_user_id(&state.storage, &username).await?; - check_playlist_access(&state.storage, id, user_id, true).await?; - let position = match req.position { - Some(p) => p, - None => { - let items = state.storage.get_playlist_items(id).await?; - items.len() as i32 - } - }; - state - .storage - .add_to_playlist(id, MediaId(req.media_id), position) - .await?; - Ok(Json(serde_json::json!({"added": true}))) + let user_id = resolve_user_id(&state.storage, &username).await?; + check_playlist_access(&state.storage, id, user_id, true).await?; + let position = match req.position { + Some(p) => p, + None => { + let items = state.storage.get_playlist_items(id).await?; + items.len() as i32 + }, + }; + state + .storage + .add_to_playlist(id, MediaId(req.media_id), position) + .await?; + Ok(Json(serde_json::json!({"added": true}))) } pub async fn remove_item( - State(state): State, - Extension(username): Extension, - Path((id, media_id)): Path<(Uuid, Uuid)>, + State(state): State, + Extension(username): Extension, + Path((id, media_id)): Path<(Uuid, Uuid)>, ) -> Result, ApiError> { - let user_id = resolve_user_id(&state.storage, &username).await?; - check_playlist_access(&state.storage, id, user_id, true).await?; - state - .storage - .remove_from_playlist(id, MediaId(media_id)) - .await?; - Ok(Json(serde_json::json!({"removed": true}))) + let user_id = resolve_user_id(&state.storage, &username).await?; + check_playlist_access(&state.storage, id, user_id, true).await?; + state + .storage + .remove_from_playlist(id, MediaId(media_id)) + .await?; + Ok(Json(serde_json::json!({"removed": true}))) } pub async fn list_items( - State(state): State, - Extension(username): Extension, - Path(id): Path, + State(state): State, + Extension(username): Extension, + Path(id): Path, ) -> Result>, ApiError> { - let user_id = resolve_user_id(&state.storage, &username).await?; - check_playlist_access(&state.storage, id, user_id, false).await?; - let items = state.storage.get_playlist_items(id).await?; - Ok(Json(items.into_iter().map(MediaResponse::from).collect())) + let user_id = resolve_user_id(&state.storage, &username).await?; + check_playlist_access(&state.storage, id, user_id, false).await?; + let items = state.storage.get_playlist_items(id).await?; + Ok(Json(items.into_iter().map(MediaResponse::from).collect())) } pub async fn reorder_item( - State(state): State, - Extension(username): Extension, - Path(id): Path, - Json(req): Json, + State(state): State, + Extension(username): Extension, + Path(id): Path, + Json(req): Json, ) -> Result, ApiError> { - let user_id = resolve_user_id(&state.storage, &username).await?; - check_playlist_access(&state.storage, id, user_id, true).await?; - state - .storage - .reorder_playlist(id, MediaId(req.media_id), req.new_position) - .await?; - Ok(Json(serde_json::json!({"reordered": true}))) + let user_id = resolve_user_id(&state.storage, &username).await?; + check_playlist_access(&state.storage, id, user_id, true).await?; + state + .storage + .reorder_playlist(id, MediaId(req.media_id), req.new_position) + .await?; + Ok(Json(serde_json::json!({"reordered": true}))) } pub async fn shuffle_playlist( - State(state): State, - Extension(username): Extension, - Path(id): Path, + State(state): State, + Extension(username): Extension, + Path(id): Path, ) -> Result>, ApiError> { - let user_id = resolve_user_id(&state.storage, &username).await?; - check_playlist_access(&state.storage, id, user_id, false).await?; - use rand::seq::SliceRandom; - let mut items = state.storage.get_playlist_items(id).await?; - items.shuffle(&mut rand::rng()); - Ok(Json(items.into_iter().map(MediaResponse::from).collect())) + let user_id = resolve_user_id(&state.storage, &username).await?; + check_playlist_access(&state.storage, id, user_id, false).await?; + use rand::seq::SliceRandom; + let mut items = state.storage.get_playlist_items(id).await?; + items.shuffle(&mut rand::rng()); + Ok(Json(items.into_iter().map(MediaResponse::from).collect())) } diff --git a/crates/pinakes-server/src/routes/plugins.rs b/crates/pinakes-server/src/routes/plugins.rs index efcd217..a2a21a2 100644 --- a/crates/pinakes-server/src/routes/plugins.rs +++ b/crates/pinakes-server/src/routes/plugins.rs @@ -1,149 +1,151 @@ -use axum::Json; -use axum::extract::{Path, State}; +use axum::{ + Json, + extract::{Path, State}, +}; -use crate::dto::*; -use crate::error::ApiError; -use crate::state::AppState; +use crate::{dto::*, error::ApiError, state::AppState}; /// List all installed plugins pub async fn list_plugins( - State(state): State, + State(state): State, ) -> Result>, ApiError> { - let plugin_manager = state.plugin_manager.as_ref().ok_or_else(|| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - "Plugin system is not enabled".to_string(), - )) - })?; + let plugin_manager = state.plugin_manager.as_ref().ok_or_else(|| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + "Plugin system is not enabled".to_string(), + )) + })?; - let plugins = plugin_manager.list_plugins().await; - let mut responses = Vec::with_capacity(plugins.len()); - for meta in plugins { - let enabled = plugin_manager.is_plugin_enabled(&meta.id).await; - responses.push(PluginResponse::new(meta, enabled)); - } - Ok(Json(responses)) + let plugins = plugin_manager.list_plugins().await; + let mut responses = Vec::with_capacity(plugins.len()); + for meta in plugins { + let enabled = plugin_manager.is_plugin_enabled(&meta.id).await; + responses.push(PluginResponse::new(meta, enabled)); + } + Ok(Json(responses)) } /// Get a specific plugin by ID pub async fn get_plugin( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result, ApiError> { - let plugin_manager = state.plugin_manager.as_ref().ok_or_else(|| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - "Plugin system is not enabled".to_string(), - )) - })?; + let plugin_manager = state.plugin_manager.as_ref().ok_or_else(|| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + "Plugin system is not enabled".to_string(), + )) + })?; - let plugin = plugin_manager.get_plugin(&id).await.ok_or_else(|| { - ApiError(pinakes_core::error::PinakesError::NotFound(format!( - "Plugin not found: {}", - id - ))) - })?; + let plugin = plugin_manager.get_plugin(&id).await.ok_or_else(|| { + ApiError(pinakes_core::error::PinakesError::NotFound(format!( + "Plugin not found: {}", + id + ))) + })?; - let enabled = plugin_manager.is_plugin_enabled(&id).await; - Ok(Json(PluginResponse::new(plugin, enabled))) + let enabled = plugin_manager.is_plugin_enabled(&id).await; + Ok(Json(PluginResponse::new(plugin, enabled))) } /// Install a plugin from URL or file path pub async fn install_plugin( - State(state): State, - Json(req): Json, + State(state): State, + Json(req): Json, ) -> Result, ApiError> { - let plugin_manager = state.plugin_manager.as_ref().ok_or_else(|| { + let plugin_manager = state.plugin_manager.as_ref().ok_or_else(|| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + "Plugin system is not enabled".to_string(), + )) + })?; + + let plugin_id = + plugin_manager + .install_plugin(&req.source) + .await + .map_err(|e| { ApiError(pinakes_core::error::PinakesError::InvalidOperation( - "Plugin system is not enabled".to_string(), + format!("Failed to install plugin: {}", e), )) + })?; + + let plugin = + plugin_manager.get_plugin(&plugin_id).await.ok_or_else(|| { + ApiError(pinakes_core::error::PinakesError::NotFound( + "Plugin installed but not found".to_string(), + )) })?; - let plugin_id = plugin_manager - .install_plugin(&req.source) - .await - .map_err(|e| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - format!("Failed to install plugin: {}", e), - )) - })?; - - let plugin = plugin_manager.get_plugin(&plugin_id).await.ok_or_else(|| { - ApiError(pinakes_core::error::PinakesError::NotFound( - "Plugin installed but not found".to_string(), - )) - })?; - - let enabled = plugin_manager.is_plugin_enabled(&plugin_id).await; - Ok(Json(PluginResponse::new(plugin, enabled))) + let enabled = plugin_manager.is_plugin_enabled(&plugin_id).await; + Ok(Json(PluginResponse::new(plugin, enabled))) } /// Uninstall a plugin pub async fn uninstall_plugin( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result, ApiError> { - let plugin_manager = state.plugin_manager.as_ref().ok_or_else(|| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - "Plugin system is not enabled".to_string(), - )) - })?; + let plugin_manager = state.plugin_manager.as_ref().ok_or_else(|| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + "Plugin system is not enabled".to_string(), + )) + })?; - plugin_manager.uninstall_plugin(&id).await.map_err(|e| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - format!("Failed to uninstall plugin: {}", e), - )) - })?; + plugin_manager.uninstall_plugin(&id).await.map_err(|e| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + format!("Failed to uninstall plugin: {}", e), + )) + })?; - Ok(Json(serde_json::json!({"uninstalled": true}))) + Ok(Json(serde_json::json!({"uninstalled": true}))) } /// Enable or disable a plugin pub async fn toggle_plugin( - State(state): State, - Path(id): Path, - Json(req): Json, + State(state): State, + Path(id): Path, + Json(req): Json, ) -> Result, ApiError> { - let plugin_manager = state.plugin_manager.as_ref().ok_or_else(|| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - "Plugin system is not enabled".to_string(), - )) + let plugin_manager = state.plugin_manager.as_ref().ok_or_else(|| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + "Plugin system is not enabled".to_string(), + )) + })?; + + if req.enabled { + plugin_manager.enable_plugin(&id).await.map_err(|e| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + format!("Failed to enable plugin: {}", e), + )) })?; + } else { + plugin_manager.disable_plugin(&id).await.map_err(|e| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + format!("Failed to disable plugin: {}", e), + )) + })?; + } - if req.enabled { - plugin_manager.enable_plugin(&id).await.map_err(|e| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - format!("Failed to enable plugin: {}", e), - )) - })?; - } else { - plugin_manager.disable_plugin(&id).await.map_err(|e| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - format!("Failed to disable plugin: {}", e), - )) - })?; - } - - Ok(Json(serde_json::json!({ - "id": id, - "enabled": req.enabled - }))) + Ok(Json(serde_json::json!({ + "id": id, + "enabled": req.enabled + }))) } /// Reload a plugin (for development) pub async fn reload_plugin( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result, ApiError> { - let plugin_manager = state.plugin_manager.as_ref().ok_or_else(|| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - "Plugin system is not enabled".to_string(), - )) - })?; + let plugin_manager = state.plugin_manager.as_ref().ok_or_else(|| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + "Plugin system is not enabled".to_string(), + )) + })?; - plugin_manager.reload_plugin(&id).await.map_err(|e| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - format!("Failed to reload plugin: {}", e), - )) - })?; + plugin_manager.reload_plugin(&id).await.map_err(|e| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + format!("Failed to reload plugin: {}", e), + )) + })?; - Ok(Json(serde_json::json!({"reloaded": true}))) + Ok(Json(serde_json::json!({"reloaded": true}))) } diff --git a/crates/pinakes-server/src/routes/saved_searches.rs b/crates/pinakes-server/src/routes/saved_searches.rs index 98e3c59..c5e6e23 100644 --- a/crates/pinakes-server/src/routes/saved_searches.rs +++ b/crates/pinakes-server/src/routes/saved_searches.rs @@ -1,76 +1,79 @@ -use axum::Json; -use axum::extract::{Path, State}; +use axum::{ + Json, + extract::{Path, State}, +}; use serde::{Deserialize, Serialize}; -use crate::error::ApiError; -use crate::state::AppState; +use crate::{error::ApiError, state::AppState}; #[derive(Debug, Deserialize)] pub struct CreateSavedSearchRequest { - pub name: String, - pub query: String, - pub sort_order: Option, + pub name: String, + pub query: String, + pub sort_order: Option, } #[derive(Debug, Serialize)] pub struct SavedSearchResponse { - pub id: String, - pub name: String, - pub query: String, - pub sort_order: Option, - pub created_at: chrono::DateTime, + pub id: String, + pub name: String, + pub query: String, + pub sort_order: Option, + pub created_at: chrono::DateTime, } pub async fn create_saved_search( - State(state): State, - Json(req): Json, + State(state): State, + Json(req): Json, ) -> Result, ApiError> { - let id = uuid::Uuid::now_v7(); - state - .storage - .save_search(id, &req.name, &req.query, req.sort_order.as_deref()) - .await - .map_err(ApiError)?; + let id = uuid::Uuid::now_v7(); + state + .storage + .save_search(id, &req.name, &req.query, req.sort_order.as_deref()) + .await + .map_err(ApiError)?; - Ok(Json(SavedSearchResponse { - id: id.to_string(), - name: req.name, - query: req.query, - sort_order: req.sort_order, - created_at: chrono::Utc::now(), - })) + Ok(Json(SavedSearchResponse { + id: id.to_string(), + name: req.name, + query: req.query, + sort_order: req.sort_order, + created_at: chrono::Utc::now(), + })) } pub async fn list_saved_searches( - State(state): State, + State(state): State, ) -> Result>, ApiError> { - let searches = state - .storage - .list_saved_searches() - .await - .map_err(ApiError)?; - Ok(Json( - searches - .into_iter() - .map(|s| SavedSearchResponse { - id: s.id.to_string(), - name: s.name, - query: s.query, - sort_order: s.sort_order, - created_at: s.created_at, - }) - .collect(), - )) + let searches = state + .storage + .list_saved_searches() + .await + .map_err(ApiError)?; + Ok(Json( + searches + .into_iter() + .map(|s| { + SavedSearchResponse { + id: s.id.to_string(), + name: s.name, + query: s.query, + sort_order: s.sort_order, + created_at: s.created_at, + } + }) + .collect(), + )) } pub async fn delete_saved_search( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result, ApiError> { - state - .storage - .delete_saved_search(id) - .await - .map_err(ApiError)?; - Ok(Json(serde_json::json!({ "deleted": true }))) + state + .storage + .delete_saved_search(id) + .await + .map_err(ApiError)?; + Ok(Json(serde_json::json!({ "deleted": true }))) } diff --git a/crates/pinakes-server/src/routes/scan.rs b/crates/pinakes-server/src/routes/scan.rs index be2a192..6fc9f2c 100644 --- a/crates/pinakes-server/src/routes/scan.rs +++ b/crates/pinakes-server/src/routes/scan.rs @@ -1,30 +1,29 @@ -use axum::Json; -use axum::extract::State; +use axum::{Json, extract::State}; -use crate::dto::*; -use crate::error::ApiError; -use crate::state::AppState; +use crate::{dto::*, error::ApiError, state::AppState}; /// Trigger a scan as a background job. Returns the job ID immediately. pub async fn trigger_scan( - State(state): State, - Json(req): Json, + State(state): State, + Json(req): Json, ) -> Result, ApiError> { - let kind = pinakes_core::jobs::JobKind::Scan { path: req.path }; - let job_id = state.job_queue.submit(kind).await; - Ok(Json(ScanJobResponse { - job_id: job_id.to_string(), - })) + let kind = pinakes_core::jobs::JobKind::Scan { path: req.path }; + let job_id = state.job_queue.submit(kind).await; + Ok(Json(ScanJobResponse { + job_id: job_id.to_string(), + })) } -pub async fn scan_status(State(state): State) -> Json { - let snapshot = state.scan_progress.snapshot(); - let error_count = snapshot.errors.len(); - Json(ScanStatusResponse { - scanning: snapshot.scanning, - files_found: snapshot.files_found, - files_processed: snapshot.files_processed, - error_count, - errors: snapshot.errors, - }) +pub async fn scan_status( + State(state): State, +) -> Json { + let snapshot = state.scan_progress.snapshot(); + let error_count = snapshot.errors.len(); + Json(ScanStatusResponse { + scanning: snapshot.scanning, + files_found: snapshot.files_found, + files_processed: snapshot.files_processed, + error_count, + errors: snapshot.errors, + }) } diff --git a/crates/pinakes-server/src/routes/scheduled_tasks.rs b/crates/pinakes-server/src/routes/scheduled_tasks.rs index 6784a78..4d50f76 100644 --- a/crates/pinakes-server/src/routes/scheduled_tasks.rs +++ b/crates/pinakes-server/src/routes/scheduled_tasks.rs @@ -1,55 +1,65 @@ -use axum::Json; -use axum::extract::{Path, State}; +use axum::{ + Json, + extract::{Path, State}, +}; -use crate::dto::ScheduledTaskResponse; -use crate::error::ApiError; -use crate::state::AppState; +use crate::{dto::ScheduledTaskResponse, error::ApiError, state::AppState}; pub async fn list_scheduled_tasks( - State(state): State, + State(state): State, ) -> Result>, ApiError> { - let tasks = state.scheduler.list_tasks().await; - let responses: Vec = tasks - .into_iter() - .map(|t| ScheduledTaskResponse { - id: t.id, - name: t.name, - schedule: t.schedule.display_string(), - enabled: t.enabled, - last_run: t.last_run.map(|dt| dt.to_rfc3339()), - next_run: t.next_run.map(|dt| dt.to_rfc3339()), - last_status: t.last_status, - }) - .collect(); - Ok(Json(responses)) + let tasks = state.scheduler.list_tasks().await; + let responses: Vec = tasks + .into_iter() + .map(|t| { + ScheduledTaskResponse { + id: t.id, + name: t.name, + schedule: t.schedule.display_string(), + enabled: t.enabled, + last_run: t.last_run.map(|dt| dt.to_rfc3339()), + next_run: t.next_run.map(|dt| dt.to_rfc3339()), + last_status: t.last_status, + } + }) + .collect(); + Ok(Json(responses)) } pub async fn toggle_scheduled_task( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result, ApiError> { - match state.scheduler.toggle_task(&id).await { - Some(enabled) => Ok(Json(serde_json::json!({ - "id": id, - "enabled": enabled, - }))), - None => Err(ApiError(pinakes_core::error::PinakesError::NotFound( - format!("scheduled task not found: {id}"), - ))), - } + match state.scheduler.toggle_task(&id).await { + Some(enabled) => { + Ok(Json(serde_json::json!({ + "id": id, + "enabled": enabled, + }))) + }, + None => { + Err(ApiError(pinakes_core::error::PinakesError::NotFound( + format!("scheduled task not found: {id}"), + ))) + }, + } } pub async fn run_scheduled_task_now( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result, ApiError> { - match state.scheduler.run_now(&id).await { - Some(job_id) => Ok(Json(serde_json::json!({ - "id": id, - "job_id": job_id, - }))), - None => Err(ApiError(pinakes_core::error::PinakesError::NotFound( - format!("scheduled task not found: {id}"), - ))), - } + match state.scheduler.run_now(&id).await { + Some(job_id) => { + Ok(Json(serde_json::json!({ + "id": id, + "job_id": job_id, + }))) + }, + None => { + Err(ApiError(pinakes_core::error::PinakesError::NotFound( + format!("scheduled task not found: {id}"), + ))) + }, + } } diff --git a/crates/pinakes-server/src/routes/search.rs b/crates/pinakes-server/src/routes/search.rs index a86694a..7db6231 100644 --- a/crates/pinakes-server/src/routes/search.rs +++ b/crates/pinakes-server/src/routes/search.rs @@ -1,87 +1,88 @@ -use axum::Json; -use axum::extract::{Query, State}; +use axum::{ + Json, + extract::{Query, State}, +}; +use pinakes_core::{ + model::Pagination, + search::{SearchRequest, SortOrder, parse_search_query}, +}; -use crate::dto::*; -use crate::error::ApiError; -use crate::state::AppState; - -use pinakes_core::model::Pagination; -use pinakes_core::search::{SearchRequest, SortOrder, parse_search_query}; +use crate::{dto::*, error::ApiError, state::AppState}; fn resolve_sort(sort: Option<&str>) -> SortOrder { - match sort { - Some("date_asc") => SortOrder::DateAsc, - Some("date_desc") => SortOrder::DateDesc, - Some("name_asc") => SortOrder::NameAsc, - Some("name_desc") => SortOrder::NameDesc, - Some("size_asc") => SortOrder::SizeAsc, - Some("size_desc") => SortOrder::SizeDesc, - _ => SortOrder::Relevance, - } + match sort { + Some("date_asc") => SortOrder::DateAsc, + Some("date_desc") => SortOrder::DateDesc, + Some("name_asc") => SortOrder::NameAsc, + Some("name_desc") => SortOrder::NameDesc, + Some("size_asc") => SortOrder::SizeAsc, + Some("size_desc") => SortOrder::SizeDesc, + _ => SortOrder::Relevance, + } } pub async fn search( - State(state): State, - Query(params): Query, + State(state): State, + Query(params): Query, ) -> Result, ApiError> { - if params.q.len() > 2048 { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( - "search query exceeds maximum length of 2048 characters".into(), - ), - )); - } + if params.q.len() > 2048 { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "search query exceeds maximum length of 2048 characters".into(), + ), + )); + } - let query = parse_search_query(¶ms.q)?; - let sort = resolve_sort(params.sort.as_deref()); + let query = parse_search_query(¶ms.q)?; + let sort = resolve_sort(params.sort.as_deref()); - let request = SearchRequest { - query, - sort, - pagination: Pagination::new( - params.offset.unwrap_or(0), - params.limit.unwrap_or(50).min(1000), - None, - ), - }; + let request = SearchRequest { + query, + sort, + pagination: Pagination::new( + params.offset.unwrap_or(0), + params.limit.unwrap_or(50).min(1000), + None, + ), + }; - let results = state.storage.search(&request).await?; + let results = state.storage.search(&request).await?; - Ok(Json(SearchResponse { - items: results.items.into_iter().map(MediaResponse::from).collect(), - total_count: results.total_count, - })) + Ok(Json(SearchResponse { + items: results.items.into_iter().map(MediaResponse::from).collect(), + total_count: results.total_count, + })) } pub async fn search_post( - State(state): State, - Json(body): Json, + State(state): State, + Json(body): Json, ) -> Result, ApiError> { - if body.q.len() > 2048 { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( - "search query exceeds maximum length of 2048 characters".into(), - ), - )); - } + if body.q.len() > 2048 { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "search query exceeds maximum length of 2048 characters".into(), + ), + )); + } - let query = parse_search_query(&body.q)?; - let sort = resolve_sort(body.sort.as_deref()); + let query = parse_search_query(&body.q)?; + let sort = resolve_sort(body.sort.as_deref()); - let request = SearchRequest { - query, - sort, - pagination: Pagination::new( - body.offset.unwrap_or(0), - body.limit.unwrap_or(50).min(1000), - None, - ), - }; + let request = SearchRequest { + query, + sort, + pagination: Pagination::new( + body.offset.unwrap_or(0), + body.limit.unwrap_or(50).min(1000), + None, + ), + }; - let results = state.storage.search(&request).await?; + let results = state.storage.search(&request).await?; - Ok(Json(SearchResponse { - items: results.items.into_iter().map(MediaResponse::from).collect(), - total_count: results.total_count, - })) + Ok(Json(SearchResponse { + items: results.items.into_iter().map(MediaResponse::from).collect(), + total_count: results.total_count, + })) } diff --git a/crates/pinakes-server/src/routes/shares.rs b/crates/pinakes-server/src/routes/shares.rs index 58ad378..980f53e 100644 --- a/crates/pinakes-server/src/routes/shares.rs +++ b/crates/pinakes-server/src/routes/shares.rs @@ -1,543 +1,576 @@ +use std::net::SocketAddr; + use axum::{ - Json, - extract::{ConnectInfo, Extension, Path, Query, State}, - http::StatusCode, + Json, + extract::{ConnectInfo, Extension, Path, Query, State}, + http::StatusCode, }; use chrono::Utc; -use std::net::SocketAddr; +use pinakes_core::{ + model::{MediaId, Pagination}, + sharing::{ + Share, + ShareActivity, + ShareActivityAction, + ShareId, + ShareNotification, + ShareNotificationType, + SharePermissions, + ShareRecipient, + ShareTarget, + generate_share_token, + hash_share_password, + verify_share_password, + }, + users::UserId, +}; use uuid::Uuid; -use crate::auth::resolve_user_id; -use crate::dto::{ - AccessSharedRequest, BatchDeleteSharesRequest, CreateShareRequest, MediaResponse, - PaginationParams, ShareActivityResponse, ShareNotificationResponse, ShareResponse, +use crate::{ + auth::resolve_user_id, + dto::{ + AccessSharedRequest, + BatchDeleteSharesRequest, + CreateShareRequest, + MediaResponse, + PaginationParams, + ShareActivityResponse, + ShareNotificationResponse, + ShareResponse, UpdateShareRequest, + }, + error::{ApiError, ApiResult}, + state::AppState, }; -use crate::error::{ApiError, ApiResult}; -use crate::state::AppState; -use pinakes_core::model::MediaId; -use pinakes_core::model::Pagination; -use pinakes_core::sharing::{ - Share, ShareActivity, ShareActivityAction, ShareId, ShareNotification, ShareNotificationType, - SharePermissions, ShareRecipient, ShareTarget, generate_share_token, hash_share_password, - verify_share_password, -}; -use pinakes_core::users::UserId; /// Create a new share /// POST /api/shares pub async fn create_share( - State(state): State, - Extension(username): Extension, - Json(req): Json, + State(state): State, + Extension(username): Extension, + Json(req): Json, ) -> ApiResult> { - let config = state.config.read().await; - if !config.sharing.enabled { - return Err(ApiError::bad_request("Sharing is not enabled")); + let config = state.config.read().await; + if !config.sharing.enabled { + return Err(ApiError::bad_request("Sharing is not enabled")); + } + + // Validate public links are allowed + if req.recipient_type == "public_link" && !config.sharing.allow_public_links { + return Err(ApiError::bad_request("Public links are not allowed")); + } + drop(config); + + let owner_id = resolve_user_id(&state.storage, &username).await?; + + // Parse target + let target_id: Uuid = req + .target_id + .parse() + .map_err(|_| ApiError::bad_request("Invalid target_id"))?; + + let target = match req.target_type.as_str() { + "media" => { + ShareTarget::Media { + media_id: MediaId(target_id), + } + }, + "collection" => { + ShareTarget::Collection { + collection_id: target_id, + } + }, + "tag" => ShareTarget::Tag { tag_id: target_id }, + "saved_search" => { + ShareTarget::SavedSearch { + search_id: target_id, + } + }, + _ => return Err(ApiError::bad_request("Invalid target_type")), + }; + + // Parse recipient + let recipient = match req.recipient_type.as_str() { + "public_link" => { + let token = generate_share_token(); + let password_hash = req.password.as_ref().map(|p| hash_share_password(p)); + ShareRecipient::PublicLink { + token, + password_hash, + } + }, + "user" => { + let recipient_user_id = req.recipient_user_id.ok_or_else(|| { + ApiError::bad_request("recipient_user_id required for user share") + })?; + ShareRecipient::User { + user_id: UserId(recipient_user_id), + } + }, + "group" => { + let group_id = req.recipient_group_id.ok_or_else(|| { + ApiError::bad_request("recipient_group_id required for group share") + })?; + ShareRecipient::Group { group_id } + }, + _ => return Err(ApiError::bad_request("Invalid recipient_type")), + }; + + // Parse permissions + let permissions = if let Some(perms) = req.permissions { + SharePermissions { + can_view: perms.can_view.unwrap_or(true), + can_download: perms.can_download.unwrap_or(false), + can_edit: perms.can_edit.unwrap_or(false), + can_delete: perms.can_delete.unwrap_or(false), + can_reshare: perms.can_reshare.unwrap_or(false), + can_add: perms.can_add.unwrap_or(false), } + } else { + SharePermissions::view_only() + }; - // Validate public links are allowed - if req.recipient_type == "public_link" && !config.sharing.allow_public_links { - return Err(ApiError::bad_request("Public links are not allowed")); - } - drop(config); + // Calculate expiration + let expires_at = req + .expires_in_hours + .map(|hours| Utc::now() + chrono::Duration::hours(hours as i64)); - let owner_id = resolve_user_id(&state.storage, &username).await?; + let share = Share { + id: ShareId(Uuid::now_v7()), + target, + owner_id, + recipient, + permissions, + note: req.note, + expires_at, + access_count: 0, + last_accessed: None, + inherit_to_children: req.inherit_to_children.unwrap_or(true), + parent_share_id: None, + created_at: Utc::now(), + updated_at: Utc::now(), + }; - // Parse target - let target_id: Uuid = req - .target_id - .parse() - .map_err(|_| ApiError::bad_request("Invalid target_id"))?; + let created = state.storage.create_share(&share).await.map_err(|e| { + ApiError::internal(format!("Failed to create share: {}", e)) + })?; - let target = match req.target_type.as_str() { - "media" => ShareTarget::Media { - media_id: MediaId(target_id), - }, - "collection" => ShareTarget::Collection { - collection_id: target_id, - }, - "tag" => ShareTarget::Tag { tag_id: target_id }, - "saved_search" => ShareTarget::SavedSearch { - search_id: target_id, - }, - _ => return Err(ApiError::bad_request("Invalid target_type")), + // Send notification to recipient if it's a user share + if let ShareRecipient::User { user_id } = &created.recipient { + let notification = ShareNotification { + id: Uuid::now_v7(), + user_id: *user_id, + share_id: created.id, + notification_type: ShareNotificationType::NewShare, + is_read: false, + created_at: Utc::now(), }; - // Parse recipient - let recipient = match req.recipient_type.as_str() { - "public_link" => { - let token = generate_share_token(); - let password_hash = req.password.as_ref().map(|p| hash_share_password(p)); - ShareRecipient::PublicLink { - token, - password_hash, - } - } - "user" => { - let recipient_user_id = req.recipient_user_id.ok_or_else(|| { - ApiError::bad_request("recipient_user_id required for user share") - })?; - ShareRecipient::User { - user_id: UserId(recipient_user_id), - } - } - "group" => { - let group_id = req.recipient_group_id.ok_or_else(|| { - ApiError::bad_request("recipient_group_id required for group share") - })?; - ShareRecipient::Group { group_id } - } - _ => return Err(ApiError::bad_request("Invalid recipient_type")), - }; + // Ignore notification errors + let _ = state.storage.create_share_notification(¬ification).await; + } - // Parse permissions - let permissions = if let Some(perms) = req.permissions { - SharePermissions { - can_view: perms.can_view.unwrap_or(true), - can_download: perms.can_download.unwrap_or(false), - can_edit: perms.can_edit.unwrap_or(false), - can_delete: perms.can_delete.unwrap_or(false), - can_reshare: perms.can_reshare.unwrap_or(false), - can_add: perms.can_add.unwrap_or(false), - } - } else { - SharePermissions::view_only() - }; - - // Calculate expiration - let expires_at = req - .expires_in_hours - .map(|hours| Utc::now() + chrono::Duration::hours(hours as i64)); - - let share = Share { - id: ShareId(Uuid::now_v7()), - target, - owner_id, - recipient, - permissions, - note: req.note, - expires_at, - access_count: 0, - last_accessed: None, - inherit_to_children: req.inherit_to_children.unwrap_or(true), - parent_share_id: None, - created_at: Utc::now(), - updated_at: Utc::now(), - }; - - let created = state - .storage - .create_share(&share) - .await - .map_err(|e| ApiError::internal(format!("Failed to create share: {}", e)))?; - - // Send notification to recipient if it's a user share - if let ShareRecipient::User { user_id } = &created.recipient { - let notification = ShareNotification { - id: Uuid::now_v7(), - user_id: *user_id, - share_id: created.id, - notification_type: ShareNotificationType::NewShare, - is_read: false, - created_at: Utc::now(), - }; - - // Ignore notification errors - let _ = state.storage.create_share_notification(¬ification).await; - } - - Ok(Json(created.into())) + Ok(Json(created.into())) } /// List outgoing shares (shares I created) /// GET /api/shares/outgoing pub async fn list_outgoing( - State(state): State, - Extension(username): Extension, - Query(params): Query, + State(state): State, + Extension(username): Extension, + Query(params): Query, ) -> ApiResult>> { - let user_id = resolve_user_id(&state.storage, &username).await?; - let pagination = Pagination { - offset: params.offset.unwrap_or(0), - limit: params.limit.unwrap_or(50), - sort: params.sort, - }; + let user_id = resolve_user_id(&state.storage, &username).await?; + let pagination = Pagination { + offset: params.offset.unwrap_or(0), + limit: params.limit.unwrap_or(50), + sort: params.sort, + }; - let shares = state - .storage - .list_shares_by_owner(user_id, &pagination) - .await - .map_err(|e| ApiError::internal(format!("Failed to list shares: {}", e)))?; + let shares = state + .storage + .list_shares_by_owner(user_id, &pagination) + .await + .map_err(|e| ApiError::internal(format!("Failed to list shares: {}", e)))?; - Ok(Json(shares.into_iter().map(Into::into).collect())) + Ok(Json(shares.into_iter().map(Into::into).collect())) } /// List incoming shares (shares shared with me) /// GET /api/shares/incoming pub async fn list_incoming( - State(state): State, - Extension(username): Extension, - Query(params): Query, + State(state): State, + Extension(username): Extension, + Query(params): Query, ) -> ApiResult>> { - let user_id = resolve_user_id(&state.storage, &username).await?; - let pagination = Pagination { - offset: params.offset.unwrap_or(0), - limit: params.limit.unwrap_or(50), - sort: params.sort, - }; + let user_id = resolve_user_id(&state.storage, &username).await?; + let pagination = Pagination { + offset: params.offset.unwrap_or(0), + limit: params.limit.unwrap_or(50), + sort: params.sort, + }; - let shares = state - .storage - .list_shares_for_user(user_id, &pagination) - .await - .map_err(|e| ApiError::internal(format!("Failed to list shares: {}", e)))?; + let shares = state + .storage + .list_shares_for_user(user_id, &pagination) + .await + .map_err(|e| ApiError::internal(format!("Failed to list shares: {}", e)))?; - Ok(Json(shares.into_iter().map(Into::into).collect())) + Ok(Json(shares.into_iter().map(Into::into).collect())) } /// Get share details /// GET /api/shares/{id} pub async fn get_share( - State(state): State, - Extension(username): Extension, - Path(id): Path, + State(state): State, + Extension(username): Extension, + Path(id): Path, ) -> ApiResult> { - let user_id = resolve_user_id(&state.storage, &username).await?; - let share = state - .storage - .get_share(ShareId(id)) - .await - .map_err(|e| ApiError::not_found(format!("Share not found: {}", e)))?; + let user_id = resolve_user_id(&state.storage, &username).await?; + let share = state + .storage + .get_share(ShareId(id)) + .await + .map_err(|e| ApiError::not_found(format!("Share not found: {}", e)))?; - // Check authorization - let is_owner = share.owner_id == user_id; - let is_recipient = match &share.recipient { - ShareRecipient::User { - user_id: recipient_id, - } => *recipient_id == user_id, - _ => false, - }; + // Check authorization + let is_owner = share.owner_id == user_id; + let is_recipient = match &share.recipient { + ShareRecipient::User { + user_id: recipient_id, + } => *recipient_id == user_id, + _ => false, + }; - if !is_owner && !is_recipient { - return Err(ApiError::forbidden("Not authorized to view this share")); - } + if !is_owner && !is_recipient { + return Err(ApiError::forbidden("Not authorized to view this share")); + } - Ok(Json(share.into())) + Ok(Json(share.into())) } /// Update a share /// PATCH /api/shares/{id} pub async fn update_share( - State(state): State, - Extension(username): Extension, - Path(id): Path, - Json(req): Json, + State(state): State, + Extension(username): Extension, + Path(id): Path, + Json(req): Json, ) -> ApiResult> { - let user_id = resolve_user_id(&state.storage, &username).await?; - let mut share = state - .storage - .get_share(ShareId(id)) - .await - .map_err(|e| ApiError::not_found(format!("Share not found: {}", e)))?; + let user_id = resolve_user_id(&state.storage, &username).await?; + let mut share = state + .storage + .get_share(ShareId(id)) + .await + .map_err(|e| ApiError::not_found(format!("Share not found: {}", e)))?; - // Only owner can update - if share.owner_id != user_id { - return Err(ApiError::forbidden("Only the owner can update this share")); - } + // Only owner can update + if share.owner_id != user_id { + return Err(ApiError::forbidden("Only the owner can update this share")); + } - // Update fields - if let Some(perms) = req.permissions { - share.permissions = SharePermissions { - can_view: perms.can_view.unwrap_or(share.permissions.can_view), - can_download: perms.can_download.unwrap_or(share.permissions.can_download), - can_edit: perms.can_edit.unwrap_or(share.permissions.can_edit), - can_delete: perms.can_delete.unwrap_or(share.permissions.can_delete), - can_reshare: perms.can_reshare.unwrap_or(share.permissions.can_reshare), - can_add: perms.can_add.unwrap_or(share.permissions.can_add), - }; - } + // Update fields + if let Some(perms) = req.permissions { + share.permissions = SharePermissions { + can_view: perms.can_view.unwrap_or(share.permissions.can_view), + can_download: perms + .can_download + .unwrap_or(share.permissions.can_download), + can_edit: perms.can_edit.unwrap_or(share.permissions.can_edit), + can_delete: perms.can_delete.unwrap_or(share.permissions.can_delete), + can_reshare: perms.can_reshare.unwrap_or(share.permissions.can_reshare), + can_add: perms.can_add.unwrap_or(share.permissions.can_add), + }; + } - if let Some(note) = req.note { - share.note = Some(note); - } + if let Some(note) = req.note { + share.note = Some(note); + } - if let Some(expires_at) = req.expires_at { - share.expires_at = Some(expires_at); - } + if let Some(expires_at) = req.expires_at { + share.expires_at = Some(expires_at); + } - if let Some(inherit) = req.inherit_to_children { - share.inherit_to_children = inherit; - } + if let Some(inherit) = req.inherit_to_children { + share.inherit_to_children = inherit; + } - share.updated_at = Utc::now(); + share.updated_at = Utc::now(); - let updated = state - .storage - .update_share(&share) - .await - .map_err(|e| ApiError::internal(format!("Failed to update share: {}", e)))?; + let updated = state.storage.update_share(&share).await.map_err(|e| { + ApiError::internal(format!("Failed to update share: {}", e)) + })?; - // Notify recipient of update - if let ShareRecipient::User { user_id } = &updated.recipient { - let notification = ShareNotification { - id: Uuid::now_v7(), - user_id: *user_id, - share_id: updated.id, - notification_type: ShareNotificationType::ShareUpdated, - is_read: false, - created_at: Utc::now(), - }; - let _ = state.storage.create_share_notification(¬ification).await; - } + // Notify recipient of update + if let ShareRecipient::User { user_id } = &updated.recipient { + let notification = ShareNotification { + id: Uuid::now_v7(), + user_id: *user_id, + share_id: updated.id, + notification_type: ShareNotificationType::ShareUpdated, + is_read: false, + created_at: Utc::now(), + }; + let _ = state.storage.create_share_notification(¬ification).await; + } - Ok(Json(updated.into())) + Ok(Json(updated.into())) } /// Delete (revoke) a share /// DELETE /api/shares/{id} pub async fn delete_share( - State(state): State, - Extension(username): Extension, - Path(id): Path, + State(state): State, + Extension(username): Extension, + Path(id): Path, ) -> ApiResult { - let user_id = resolve_user_id(&state.storage, &username).await?; - let share = state - .storage - .get_share(ShareId(id)) - .await - .map_err(|e| ApiError::not_found(format!("Share not found: {}", e)))?; + let user_id = resolve_user_id(&state.storage, &username).await?; + let share = state + .storage + .get_share(ShareId(id)) + .await + .map_err(|e| ApiError::not_found(format!("Share not found: {}", e)))?; - // Only owner can delete - if share.owner_id != user_id { - return Err(ApiError::forbidden("Only the owner can revoke this share")); - } + // Only owner can delete + if share.owner_id != user_id { + return Err(ApiError::forbidden("Only the owner can revoke this share")); + } - // Notify recipient before deletion - if let ShareRecipient::User { user_id } = &share.recipient { - let notification = ShareNotification { - id: Uuid::now_v7(), - user_id: *user_id, - share_id: share.id, - notification_type: ShareNotificationType::ShareRevoked, - is_read: false, - created_at: Utc::now(), - }; - let _ = state.storage.create_share_notification(¬ification).await; - } + // Notify recipient before deletion + if let ShareRecipient::User { user_id } = &share.recipient { + let notification = ShareNotification { + id: Uuid::now_v7(), + user_id: *user_id, + share_id: share.id, + notification_type: ShareNotificationType::ShareRevoked, + is_read: false, + created_at: Utc::now(), + }; + let _ = state.storage.create_share_notification(¬ification).await; + } - state - .storage - .delete_share(ShareId(id)) - .await - .map_err(|e| ApiError::internal(format!("Failed to delete share: {}", e)))?; + state.storage.delete_share(ShareId(id)).await.map_err(|e| { + ApiError::internal(format!("Failed to delete share: {}", e)) + })?; - Ok(StatusCode::NO_CONTENT) + Ok(StatusCode::NO_CONTENT) } /// Batch delete shares /// POST /api/shares/batch/delete pub async fn batch_delete( - State(state): State, - Extension(username): Extension, - Json(req): Json, + State(state): State, + Extension(username): Extension, + Json(req): Json, ) -> ApiResult> { - let user_id = resolve_user_id(&state.storage, &username).await?; - let share_ids: Vec = req.share_ids.into_iter().map(ShareId).collect(); + let user_id = resolve_user_id(&state.storage, &username).await?; + let share_ids: Vec = + req.share_ids.into_iter().map(ShareId).collect(); - // Verify ownership of all shares - for share_id in &share_ids { - let share = state - .storage - .get_share(*share_id) - .await - .map_err(|e| ApiError::not_found(format!("Share not found: {}", e)))?; + // Verify ownership of all shares + for share_id in &share_ids { + let share = state + .storage + .get_share(*share_id) + .await + .map_err(|e| ApiError::not_found(format!("Share not found: {}", e)))?; - if share.owner_id != user_id { - return Err(ApiError::forbidden(format!( - "Not authorized to delete share {}", - share_id.0 - ))); - } + if share.owner_id != user_id { + return Err(ApiError::forbidden(format!( + "Not authorized to delete share {}", + share_id.0 + ))); } + } - let deleted = state - .storage - .batch_delete_shares(&share_ids) - .await - .map_err(|e| ApiError::internal(format!("Failed to batch delete: {}", e)))?; + let deleted = state + .storage + .batch_delete_shares(&share_ids) + .await + .map_err(|e| { + ApiError::internal(format!("Failed to batch delete: {}", e)) + })?; - Ok(Json(serde_json::json!({ "deleted": deleted }))) + Ok(Json(serde_json::json!({ "deleted": deleted }))) } /// Access a public shared resource /// GET /api/shared/{token} pub async fn access_shared( - State(state): State, - Path(token): Path, - Query(params): Query, - ConnectInfo(addr): ConnectInfo, + State(state): State, + Path(token): Path, + Query(params): Query, + ConnectInfo(addr): ConnectInfo, ) -> ApiResult> { - let share = state - .storage - .get_share_by_token(&token) - .await - .map_err(|e| ApiError::not_found(format!("Share not found: {}", e)))?; + let share = state + .storage + .get_share_by_token(&token) + .await + .map_err(|e| ApiError::not_found(format!("Share not found: {}", e)))?; - // Check expiration - if let Some(expires_at) = share.expires_at { - if Utc::now() > expires_at { - return Err(ApiError::not_found("Share has expired")); - } + // Check expiration + if let Some(expires_at) = share.expires_at { + if Utc::now() > expires_at { + return Err(ApiError::not_found("Share has expired")); } + } - // Check password if required - if let ShareRecipient::PublicLink { password_hash, .. } = &share.recipient { - if let Some(hash) = password_hash { - let provided_password = params - .password - .as_ref() - .ok_or_else(|| ApiError::unauthorized("Password required"))?; + // Check password if required + if let ShareRecipient::PublicLink { password_hash, .. } = &share.recipient { + if let Some(hash) = password_hash { + let provided_password = params + .password + .as_ref() + .ok_or_else(|| ApiError::unauthorized("Password required"))?; - if !verify_share_password(provided_password, hash) { - // Log failed attempt - let activity = ShareActivity { - id: Uuid::now_v7(), - share_id: share.id, - actor_id: None, - actor_ip: Some(addr.ip().to_string()), - action: ShareActivityAction::PasswordFailed, - details: None, - timestamp: Utc::now(), - }; - let _ = state.storage.record_share_activity(&activity).await; + if !verify_share_password(provided_password, hash) { + // Log failed attempt + let activity = ShareActivity { + id: Uuid::now_v7(), + share_id: share.id, + actor_id: None, + actor_ip: Some(addr.ip().to_string()), + action: ShareActivityAction::PasswordFailed, + details: None, + timestamp: Utc::now(), + }; + let _ = state.storage.record_share_activity(&activity).await; - return Err(ApiError::unauthorized("Invalid password")); - } - } + return Err(ApiError::unauthorized("Invalid password")); + } } + } - // Record access - state - .storage - .record_share_access(share.id) - .await - .map_err(|e| ApiError::internal(format!("Failed to record access: {}", e)))?; + // Record access + state + .storage + .record_share_access(share.id) + .await + .map_err(|e| { + ApiError::internal(format!("Failed to record access: {}", e)) + })?; - // Log the access - let activity = ShareActivity { - id: Uuid::now_v7(), - share_id: share.id, - actor_id: None, - actor_ip: Some(addr.ip().to_string()), - action: ShareActivityAction::Accessed, - details: None, - timestamp: Utc::now(), - }; - let _ = state.storage.record_share_activity(&activity).await; + // Log the access + let activity = ShareActivity { + id: Uuid::now_v7(), + share_id: share.id, + actor_id: None, + actor_ip: Some(addr.ip().to_string()), + action: ShareActivityAction::Accessed, + details: None, + timestamp: Utc::now(), + }; + let _ = state.storage.record_share_activity(&activity).await; - // Return the shared content - match &share.target { - ShareTarget::Media { media_id } => { - let item = state - .storage - .get_media(*media_id) - .await - .map_err(|e| ApiError::not_found(format!("Media not found: {}", e)))?; + // Return the shared content + match &share.target { + ShareTarget::Media { media_id } => { + let item = + state.storage.get_media(*media_id).await.map_err(|e| { + ApiError::not_found(format!("Media not found: {}", e)) + })?; - Ok(Json(item.into())) - } - _ => { - // For collections/tags, return a placeholder - // Full implementation would return the collection contents - Err(ApiError::bad_request( - "Collection/tag sharing not yet fully implemented", - )) - } - } + Ok(Json(item.into())) + }, + _ => { + // For collections/tags, return a placeholder + // Full implementation would return the collection contents + Err(ApiError::bad_request( + "Collection/tag sharing not yet fully implemented", + )) + }, + } } /// Get share activity log /// GET /api/shares/{id}/activity pub async fn get_activity( - State(state): State, - Extension(username): Extension, - Path(id): Path, - Query(params): Query, + State(state): State, + Extension(username): Extension, + Path(id): Path, + Query(params): Query, ) -> ApiResult>> { - let user_id = resolve_user_id(&state.storage, &username).await?; - let share = state - .storage - .get_share(ShareId(id)) - .await - .map_err(|e| ApiError::not_found(format!("Share not found: {}", e)))?; + let user_id = resolve_user_id(&state.storage, &username).await?; + let share = state + .storage + .get_share(ShareId(id)) + .await + .map_err(|e| ApiError::not_found(format!("Share not found: {}", e)))?; - // Only owner can view activity - if share.owner_id != user_id { - return Err(ApiError::forbidden( - "Only the owner can view share activity", - )); - } + // Only owner can view activity + if share.owner_id != user_id { + return Err(ApiError::forbidden( + "Only the owner can view share activity", + )); + } - let pagination = Pagination { - offset: params.offset.unwrap_or(0), - limit: params.limit.unwrap_or(50), - sort: params.sort, - }; + let pagination = Pagination { + offset: params.offset.unwrap_or(0), + limit: params.limit.unwrap_or(50), + sort: params.sort, + }; - let activity = state - .storage - .get_share_activity(ShareId(id), &pagination) - .await - .map_err(|e| ApiError::internal(format!("Failed to get activity: {}", e)))?; + let activity = state + .storage + .get_share_activity(ShareId(id), &pagination) + .await + .map_err(|e| { + ApiError::internal(format!("Failed to get activity: {}", e)) + })?; - Ok(Json(activity.into_iter().map(Into::into).collect())) + Ok(Json(activity.into_iter().map(Into::into).collect())) } /// Get unread share notifications /// GET /api/notifications/shares pub async fn get_notifications( - State(state): State, - Extension(username): Extension, + State(state): State, + Extension(username): Extension, ) -> ApiResult>> { - let user_id = resolve_user_id(&state.storage, &username).await?; - let notifications = state - .storage - .get_unread_notifications(user_id) - .await - .map_err(|e| ApiError::internal(format!("Failed to get notifications: {}", e)))?; + let user_id = resolve_user_id(&state.storage, &username).await?; + let notifications = state + .storage + .get_unread_notifications(user_id) + .await + .map_err(|e| { + ApiError::internal(format!("Failed to get notifications: {}", e)) + })?; - Ok(Json(notifications.into_iter().map(Into::into).collect())) + Ok(Json(notifications.into_iter().map(Into::into).collect())) } /// Mark a notification as read /// POST /api/notifications/shares/{id}/read pub async fn mark_notification_read( - State(state): State, - Extension(_username): Extension, - Path(id): Path, + State(state): State, + Extension(_username): Extension, + Path(id): Path, ) -> ApiResult { - state - .storage - .mark_notification_read(id) - .await - .map_err(|e| ApiError::internal(format!("Failed to mark as read: {}", e)))?; + state + .storage + .mark_notification_read(id) + .await + .map_err(|e| { + ApiError::internal(format!("Failed to mark as read: {}", e)) + })?; - Ok(StatusCode::OK) + Ok(StatusCode::OK) } /// Mark all notifications as read /// POST /api/notifications/shares/read-all pub async fn mark_all_read( - State(state): State, - Extension(username): Extension, + State(state): State, + Extension(username): Extension, ) -> ApiResult { - let user_id = resolve_user_id(&state.storage, &username).await?; - state - .storage - .mark_all_notifications_read(user_id) - .await - .map_err(|e| ApiError::internal(format!("Failed to mark all as read: {}", e)))?; + let user_id = resolve_user_id(&state.storage, &username).await?; + state + .storage + .mark_all_notifications_read(user_id) + .await + .map_err(|e| { + ApiError::internal(format!("Failed to mark all as read: {}", e)) + })?; - Ok(StatusCode::OK) + Ok(StatusCode::OK) } diff --git a/crates/pinakes-server/src/routes/social.rs b/crates/pinakes-server/src/routes/social.rs index 2904e0e..bf47014 100644 --- a/crates/pinakes-server/src/routes/social.rs +++ b/crates/pinakes-server/src/routes/social.rs @@ -1,199 +1,204 @@ -use axum::Json; -use axum::extract::{Extension, Path, Query, State}; +use axum::{ + Json, + extract::{Extension, Path, Query, State}, +}; +use pinakes_core::model::{MediaId, Pagination}; use serde::Deserialize; use uuid::Uuid; -use crate::auth::resolve_user_id; -use crate::dto::*; -use crate::error::ApiError; -use crate::state::AppState; - -use pinakes_core::model::{MediaId, Pagination}; +use crate::{auth::resolve_user_id, dto::*, error::ApiError, state::AppState}; #[derive(Deserialize)] pub struct ShareLinkQuery { - pub password: Option, + pub password: Option, } // ===== Ratings ===== pub async fn rate_media( - State(state): State, - Extension(username): Extension, - Path(id): Path, - Json(req): Json, + State(state): State, + Extension(username): Extension, + Path(id): Path, + Json(req): Json, ) -> Result, ApiError> { - if req.stars < 1 || req.stars > 5 { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( - "stars must be between 1 and 5".into(), - ), - )); - } - let user_id = resolve_user_id(&state.storage, &username).await?; - let rating = state - .storage - .rate_media(user_id, MediaId(id), req.stars, req.review_text.as_deref()) - .await?; - Ok(Json(RatingResponse::from(rating))) + if req.stars < 1 || req.stars > 5 { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "stars must be between 1 and 5".into(), + ), + )); + } + let user_id = resolve_user_id(&state.storage, &username).await?; + let rating = state + .storage + .rate_media(user_id, MediaId(id), req.stars, req.review_text.as_deref()) + .await?; + Ok(Json(RatingResponse::from(rating))) } pub async fn get_media_ratings( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result>, ApiError> { - let ratings = state.storage.get_media_ratings(MediaId(id)).await?; - Ok(Json( - ratings.into_iter().map(RatingResponse::from).collect(), - )) + let ratings = state.storage.get_media_ratings(MediaId(id)).await?; + Ok(Json( + ratings.into_iter().map(RatingResponse::from).collect(), + )) } // ===== Comments ===== pub async fn add_comment( - State(state): State, - Extension(username): Extension, - Path(id): Path, - Json(req): Json, + State(state): State, + Extension(username): Extension, + Path(id): Path, + Json(req): Json, ) -> Result, ApiError> { - let char_count = req.text.chars().count(); - if char_count == 0 || char_count > 10_000 { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( - "comment text must be 1-10000 characters".into(), - ), - )); - } - let user_id = resolve_user_id(&state.storage, &username).await?; - let comment = state - .storage - .add_comment(user_id, MediaId(id), &req.text, req.parent_id) - .await?; - Ok(Json(CommentResponse::from(comment))) + let char_count = req.text.chars().count(); + if char_count == 0 || char_count > 10_000 { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "comment text must be 1-10000 characters".into(), + ), + )); + } + let user_id = resolve_user_id(&state.storage, &username).await?; + let comment = state + .storage + .add_comment(user_id, MediaId(id), &req.text, req.parent_id) + .await?; + Ok(Json(CommentResponse::from(comment))) } pub async fn get_media_comments( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result>, ApiError> { - let comments = state.storage.get_media_comments(MediaId(id)).await?; - Ok(Json( - comments.into_iter().map(CommentResponse::from).collect(), - )) + let comments = state.storage.get_media_comments(MediaId(id)).await?; + Ok(Json( + comments.into_iter().map(CommentResponse::from).collect(), + )) } // ===== Favorites ===== pub async fn add_favorite( - State(state): State, - Extension(username): Extension, - Json(req): Json, + State(state): State, + Extension(username): Extension, + Json(req): Json, ) -> Result, ApiError> { - let user_id = resolve_user_id(&state.storage, &username).await?; - state - .storage - .add_favorite(user_id, MediaId(req.media_id)) - .await?; - Ok(Json(serde_json::json!({"added": true}))) + let user_id = resolve_user_id(&state.storage, &username).await?; + state + .storage + .add_favorite(user_id, MediaId(req.media_id)) + .await?; + Ok(Json(serde_json::json!({"added": true}))) } pub async fn remove_favorite( - State(state): State, - Extension(username): Extension, - Path(media_id): Path, + State(state): State, + Extension(username): Extension, + Path(media_id): Path, ) -> Result, ApiError> { - let user_id = resolve_user_id(&state.storage, &username).await?; - state - .storage - .remove_favorite(user_id, MediaId(media_id)) - .await?; - Ok(Json(serde_json::json!({"removed": true}))) + let user_id = resolve_user_id(&state.storage, &username).await?; + state + .storage + .remove_favorite(user_id, MediaId(media_id)) + .await?; + Ok(Json(serde_json::json!({"removed": true}))) } pub async fn list_favorites( - State(state): State, - Extension(username): Extension, + State(state): State, + Extension(username): Extension, ) -> Result>, ApiError> { - let user_id = resolve_user_id(&state.storage, &username).await?; - let items = state - .storage - .get_user_favorites(user_id, &Pagination::default()) - .await?; - Ok(Json(items.into_iter().map(MediaResponse::from).collect())) + let user_id = resolve_user_id(&state.storage, &username).await?; + let items = state + .storage + .get_user_favorites(user_id, &Pagination::default()) + .await?; + Ok(Json(items.into_iter().map(MediaResponse::from).collect())) } // ===== Share Links ===== pub async fn create_share_link( - State(state): State, - Extension(username): Extension, - Json(req): Json, + State(state): State, + Extension(username): Extension, + Json(req): Json, ) -> Result, ApiError> { - let user_id = resolve_user_id(&state.storage, &username).await?; - let token = uuid::Uuid::now_v7().to_string().replace('-', ""); - let password_hash = match req.password.as_ref() { - Some(p) => Some(pinakes_core::users::auth::hash_password(p).map_err(ApiError)?), - None => None, - }; - const MAX_EXPIRY_HOURS: u64 = 8760; // 1 year - if let Some(h) = req.expires_in_hours - && h > MAX_EXPIRY_HOURS - { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation(format!( - "expires_in_hours cannot exceed {}", - MAX_EXPIRY_HOURS - )), - )); - } - let expires_at = req - .expires_in_hours - .map(|h| chrono::Utc::now() + chrono::Duration::hours(h as i64)); - let link = state - .storage - .create_share_link( - MediaId(req.media_id), - user_id, - &token, - password_hash.as_deref(), - expires_at, - ) - .await?; - Ok(Json(ShareLinkResponse::from(link))) + let user_id = resolve_user_id(&state.storage, &username).await?; + let token = uuid::Uuid::now_v7().to_string().replace('-', ""); + let password_hash = match req.password.as_ref() { + Some(p) => { + Some(pinakes_core::users::auth::hash_password(p).map_err(ApiError)?) + }, + None => None, + }; + const MAX_EXPIRY_HOURS: u64 = 8760; // 1 year + if let Some(h) = req.expires_in_hours + && h > MAX_EXPIRY_HOURS + { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation(format!( + "expires_in_hours cannot exceed {}", + MAX_EXPIRY_HOURS + )), + )); + } + let expires_at = req + .expires_in_hours + .map(|h| chrono::Utc::now() + chrono::Duration::hours(h as i64)); + let link = state + .storage + .create_share_link( + MediaId(req.media_id), + user_id, + &token, + password_hash.as_deref(), + expires_at, + ) + .await?; + Ok(Json(ShareLinkResponse::from(link))) } pub async fn access_shared_media( - State(state): State, - Path(token): Path, - Query(query): Query, + State(state): State, + Path(token): Path, + Query(query): Query, ) -> Result, ApiError> { - let link = state.storage.get_share_link(&token).await?; - // Check expiration - if let Some(expires) = link.expires_at - && chrono::Utc::now() > expires - { + let link = state.storage.get_share_link(&token).await?; + // Check expiration + if let Some(expires) = link.expires_at + && chrono::Utc::now() > expires + { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "share link has expired".into(), + ), + )); + } + // Verify password if set + if let Some(ref hash) = link.password_hash { + let password = match query.password.as_deref() { + Some(p) => p, + None => { return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation("share link has expired".into()), + pinakes_core::error::PinakesError::Authentication( + "password required for this share link".into(), + ), )); + }, + }; + let valid = pinakes_core::users::auth::verify_password(password, hash) + .unwrap_or(false); + if !valid { + return Err(ApiError(pinakes_core::error::PinakesError::Authentication( + "invalid share link password".into(), + ))); } - // Verify password if set - if let Some(ref hash) = link.password_hash { - let password = match query.password.as_deref() { - Some(p) => p, - None => { - return Err(ApiError(pinakes_core::error::PinakesError::Authentication( - "password required for this share link".into(), - ))); - } - }; - let valid = pinakes_core::users::auth::verify_password(password, hash).unwrap_or(false); - if !valid { - return Err(ApiError(pinakes_core::error::PinakesError::Authentication( - "invalid share link password".into(), - ))); - } - } - state.storage.increment_share_views(&token).await?; - let item = state.storage.get_media(link.media_id).await?; - Ok(Json(MediaResponse::from(item))) + } + state.storage.increment_share_views(&token).await?; + let item = state.storage.get_media(link.media_id).await?; + Ok(Json(MediaResponse::from(item))) } diff --git a/crates/pinakes-server/src/routes/statistics.rs b/crates/pinakes-server/src/routes/statistics.rs index 06db6b6..24dc7b9 100644 --- a/crates/pinakes-server/src/routes/statistics.rs +++ b/crates/pinakes-server/src/routes/statistics.rs @@ -1,13 +1,10 @@ -use axum::Json; -use axum::extract::State; +use axum::{Json, extract::State}; -use crate::dto::LibraryStatisticsResponse; -use crate::error::ApiError; -use crate::state::AppState; +use crate::{dto::LibraryStatisticsResponse, error::ApiError, state::AppState}; pub async fn library_statistics( - State(state): State, + State(state): State, ) -> Result, ApiError> { - let stats = state.storage.library_statistics().await?; - Ok(Json(LibraryStatisticsResponse::from(stats))) + let stats = state.storage.library_statistics().await?; + Ok(Json(LibraryStatisticsResponse::from(stats))) } diff --git a/crates/pinakes-server/src/routes/streaming.rs b/crates/pinakes-server/src/routes/streaming.rs index 3013a9b..6735eca 100644 --- a/crates/pinakes-server/src/routes/streaming.rs +++ b/crates/pinakes-server/src/routes/streaming.rs @@ -1,240 +1,269 @@ -use axum::extract::{Path, State}; -use axum::http::StatusCode; +use axum::{ + extract::{Path, State}, + http::StatusCode, +}; use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; +use pinakes_core::{ + model::MediaId, + transcode::{estimate_bandwidth, parse_resolution}, +}; use uuid::Uuid; -use crate::error::ApiError; -use crate::state::AppState; - -use pinakes_core::model::MediaId; -use pinakes_core::transcode::{estimate_bandwidth, parse_resolution}; +use crate::{error::ApiError, state::AppState}; fn escape_xml(s: &str) -> String { - s.replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") } pub async fn hls_master_playlist( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result { - // Verify media exists - let _item = state.storage.get_media(MediaId(id)).await?; + // Verify media exists + let _item = state.storage.get_media(MediaId(id)).await?; - let config = state.config.read().await; - let profiles = &config.transcoding.profiles; + let config = state.config.read().await; + let profiles = &config.transcoding.profiles; - let mut playlist = String::from("#EXTM3U\n#EXT-X-VERSION:3\n\n"); + let mut playlist = String::from("#EXTM3U\n#EXT-X-VERSION:3\n\n"); - for profile in profiles { - let (w, h) = parse_resolution(&profile.max_resolution); - let bandwidth = estimate_bandwidth(profile); - let encoded_name = utf8_percent_encode(&profile.name, NON_ALPHANUMERIC).to_string(); - playlist.push_str(&format!( - "#EXT-X-STREAM-INF:BANDWIDTH={bandwidth},RESOLUTION={w}x{h}\n\ - /api/v1/media/{id}/stream/hls/{encoded_name}/playlist.m3u8\n\n", - )); - } + for profile in profiles { + let (w, h) = parse_resolution(&profile.max_resolution); + let bandwidth = estimate_bandwidth(profile); + let encoded_name = + utf8_percent_encode(&profile.name, NON_ALPHANUMERIC).to_string(); + playlist.push_str(&format!( + "#EXT-X-STREAM-INF:BANDWIDTH={bandwidth},RESOLUTION={w}x{h}\n/api/v1/\ + media/{id}/stream/hls/{encoded_name}/playlist.m3u8\n\n", + )); + } - Ok(axum::response::Response::builder() - .header("Content-Type", "application/vnd.apple.mpegurl") - .body(axum::body::Body::from(playlist)) - .unwrap()) + Ok( + axum::response::Response::builder() + .header("Content-Type", "application/vnd.apple.mpegurl") + .body(axum::body::Body::from(playlist)) + .unwrap(), + ) } pub async fn hls_variant_playlist( - State(state): State, - Path((id, profile)): Path<(Uuid, String)>, + State(state): State, + Path((id, profile)): Path<(Uuid, String)>, ) -> Result { - let item = state.storage.get_media(MediaId(id)).await?; - let duration = item.duration_secs.unwrap_or(0.0); - if duration <= 0.0 { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( - "cannot generate HLS playlist for media with unknown or zero duration".into(), - ), - )); - } - let segment_duration = 10.0; - let num_segments = (duration / segment_duration).ceil() as usize; + let item = state.storage.get_media(MediaId(id)).await?; + let duration = item.duration_secs.unwrap_or(0.0); + if duration <= 0.0 { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "cannot generate HLS playlist for media with unknown or zero duration" + .into(), + ), + )); + } + let segment_duration = 10.0; + let num_segments = (duration / segment_duration).ceil() as usize; - let mut playlist = String::from( - "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:10\n#EXT-X-MEDIA-SEQUENCE:0\n", - ); - for i in 0..num_segments.max(1) { - let seg_dur = if i == num_segments.saturating_sub(1) && duration > 0.0 { - duration - (i as f64 * segment_duration) - } else { - segment_duration - }; - playlist.push_str(&format!("#EXTINF:{seg_dur:.3},\n")); - playlist.push_str(&format!( - "/api/v1/media/{id}/stream/hls/{profile}/segment{i}.ts\n" - )); - } - playlist.push_str("#EXT-X-ENDLIST\n"); + let mut playlist = String::from( + "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:10\n#\ + EXT-X-MEDIA-SEQUENCE:0\n", + ); + for i in 0..num_segments.max(1) { + let seg_dur = if i == num_segments.saturating_sub(1) && duration > 0.0 { + duration - (i as f64 * segment_duration) + } else { + segment_duration + }; + playlist.push_str(&format!("#EXTINF:{seg_dur:.3},\n")); + playlist.push_str(&format!( + "/api/v1/media/{id}/stream/hls/{profile}/segment{i}.ts\n" + )); + } + playlist.push_str("#EXT-X-ENDLIST\n"); - Ok(axum::response::Response::builder() - .header("Content-Type", "application/vnd.apple.mpegurl") - .body(axum::body::Body::from(playlist)) - .unwrap()) + Ok( + axum::response::Response::builder() + .header("Content-Type", "application/vnd.apple.mpegurl") + .body(axum::body::Body::from(playlist)) + .unwrap(), + ) } pub async fn hls_segment( - State(state): State, - Path((id, profile, segment)): Path<(Uuid, String, String)>, + State(state): State, + Path((id, profile, segment)): Path<(Uuid, String, String)>, ) -> Result { - // Strict validation: reject path traversal, null bytes, leading dots - if segment.is_empty() - || segment.starts_with('.') - || segment.contains('\0') - || segment.contains("..") - || segment.contains('/') - || segment.contains('\\') - { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation("invalid segment name".into()), - )); + // Strict validation: reject path traversal, null bytes, leading dots + if segment.is_empty() + || segment.starts_with('.') + || segment.contains('\0') + || segment.contains("..") + || segment.contains('/') + || segment.contains('\\') + { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "invalid segment name".into(), + ), + )); + } + + let media_id = MediaId(id); + + // Look for an active/completed transcode session + if let Some(transcode_service) = &state.transcode_service + && let Some(session) = + transcode_service.find_session(media_id, &profile).await + { + let segment_path = session.cache_path.join(&segment); + + if segment_path.exists() { + let data = tokio::fs::read(&segment_path).await.map_err(|e| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + format!("failed to read segment: {}", e), + )) + })?; + + return Ok( + axum::response::Response::builder() + .header("Content-Type", "video/MP2T") + .body(axum::body::Body::from(data)) + .unwrap(), + ); } - let media_id = MediaId(id); + // Session exists but segment not ready yet + return Ok( + axum::response::Response::builder() + .status(StatusCode::ACCEPTED) + .header("Retry-After", "2") + .body(axum::body::Body::from("segment not yet available")) + .unwrap(), + ); + } - // Look for an active/completed transcode session - if let Some(transcode_service) = &state.transcode_service - && let Some(session) = transcode_service.find_session(media_id, &profile).await - { - let segment_path = session.cache_path.join(&segment); - - if segment_path.exists() { - let data = tokio::fs::read(&segment_path).await.map_err(|e| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - format!("failed to read segment: {}", e), - )) - })?; - - return Ok(axum::response::Response::builder() - .header("Content-Type", "video/MP2T") - .body(axum::body::Body::from(data)) - .unwrap()); - } - - // Session exists but segment not ready yet - return Ok(axum::response::Response::builder() - .status(StatusCode::ACCEPTED) - .header("Retry-After", "2") - .body(axum::body::Body::from("segment not yet available")) - .unwrap()); - } - - Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( - "no transcode session found; start a transcode first via POST /media/{id}/transcode" - .into(), - ), - )) + Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "no transcode session found; start a transcode first via POST \ + /media/{id}/transcode" + .into(), + ), + )) } pub async fn dash_manifest( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result { - let item = state.storage.get_media(MediaId(id)).await?; - let duration = item.duration_secs.unwrap_or(0.0); - if duration <= 0.0 { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( - "cannot generate DASH manifest for media with unknown or zero duration".into(), - ), - )); - } - let hours = (duration / 3600.0) as u32; - let minutes = ((duration % 3600.0) / 60.0) as u32; - let seconds = duration % 60.0; + let item = state.storage.get_media(MediaId(id)).await?; + let duration = item.duration_secs.unwrap_or(0.0); + if duration <= 0.0 { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "cannot generate DASH manifest for media with unknown or zero duration" + .into(), + ), + )); + } + let hours = (duration / 3600.0) as u32; + let minutes = ((duration % 3600.0) / 60.0) as u32; + let seconds = duration % 60.0; - let config = state.config.read().await; - let profiles = &config.transcoding.profiles; + let config = state.config.read().await; + let profiles = &config.transcoding.profiles; - let mut representations = String::new(); - for profile in profiles { - let (w, h) = parse_resolution(&profile.max_resolution); - let bandwidth = estimate_bandwidth(profile); - let xml_name = escape_xml(&profile.name); - let url_name = utf8_percent_encode(&profile.name, NON_ALPHANUMERIC).to_string(); - representations.push_str(&format!( + let mut representations = String::new(); + for profile in profiles { + let (w, h) = parse_resolution(&profile.max_resolution); + let bandwidth = estimate_bandwidth(profile); + let xml_name = escape_xml(&profile.name); + let url_name = + utf8_percent_encode(&profile.name, NON_ALPHANUMERIC).to_string(); + representations.push_str(&format!( r#" "#, )); - } + } - let mpd = format!( - r#" + let mpd = format!( + r#" {representations} "# - ); + ); - Ok(axum::response::Response::builder() - .header("Content-Type", "application/dash+xml") - .body(axum::body::Body::from(mpd)) - .unwrap()) + Ok( + axum::response::Response::builder() + .header("Content-Type", "application/dash+xml") + .body(axum::body::Body::from(mpd)) + .unwrap(), + ) } pub async fn dash_segment( - State(state): State, - Path((id, profile, segment)): Path<(Uuid, String, String)>, + State(state): State, + Path((id, profile, segment)): Path<(Uuid, String, String)>, ) -> Result { - // Strict validation: reject path traversal, null bytes, leading dots - if segment.is_empty() - || segment.starts_with('.') - || segment.contains('\0') - || segment.contains("..") - || segment.contains('/') - || segment.contains('\\') - { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation("invalid segment name".into()), - )); + // Strict validation: reject path traversal, null bytes, leading dots + if segment.is_empty() + || segment.starts_with('.') + || segment.contains('\0') + || segment.contains("..") + || segment.contains('/') + || segment.contains('\\') + { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "invalid segment name".into(), + ), + )); + } + + let media_id = MediaId(id); + + if let Some(transcode_service) = &state.transcode_service + && let Some(session) = + transcode_service.find_session(media_id, &profile).await + { + let segment_path = session.cache_path.join(&segment); + + if segment_path.exists() { + let data = tokio::fs::read(&segment_path).await.map_err(|e| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + format!("failed to read segment: {}", e), + )) + })?; + + return Ok( + axum::response::Response::builder() + .header("Content-Type", "video/mp4") + .body(axum::body::Body::from(data)) + .unwrap(), + ); } - let media_id = MediaId(id); + return Ok( + axum::response::Response::builder() + .status(StatusCode::ACCEPTED) + .header("Retry-After", "2") + .body(axum::body::Body::from("segment not yet available")) + .unwrap(), + ); + } - if let Some(transcode_service) = &state.transcode_service - && let Some(session) = transcode_service.find_session(media_id, &profile).await - { - let segment_path = session.cache_path.join(&segment); - - if segment_path.exists() { - let data = tokio::fs::read(&segment_path).await.map_err(|e| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - format!("failed to read segment: {}", e), - )) - })?; - - return Ok(axum::response::Response::builder() - .header("Content-Type", "video/mp4") - .body(axum::body::Body::from(data)) - .unwrap()); - } - - return Ok(axum::response::Response::builder() - .status(StatusCode::ACCEPTED) - .header("Retry-After", "2") - .body(axum::body::Body::from("segment not yet available")) - .unwrap()); - } - - Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( - "no transcode session found; start a transcode first via POST /media/{id}/transcode" - .into(), - ), - )) + Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "no transcode session found; start a transcode first via POST \ + /media/{id}/transcode" + .into(), + ), + )) } diff --git a/crates/pinakes-server/src/routes/subtitles.rs b/crates/pinakes-server/src/routes/subtitles.rs index 7577135..d4742ef 100644 --- a/crates/pinakes-server/src/routes/subtitles.rs +++ b/crates/pinakes-server/src/routes/subtitles.rs @@ -1,123 +1,125 @@ -use axum::Json; -use axum::extract::{Path, State}; +use axum::{ + Json, + extract::{Path, State}, +}; +use pinakes_core::{ + model::MediaId, + subtitles::{Subtitle, SubtitleFormat}, +}; use uuid::Uuid; -use crate::dto::*; -use crate::error::ApiError; -use crate::state::AppState; - -use pinakes_core::model::MediaId; -use pinakes_core::subtitles::{Subtitle, SubtitleFormat}; +use crate::{dto::*, error::ApiError, state::AppState}; pub async fn list_subtitles( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result>, ApiError> { - let subtitles = state.storage.get_media_subtitles(MediaId(id)).await?; - Ok(Json( - subtitles.into_iter().map(SubtitleResponse::from).collect(), - )) + let subtitles = state.storage.get_media_subtitles(MediaId(id)).await?; + Ok(Json( + subtitles.into_iter().map(SubtitleResponse::from).collect(), + )) } pub async fn add_subtitle( - State(state): State, - Path(id): Path, - Json(req): Json, + State(state): State, + Path(id): Path, + Json(req): Json, ) -> Result, ApiError> { - let format: SubtitleFormat = req - .format - .parse() - .map_err(|e: String| ApiError(pinakes_core::error::PinakesError::InvalidOperation(e)))?; - let is_embedded = req.is_embedded.unwrap_or(false); - if !is_embedded && req.file_path.is_none() { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( - "file_path is required for non-embedded subtitles".into(), - ), - )); - } - if is_embedded && req.track_index.is_none() { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( - "track_index is required for embedded subtitles".into(), - ), - )); - } - let subtitle = Subtitle { - id: Uuid::now_v7(), - media_id: MediaId(id), - language: req.language, - format, - file_path: req.file_path.map(std::path::PathBuf::from), - is_embedded, - track_index: req.track_index, - offset_ms: req.offset_ms.unwrap_or(0), - created_at: chrono::Utc::now(), - }; - state.storage.add_subtitle(&subtitle).await?; - Ok(Json(SubtitleResponse::from(subtitle))) + let format: SubtitleFormat = req.format.parse().map_err(|e: String| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation(e)) + })?; + let is_embedded = req.is_embedded.unwrap_or(false); + if !is_embedded && req.file_path.is_none() { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "file_path is required for non-embedded subtitles".into(), + ), + )); + } + if is_embedded && req.track_index.is_none() { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "track_index is required for embedded subtitles".into(), + ), + )); + } + let subtitle = Subtitle { + id: Uuid::now_v7(), + media_id: MediaId(id), + language: req.language, + format, + file_path: req.file_path.map(std::path::PathBuf::from), + is_embedded, + track_index: req.track_index, + offset_ms: req.offset_ms.unwrap_or(0), + created_at: chrono::Utc::now(), + }; + state.storage.add_subtitle(&subtitle).await?; + Ok(Json(SubtitleResponse::from(subtitle))) } pub async fn delete_subtitle( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result, ApiError> { - state.storage.delete_subtitle(id).await?; - Ok(Json(serde_json::json!({"deleted": true}))) + state.storage.delete_subtitle(id).await?; + Ok(Json(serde_json::json!({"deleted": true}))) } pub async fn get_subtitle_content( - State(state): State, - Path((media_id, subtitle_id)): Path<(Uuid, Uuid)>, + State(state): State, + Path((media_id, subtitle_id)): Path<(Uuid, Uuid)>, ) -> Result { - let subtitles = state.storage.get_media_subtitles(MediaId(media_id)).await?; - let subtitle = subtitles - .into_iter() - .find(|s| s.id == subtitle_id) - .ok_or_else(|| { - ApiError(pinakes_core::error::PinakesError::NotFound(format!( - "subtitle {subtitle_id}" - ))) - })?; + let subtitles = state.storage.get_media_subtitles(MediaId(media_id)).await?; + let subtitle = subtitles + .into_iter() + .find(|s| s.id == subtitle_id) + .ok_or_else(|| { + ApiError(pinakes_core::error::PinakesError::NotFound(format!( + "subtitle {subtitle_id}" + ))) + })?; - if let Some(ref path) = subtitle.file_path { - let content = tokio::fs::read_to_string(path).await.map_err(|e| { - if e.kind() == std::io::ErrorKind::NotFound { - ApiError(pinakes_core::error::PinakesError::FileNotFound( - path.clone(), - )) - } else { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - format!("failed to read subtitle file {}: {}", path.display(), e), - )) - } - })?; - let content_type = match subtitle.format { - SubtitleFormat::Vtt => "text/vtt", - SubtitleFormat::Srt => "application/x-subrip", - _ => "text/plain", - }; - Ok(axum::response::Response::builder() - .header("Content-Type", content_type) - .body(axum::body::Body::from(content)) - .unwrap()) - } else { - Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( - "subtitle is embedded, no file to serve".into(), - ), + if let Some(ref path) = subtitle.file_path { + let content = tokio::fs::read_to_string(path).await.map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + ApiError(pinakes_core::error::PinakesError::FileNotFound( + path.clone(), )) - } + } else { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + format!("failed to read subtitle file {}: {}", path.display(), e), + )) + } + })?; + let content_type = match subtitle.format { + SubtitleFormat::Vtt => "text/vtt", + SubtitleFormat::Srt => "application/x-subrip", + _ => "text/plain", + }; + Ok( + axum::response::Response::builder() + .header("Content-Type", content_type) + .body(axum::body::Body::from(content)) + .unwrap(), + ) + } else { + Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "subtitle is embedded, no file to serve".into(), + ), + )) + } } pub async fn update_offset( - State(state): State, - Path(id): Path, - Json(req): Json, + State(state): State, + Path(id): Path, + Json(req): Json, ) -> Result, ApiError> { - state - .storage - .update_subtitle_offset(id, req.offset_ms) - .await?; - Ok(Json(serde_json::json!({"updated": true}))) + state + .storage + .update_subtitle_offset(id, req.offset_ms) + .await?; + Ok(Json(serde_json::json!({"updated": true}))) } diff --git a/crates/pinakes-server/src/routes/sync.rs b/crates/pinakes-server/src/routes/sync.rs index f31d08a..988b32e 100644 --- a/crates/pinakes-server/src/routes/sync.rs +++ b/crates/pinakes-server/src/routes/sync.rs @@ -1,30 +1,55 @@ +use std::path::Path as FilePath; + use axum::{ - Json, - body::Body, - extract::{Extension, Path, Query, State}, - http::{HeaderMap, StatusCode, header}, - response::IntoResponse, + Json, + body::Body, + extract::{Extension, Path, Query, State}, + http::{HeaderMap, StatusCode, header}, + response::IntoResponse, }; use chrono::Utc; +use pinakes_core::{ + config::ConflictResolution, + model::ContentHash, + sync::{ + DeviceId, + DeviceType, + SyncChangeType, + SyncConflict, + SyncDevice, + SyncLogEntry, + UploadSession, + UploadStatus, + generate_device_token, + hash_device_token, + update_device_cursor, + }, +}; use tokio_util::io::ReaderStream; use uuid::Uuid; -use crate::auth::resolve_user_id; -use crate::dto::{ - AcknowledgeChangesRequest, ChangesResponse, ChunkUploadedResponse, ConflictResponse, - CreateUploadSessionRequest, DeviceRegistrationResponse, DeviceResponse, GetChangesParams, - RegisterDeviceRequest, ReportChangesRequest, ReportChangesResponse, ResolveConflictRequest, - SyncChangeResponse, UpdateDeviceRequest, UploadSessionResponse, +use crate::{ + auth::resolve_user_id, + dto::{ + AcknowledgeChangesRequest, + ChangesResponse, + ChunkUploadedResponse, + ConflictResponse, + CreateUploadSessionRequest, + DeviceRegistrationResponse, + DeviceResponse, + GetChangesParams, + RegisterDeviceRequest, + ReportChangesRequest, + ReportChangesResponse, + ResolveConflictRequest, + SyncChangeResponse, + UpdateDeviceRequest, + UploadSessionResponse, + }, + error::{ApiError, ApiResult}, + state::AppState, }; -use crate::error::{ApiError, ApiResult}; -use crate::state::AppState; -use pinakes_core::config::ConflictResolution; -use pinakes_core::model::ContentHash; -use pinakes_core::sync::{ - DeviceId, DeviceType, SyncChangeType, SyncConflict, SyncDevice, SyncLogEntry, UploadSession, - UploadStatus, generate_device_token, hash_device_token, update_device_cursor, -}; -use std::path::Path as FilePath; const DEFAULT_CHUNK_SIZE: u64 = 4 * 1024 * 1024; // 4MB const DEFAULT_CHANGES_LIMIT: u64 = 100; @@ -32,820 +57,846 @@ const DEFAULT_CHANGES_LIMIT: u64 = 100; /// Register a new sync device /// POST /api/sync/devices pub async fn register_device( - State(state): State, - Extension(username): Extension, - Json(req): Json, + State(state): State, + Extension(username): Extension, + Json(req): Json, ) -> ApiResult> { - let config = state.config.read().await; - if !config.sync.enabled { - return Err(ApiError::bad_request("Sync is not enabled")); - } - drop(config); + let config = state.config.read().await; + if !config.sync.enabled { + return Err(ApiError::bad_request("Sync is not enabled")); + } + drop(config); - let user_id = resolve_user_id(&state.storage, &username).await?; + let user_id = resolve_user_id(&state.storage, &username).await?; - let device_type = req - .device_type - .parse::() - .map_err(|_| ApiError::bad_request("Invalid device type"))?; + let device_type = req + .device_type + .parse::() + .map_err(|_| ApiError::bad_request("Invalid device type"))?; - // Generate device token - let device_token = generate_device_token(); - let token_hash = hash_device_token(&device_token); + // Generate device token + let device_token = generate_device_token(); + let token_hash = hash_device_token(&device_token); - let now = Utc::now(); - let device = SyncDevice { - id: DeviceId(Uuid::now_v7()), - user_id, - name: req.name, - device_type, - client_version: req.client_version, - os_info: req.os_info, - last_sync_at: None, - last_seen_at: now, - sync_cursor: Some(0), - enabled: true, - created_at: now, - updated_at: now, - }; + let now = Utc::now(); + let device = SyncDevice { + id: DeviceId(Uuid::now_v7()), + user_id, + name: req.name, + device_type, + client_version: req.client_version, + os_info: req.os_info, + last_sync_at: None, + last_seen_at: now, + sync_cursor: Some(0), + enabled: true, + created_at: now, + updated_at: now, + }; - let registered = state - .storage - .register_device(&device, &token_hash) - .await - .map_err(|e| ApiError::internal(format!("Failed to register device: {}", e)))?; + let registered = state + .storage + .register_device(&device, &token_hash) + .await + .map_err(|e| { + ApiError::internal(format!("Failed to register device: {}", e)) + })?; - Ok(Json(DeviceRegistrationResponse { - device: registered.into(), - device_token, - })) + Ok(Json(DeviceRegistrationResponse { + device: registered.into(), + device_token, + })) } /// List user's sync devices /// GET /api/sync/devices pub async fn list_devices( - State(state): State, - Extension(username): Extension, + State(state): State, + Extension(username): Extension, ) -> ApiResult>> { - let user_id = resolve_user_id(&state.storage, &username).await?; - let devices = state - .storage - .list_user_devices(user_id) - .await - .map_err(|e| ApiError::internal(format!("Failed to list devices: {}", e)))?; + let user_id = resolve_user_id(&state.storage, &username).await?; + let devices = + state + .storage + .list_user_devices(user_id) + .await + .map_err(|e| { + ApiError::internal(format!("Failed to list devices: {}", e)) + })?; - Ok(Json(devices.into_iter().map(Into::into).collect())) + Ok(Json(devices.into_iter().map(Into::into).collect())) } /// Get device details /// GET /api/sync/devices/{id} pub async fn get_device( - State(state): State, - Extension(username): Extension, - Path(id): Path, + State(state): State, + Extension(username): Extension, + Path(id): Path, ) -> ApiResult> { - let user_id = resolve_user_id(&state.storage, &username).await?; - let device = state - .storage - .get_device(DeviceId(id)) - .await - .map_err(|e| ApiError::not_found(format!("Device not found: {}", e)))?; + let user_id = resolve_user_id(&state.storage, &username).await?; + let device = state + .storage + .get_device(DeviceId(id)) + .await + .map_err(|e| ApiError::not_found(format!("Device not found: {}", e)))?; - // Verify ownership - if device.user_id != user_id { - return Err(ApiError::forbidden("Not authorized to access this device")); - } + // Verify ownership + if device.user_id != user_id { + return Err(ApiError::forbidden("Not authorized to access this device")); + } - Ok(Json(device.into())) + Ok(Json(device.into())) } /// Update a device /// PUT /api/sync/devices/{id} pub async fn update_device( - State(state): State, - Extension(username): Extension, - Path(id): Path, - Json(req): Json, + State(state): State, + Extension(username): Extension, + Path(id): Path, + Json(req): Json, ) -> ApiResult> { - let user_id = resolve_user_id(&state.storage, &username).await?; - let mut device = state - .storage - .get_device(DeviceId(id)) - .await - .map_err(|e| ApiError::not_found(format!("Device not found: {}", e)))?; + let user_id = resolve_user_id(&state.storage, &username).await?; + let mut device = state + .storage + .get_device(DeviceId(id)) + .await + .map_err(|e| ApiError::not_found(format!("Device not found: {}", e)))?; - // Verify ownership - if device.user_id != user_id { - return Err(ApiError::forbidden("Not authorized to update this device")); - } + // Verify ownership + if device.user_id != user_id { + return Err(ApiError::forbidden("Not authorized to update this device")); + } - if let Some(name) = req.name { - device.name = name; - } - if let Some(enabled) = req.enabled { - device.enabled = enabled; - } + if let Some(name) = req.name { + device.name = name; + } + if let Some(enabled) = req.enabled { + device.enabled = enabled; + } - state - .storage - .update_device(&device) - .await - .map_err(|e| ApiError::internal(format!("Failed to update device: {}", e)))?; + state.storage.update_device(&device).await.map_err(|e| { + ApiError::internal(format!("Failed to update device: {}", e)) + })?; - Ok(Json(device.into())) + Ok(Json(device.into())) } /// Delete a device /// DELETE /api/sync/devices/{id} pub async fn delete_device( - State(state): State, - Extension(username): Extension, - Path(id): Path, + State(state): State, + Extension(username): Extension, + Path(id): Path, ) -> ApiResult { - let user_id = resolve_user_id(&state.storage, &username).await?; - let device = state - .storage - .get_device(DeviceId(id)) - .await - .map_err(|e| ApiError::not_found(format!("Device not found: {}", e)))?; + let user_id = resolve_user_id(&state.storage, &username).await?; + let device = state + .storage + .get_device(DeviceId(id)) + .await + .map_err(|e| ApiError::not_found(format!("Device not found: {}", e)))?; - // Verify ownership - if device.user_id != user_id { - return Err(ApiError::forbidden("Not authorized to delete this device")); - } + // Verify ownership + if device.user_id != user_id { + return Err(ApiError::forbidden("Not authorized to delete this device")); + } - state - .storage - .delete_device(DeviceId(id)) - .await - .map_err(|e| ApiError::internal(format!("Failed to delete device: {}", e)))?; + state + .storage + .delete_device(DeviceId(id)) + .await + .map_err(|e| { + ApiError::internal(format!("Failed to delete device: {}", e)) + })?; - Ok(StatusCode::NO_CONTENT) + Ok(StatusCode::NO_CONTENT) } /// Regenerate device token /// POST /api/sync/devices/{id}/token pub async fn regenerate_token( - State(state): State, - Extension(username): Extension, - Path(id): Path, + State(state): State, + Extension(username): Extension, + Path(id): Path, ) -> ApiResult> { - let user_id = resolve_user_id(&state.storage, &username).await?; - let device = state - .storage - .get_device(DeviceId(id)) - .await - .map_err(|e| ApiError::not_found(format!("Device not found: {}", e)))?; + let user_id = resolve_user_id(&state.storage, &username).await?; + let device = state + .storage + .get_device(DeviceId(id)) + .await + .map_err(|e| ApiError::not_found(format!("Device not found: {}", e)))?; - // Verify ownership - if device.user_id != user_id { - return Err(ApiError::forbidden( - "Not authorized to regenerate token for this device", - )); - } + // Verify ownership + if device.user_id != user_id { + return Err(ApiError::forbidden( + "Not authorized to regenerate token for this device", + )); + } - // Generate new token - let new_token = generate_device_token(); - let token_hash = hash_device_token(&new_token); + // Generate new token + let new_token = generate_device_token(); + let token_hash = hash_device_token(&new_token); - // Re-register with new token (this updates the token hash) - let updated = state - .storage - .register_device(&device, &token_hash) - .await - .map_err(|e| ApiError::internal(format!("Failed to regenerate token: {}", e)))?; + // Re-register with new token (this updates the token hash) + let updated = state + .storage + .register_device(&device, &token_hash) + .await + .map_err(|e| { + ApiError::internal(format!("Failed to regenerate token: {}", e)) + })?; - Ok(Json(DeviceRegistrationResponse { - device: updated.into(), - device_token: new_token, - })) + Ok(Json(DeviceRegistrationResponse { + device: updated.into(), + device_token: new_token, + })) } /// Get changes since cursor /// GET /api/sync/changes pub async fn get_changes( - State(state): State, - Query(params): Query, + State(state): State, + Query(params): Query, ) -> ApiResult> { - let config = state.config.read().await; - if !config.sync.enabled { - return Err(ApiError::bad_request("Sync is not enabled")); - } - drop(config); + let config = state.config.read().await; + if !config.sync.enabled { + return Err(ApiError::bad_request("Sync is not enabled")); + } + drop(config); - let cursor = params.cursor.unwrap_or(0); - let limit = params.limit.unwrap_or(DEFAULT_CHANGES_LIMIT); + let cursor = params.cursor.unwrap_or(0); + let limit = params.limit.unwrap_or(DEFAULT_CHANGES_LIMIT); - let changes = state - .storage - .get_changes_since(cursor, limit + 1) - .await - .map_err(|e| ApiError::internal(format!("Failed to get changes: {}", e)))?; + let changes = state + .storage + .get_changes_since(cursor, limit + 1) + .await + .map_err(|e| ApiError::internal(format!("Failed to get changes: {}", e)))?; - let has_more = changes.len() > limit as usize; - let changes: Vec = changes - .into_iter() - .take(limit as usize) - .map(Into::into) - .collect(); + let has_more = changes.len() > limit as usize; + let changes: Vec = changes + .into_iter() + .take(limit as usize) + .map(Into::into) + .collect(); - let new_cursor = changes.last().map(|c| c.sequence).unwrap_or(cursor); + let new_cursor = changes.last().map(|c| c.sequence).unwrap_or(cursor); - Ok(Json(ChangesResponse { - changes, - cursor: new_cursor, - has_more, - })) + Ok(Json(ChangesResponse { + changes, + cursor: new_cursor, + has_more, + })) } /// Report local changes from client /// POST /api/sync/report pub async fn report_changes( - State(state): State, - Extension(_username): Extension, - Json(req): Json, + State(state): State, + Extension(_username): Extension, + Json(req): Json, ) -> ApiResult> { - let config = state.config.read().await; - if !config.sync.enabled { - return Err(ApiError::bad_request("Sync is not enabled")); - } - let conflict_resolution = config.sync.default_conflict_resolution.clone(); - drop(config); + let config = state.config.read().await; + if !config.sync.enabled { + return Err(ApiError::bad_request("Sync is not enabled")); + } + let conflict_resolution = config.sync.default_conflict_resolution.clone(); + drop(config); - let mut accepted = Vec::new(); - let mut conflicts = Vec::new(); - let mut upload_required = Vec::new(); + let mut accepted = Vec::new(); + let mut conflicts = Vec::new(); + let mut upload_required = Vec::new(); - for change in req.changes { - // Check for conflicts - if let Some(content_hash) = &change.content_hash { - let server_state = state - .storage - .get_media_by_path(FilePath::new(&change.path)) - .await - .ok() - .flatten(); + for change in req.changes { + // Check for conflicts + if let Some(content_hash) = &change.content_hash { + let server_state = state + .storage + .get_media_by_path(FilePath::new(&change.path)) + .await + .ok() + .flatten(); - if let Some(server_item) = server_state { - let client_hash = ContentHash(content_hash.clone()); - if server_item.content_hash != client_hash { - // Conflict detected - let conflict = SyncConflict { - id: Uuid::now_v7(), - device_id: DeviceId(Uuid::nil()), // Will be set by device context - path: change.path.clone(), - local_hash: content_hash.clone(), - local_mtime: change.local_mtime.unwrap_or(0), - server_hash: server_item.content_hash.to_string(), - server_mtime: server_item.updated_at.timestamp(), - detected_at: Utc::now(), - resolved_at: None, - resolution: None, - }; + if let Some(server_item) = server_state { + let client_hash = ContentHash(content_hash.clone()); + if server_item.content_hash != client_hash { + // Conflict detected + let conflict = SyncConflict { + id: Uuid::now_v7(), + device_id: DeviceId(Uuid::nil()), /* Will be set by device + * context */ + path: change.path.clone(), + local_hash: content_hash.clone(), + local_mtime: change.local_mtime.unwrap_or(0), + server_hash: server_item.content_hash.to_string(), + server_mtime: server_item.updated_at.timestamp(), + detected_at: Utc::now(), + resolved_at: None, + resolution: None, + }; - // Auto-resolve if configured - match conflict_resolution { - ConflictResolution::ServerWins => { - // Client should download server version - accepted.push(change.path); - } - ConflictResolution::ClientWins => { - // Client should upload - upload_required.push(change.path); - } - ConflictResolution::KeepBoth | ConflictResolution::Manual => { - conflicts.push(conflict.into()); - } - } - continue; - } - } - } - - // No conflict, check if upload is needed - match change.change_type.as_str() { - "created" | "modified" => { - if change.content_hash.is_some() { - upload_required.push(change.path); - } else { - accepted.push(change.path); - } - } - "deleted" => { - // Record deletion - let entry = SyncLogEntry { - id: Uuid::now_v7(), - sequence: 0, // Will be assigned by storage - change_type: SyncChangeType::Deleted, - media_id: None, - path: change.path.clone(), - content_hash: None, - file_size: None, - metadata_json: None, - changed_by_device: None, - timestamp: Utc::now(), - }; - - if state.storage.record_sync_change(&entry).await.is_ok() { - accepted.push(change.path); - } - } - _ => { - accepted.push(change.path); - } + // Auto-resolve if configured + match conflict_resolution { + ConflictResolution::ServerWins => { + // Client should download server version + accepted.push(change.path); + }, + ConflictResolution::ClientWins => { + // Client should upload + upload_required.push(change.path); + }, + ConflictResolution::KeepBoth | ConflictResolution::Manual => { + conflicts.push(conflict.into()); + }, + } + continue; } + } } - Ok(Json(ReportChangesResponse { - accepted, - conflicts, - upload_required, - })) + // No conflict, check if upload is needed + match change.change_type.as_str() { + "created" | "modified" => { + if change.content_hash.is_some() { + upload_required.push(change.path); + } else { + accepted.push(change.path); + } + }, + "deleted" => { + // Record deletion + let entry = SyncLogEntry { + id: Uuid::now_v7(), + sequence: 0, // Will be assigned by storage + change_type: SyncChangeType::Deleted, + media_id: None, + path: change.path.clone(), + content_hash: None, + file_size: None, + metadata_json: None, + changed_by_device: None, + timestamp: Utc::now(), + }; + + if state.storage.record_sync_change(&entry).await.is_ok() { + accepted.push(change.path); + } + }, + _ => { + accepted.push(change.path); + }, + } + } + + Ok(Json(ReportChangesResponse { + accepted, + conflicts, + upload_required, + })) } /// Acknowledge processed changes /// POST /api/sync/ack pub async fn acknowledge_changes( - State(state): State, - Extension(_username): Extension, - headers: HeaderMap, - Json(req): Json, + State(state): State, + Extension(_username): Extension, + headers: HeaderMap, + Json(req): Json, ) -> ApiResult { - // Get device from header or context - let device_token = headers - .get("X-Device-Token") - .and_then(|v| v.to_str().ok()) - .ok_or_else(|| ApiError::bad_request("Missing X-Device-Token header"))?; + // Get device from header or context + let device_token = headers + .get("X-Device-Token") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| ApiError::bad_request("Missing X-Device-Token header"))?; - let token_hash = hash_device_token(device_token); - let device = state - .storage - .get_device_by_token(&token_hash) - .await - .map_err(|e| ApiError::internal(format!("Failed to get device: {}", e)))? - .ok_or_else(|| ApiError::unauthorized("Invalid device token"))?; + let token_hash = hash_device_token(device_token); + let device = state + .storage + .get_device_by_token(&token_hash) + .await + .map_err(|e| ApiError::internal(format!("Failed to get device: {}", e)))? + .ok_or_else(|| ApiError::unauthorized("Invalid device token"))?; - // Update device cursor - update_device_cursor(&state.storage, device.id, req.cursor) - .await - .map_err(|e| ApiError::internal(format!("Failed to update cursor: {}", e)))?; + // Update device cursor + update_device_cursor(&state.storage, device.id, req.cursor) + .await + .map_err(|e| { + ApiError::internal(format!("Failed to update cursor: {}", e)) + })?; - Ok(StatusCode::OK) + Ok(StatusCode::OK) } /// List unresolved conflicts /// GET /api/sync/conflicts pub async fn list_conflicts( - State(state): State, - Extension(_username): Extension, - headers: HeaderMap, + State(state): State, + Extension(_username): Extension, + headers: HeaderMap, ) -> ApiResult>> { - let device_token = headers - .get("X-Device-Token") - .and_then(|v| v.to_str().ok()) - .ok_or_else(|| ApiError::bad_request("Missing X-Device-Token header"))?; + let device_token = headers + .get("X-Device-Token") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| ApiError::bad_request("Missing X-Device-Token header"))?; - let token_hash = hash_device_token(device_token); - let device = state - .storage - .get_device_by_token(&token_hash) - .await - .map_err(|e| ApiError::internal(format!("Failed to get device: {}", e)))? - .ok_or_else(|| ApiError::unauthorized("Invalid device token"))?; + let token_hash = hash_device_token(device_token); + let device = state + .storage + .get_device_by_token(&token_hash) + .await + .map_err(|e| ApiError::internal(format!("Failed to get device: {}", e)))? + .ok_or_else(|| ApiError::unauthorized("Invalid device token"))?; - let conflicts = state - .storage - .get_unresolved_conflicts(device.id) - .await - .map_err(|e| ApiError::internal(format!("Failed to get conflicts: {}", e)))?; + let conflicts = state + .storage + .get_unresolved_conflicts(device.id) + .await + .map_err(|e| { + ApiError::internal(format!("Failed to get conflicts: {}", e)) + })?; - Ok(Json(conflicts.into_iter().map(Into::into).collect())) + Ok(Json(conflicts.into_iter().map(Into::into).collect())) } /// Resolve a sync conflict /// POST /api/sync/conflicts/{id}/resolve pub async fn resolve_conflict( - State(state): State, - Extension(_username): Extension, - Path(id): Path, - Json(req): Json, + State(state): State, + Extension(_username): Extension, + Path(id): Path, + Json(req): Json, ) -> ApiResult { - let resolution = match req.resolution.as_str() { - "server_wins" => ConflictResolution::ServerWins, - "client_wins" => ConflictResolution::ClientWins, - "keep_both" => ConflictResolution::KeepBoth, - _ => return Err(ApiError::bad_request("Invalid resolution type")), - }; + let resolution = match req.resolution.as_str() { + "server_wins" => ConflictResolution::ServerWins, + "client_wins" => ConflictResolution::ClientWins, + "keep_both" => ConflictResolution::KeepBoth, + _ => return Err(ApiError::bad_request("Invalid resolution type")), + }; - state - .storage - .resolve_conflict(id, resolution) - .await - .map_err(|e| ApiError::internal(format!("Failed to resolve conflict: {}", e)))?; + state + .storage + .resolve_conflict(id, resolution) + .await + .map_err(|e| { + ApiError::internal(format!("Failed to resolve conflict: {}", e)) + })?; - Ok(StatusCode::OK) + Ok(StatusCode::OK) } /// Create an upload session for chunked upload /// POST /api/sync/upload pub async fn create_upload( - State(state): State, - Extension(_username): Extension, - headers: HeaderMap, - Json(req): Json, + State(state): State, + Extension(_username): Extension, + headers: HeaderMap, + Json(req): Json, ) -> ApiResult> { - let config = state.config.read().await; - if !config.sync.enabled { - return Err(ApiError::bad_request("Sync is not enabled")); - } - let upload_timeout_hours = config.sync.upload_timeout_hours; - drop(config); + let config = state.config.read().await; + if !config.sync.enabled { + return Err(ApiError::bad_request("Sync is not enabled")); + } + let upload_timeout_hours = config.sync.upload_timeout_hours; + drop(config); - let device_token = headers - .get("X-Device-Token") - .and_then(|v| v.to_str().ok()) - .ok_or_else(|| ApiError::bad_request("Missing X-Device-Token header"))?; + let device_token = headers + .get("X-Device-Token") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| ApiError::bad_request("Missing X-Device-Token header"))?; - let token_hash = hash_device_token(device_token); - let device = state - .storage - .get_device_by_token(&token_hash) - .await - .map_err(|e| ApiError::internal(format!("Failed to get device: {}", e)))? - .ok_or_else(|| ApiError::unauthorized("Invalid device token"))?; + let token_hash = hash_device_token(device_token); + let device = state + .storage + .get_device_by_token(&token_hash) + .await + .map_err(|e| ApiError::internal(format!("Failed to get device: {}", e)))? + .ok_or_else(|| ApiError::unauthorized("Invalid device token"))?; - let chunk_size = req.chunk_size.unwrap_or(DEFAULT_CHUNK_SIZE); - let chunk_count = (req.expected_size + chunk_size - 1) / chunk_size; - let now = Utc::now(); + let chunk_size = req.chunk_size.unwrap_or(DEFAULT_CHUNK_SIZE); + let chunk_count = (req.expected_size + chunk_size - 1) / chunk_size; + let now = Utc::now(); - let session = UploadSession { - id: Uuid::now_v7(), - device_id: device.id, - target_path: req.target_path, - expected_hash: ContentHash(req.expected_hash), - expected_size: req.expected_size, - chunk_size, - chunk_count, - status: UploadStatus::Pending, - created_at: now, - expires_at: now + chrono::Duration::hours(upload_timeout_hours as i64), - last_activity: now, - }; + let session = UploadSession { + id: Uuid::now_v7(), + device_id: device.id, + target_path: req.target_path, + expected_hash: ContentHash(req.expected_hash), + expected_size: req.expected_size, + chunk_size, + chunk_count, + status: UploadStatus::Pending, + created_at: now, + expires_at: now + chrono::Duration::hours(upload_timeout_hours as i64), + last_activity: now, + }; - state - .storage - .create_upload_session(&session) - .await - .map_err(|e| ApiError::internal(format!("Failed to create upload session: {}", e)))?; + state + .storage + .create_upload_session(&session) + .await + .map_err(|e| { + ApiError::internal(format!("Failed to create upload session: {}", e)) + })?; - // Create temp file for chunked upload if manager is available - if let Some(ref manager) = state.chunked_upload_manager { - manager - .create_temp_file(&session) - .await - .map_err(|e| ApiError::internal(format!("Failed to create temp file: {}", e)))?; - } + // Create temp file for chunked upload if manager is available + if let Some(ref manager) = state.chunked_upload_manager { + manager.create_temp_file(&session).await.map_err(|e| { + ApiError::internal(format!("Failed to create temp file: {}", e)) + })?; + } - Ok(Json(session.into())) + Ok(Json(session.into())) } /// Upload a chunk /// PUT /api/sync/upload/{id}/chunks/{index} pub async fn upload_chunk( - State(state): State, - Path((session_id, chunk_index)): Path<(Uuid, u64)>, - _headers: HeaderMap, - body: axum::body::Bytes, + State(state): State, + Path((session_id, chunk_index)): Path<(Uuid, u64)>, + _headers: HeaderMap, + body: axum::body::Bytes, ) -> ApiResult> { - let session = state - .storage - .get_upload_session(session_id) - .await - .map_err(|e| ApiError::not_found(format!("Upload session not found: {}", e)))?; - - if session.status == UploadStatus::Expired { - return Err(ApiError::bad_request("Upload session has expired")); - } - - if chunk_index >= session.chunk_count { - return Err(ApiError::bad_request("Invalid chunk index")); - } - - // Require chunked upload manager to be available - let manager = state - .chunked_upload_manager - .as_ref() - .ok_or_else(|| ApiError::internal("Chunked upload manager not available"))?; - - // Write chunk data to temp file - let chunk = manager - .write_chunk(&session, chunk_index, body.as_ref()) - .await - .map_err(|e| ApiError::internal(format!("Failed to write chunk: {}", e)))?; - - // Record chunk metadata in database + let session = state - .storage - .record_chunk(session_id, &chunk) - .await - .map_err(|e| ApiError::internal(format!("Failed to record chunk: {}", e)))?; + .storage + .get_upload_session(session_id) + .await + .map_err(|e| { + ApiError::not_found(format!("Upload session not found: {}", e)) + })?; - Ok(Json(ChunkUploadedResponse { - chunk_index, - received: true, - })) + if session.status == UploadStatus::Expired { + return Err(ApiError::bad_request("Upload session has expired")); + } + + if chunk_index >= session.chunk_count { + return Err(ApiError::bad_request("Invalid chunk index")); + } + + // Require chunked upload manager to be available + let manager = state.chunked_upload_manager.as_ref().ok_or_else(|| { + ApiError::internal("Chunked upload manager not available") + })?; + + // Write chunk data to temp file + let chunk = manager + .write_chunk(&session, chunk_index, body.as_ref()) + .await + .map_err(|e| ApiError::internal(format!("Failed to write chunk: {}", e)))?; + + // Record chunk metadata in database + state + .storage + .record_chunk(session_id, &chunk) + .await + .map_err(|e| { + ApiError::internal(format!("Failed to record chunk: {}", e)) + })?; + + Ok(Json(ChunkUploadedResponse { + chunk_index, + received: true, + })) } /// Get upload session status /// GET /api/sync/upload/{id} pub async fn get_upload_status( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> ApiResult> { - let session = state - .storage - .get_upload_session(id) - .await - .map_err(|e| ApiError::not_found(format!("Upload session not found: {}", e)))?; + let session = state.storage.get_upload_session(id).await.map_err(|e| { + ApiError::not_found(format!("Upload session not found: {}", e)) + })?; - Ok(Json(session.into())) + Ok(Json(session.into())) } /// Complete an upload session /// POST /api/sync/upload/{id}/complete pub async fn complete_upload( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> ApiResult { - let mut session = state - .storage - .get_upload_session(id) - .await - .map_err(|e| ApiError::not_found(format!("Upload session not found: {}", e)))?; + let mut session = + state.storage.get_upload_session(id).await.map_err(|e| { + ApiError::not_found(format!("Upload session not found: {}", e)) + })?; - // Verify all chunks received - let chunks = state - .storage - .get_upload_chunks(id) - .await - .map_err(|e| ApiError::internal(format!("Failed to get chunks: {}", e)))?; + // Verify all chunks received + let chunks = + state.storage.get_upload_chunks(id).await.map_err(|e| { + ApiError::internal(format!("Failed to get chunks: {}", e)) + })?; - if chunks.len() != session.chunk_count as usize { - return Err(ApiError::bad_request(format!( - "Missing chunks: expected {}, got {}", - session.chunk_count, - chunks.len() - ))); - } + if chunks.len() != session.chunk_count as usize { + return Err(ApiError::bad_request(format!( + "Missing chunks: expected {}, got {}", + session.chunk_count, + chunks.len() + ))); + } - // Require chunked upload manager to be available - let manager = state - .chunked_upload_manager - .as_ref() - .ok_or_else(|| ApiError::internal("Chunked upload manager not available"))?; + // Require chunked upload manager to be available + let manager = state.chunked_upload_manager.as_ref().ok_or_else(|| { + ApiError::internal("Chunked upload manager not available") + })?; - // Verify and finalize the temp file - let temp_path = manager - .finalize(&session, &chunks) - .await - .map_err(|e| ApiError::internal(format!("Failed to finalize upload: {}", e)))?; + // Verify and finalize the temp file + let temp_path = manager.finalize(&session, &chunks).await.map_err(|e| { + ApiError::internal(format!("Failed to finalize upload: {}", e)) + })?; - // Validate and resolve target path securely - let target_path = std::path::Path::new(&session.target_path); + // Validate and resolve target path securely + let target_path = std::path::Path::new(&session.target_path); - // Reject absolute paths from client - if target_path.is_absolute() { - return Err(ApiError::bad_request("Absolute paths are not allowed")); - } + // Reject absolute paths from client + if target_path.is_absolute() { + return Err(ApiError::bad_request("Absolute paths are not allowed")); + } - // Reject empty paths - if target_path.as_os_str().is_empty() { - return Err(ApiError::bad_request("Empty target path not allowed")); - } + // Reject empty paths + if target_path.as_os_str().is_empty() { + return Err(ApiError::bad_request("Empty target path not allowed")); + } - // Get root directory from config - let config = state.config.read().await; - let root = config - .directories - .roots - .first() - .cloned() - .ok_or_else(|| ApiError::internal("No root directory configured"))?; - drop(config); + // Get root directory from config + let config = state.config.read().await; + let root = config + .directories + .roots + .first() + .cloned() + .ok_or_else(|| ApiError::internal("No root directory configured"))?; + drop(config); - // Build candidate path - let candidate = root.join(target_path); + // Build candidate path + let candidate = root.join(target_path); - // Canonicalize root to resolve symlinks and get absolute path - let root_canon = tokio::fs::canonicalize(&root) - .await - .map_err(|e| ApiError::internal(format!("Failed to canonicalize root: {}", e)))?; + // Canonicalize root to resolve symlinks and get absolute path + let root_canon = tokio::fs::canonicalize(&root).await.map_err(|e| { + ApiError::internal(format!("Failed to canonicalize root: {}", e)) + })?; - // Ensure parent directory exists before canonicalizing candidate - if let Some(parent) = candidate.parent() { - tokio::fs::create_dir_all(parent) - .await - .map_err(|e| ApiError::internal(format!("Failed to create directory: {}", e)))?; - } + // Ensure parent directory exists before canonicalizing candidate + if let Some(parent) = candidate.parent() { + tokio::fs::create_dir_all(parent).await.map_err(|e| { + ApiError::internal(format!("Failed to create directory: {}", e)) + })?; + } - // Try to canonicalize the candidate path (without the final file) - // If it exists, canonicalize it; otherwise canonicalize parent and append filename - let final_path = if let Ok(canon) = tokio::fs::canonicalize(&candidate).await { - canon - } else if let Some(parent) = candidate.parent() { - let parent_canon = tokio::fs::canonicalize(parent) - .await - .map_err(|e| ApiError::internal(format!("Failed to canonicalize parent: {}", e)))?; + // Try to canonicalize the candidate path (without the final file) + // If it exists, canonicalize it; otherwise canonicalize parent and append + // filename + let final_path = if let Ok(canon) = tokio::fs::canonicalize(&candidate).await + { + canon + } else if let Some(parent) = candidate.parent() { + let parent_canon = tokio::fs::canonicalize(parent).await.map_err(|e| { + ApiError::internal(format!("Failed to canonicalize parent: {}", e)) + })?; - if let Some(filename) = candidate.file_name() { - parent_canon.join(filename) - } else { - return Err(ApiError::bad_request("Invalid target path")); - } + if let Some(filename) = candidate.file_name() { + parent_canon.join(filename) } else { - return Err(ApiError::bad_request("Invalid target path")); - }; - - // Ensure resolved path is still under root (path traversal check) - if !final_path.starts_with(&root_canon) { - return Err(ApiError::bad_request("Path traversal not allowed")); + return Err(ApiError::bad_request("Invalid target path")); } + } else { + return Err(ApiError::bad_request("Invalid target path")); + }; - // Move temp file to final location (with cross-filesystem fallback) - if let Err(e) = tokio::fs::rename(&temp_path, &final_path).await { - // Check for cross-filesystem error - if e.kind() == std::io::ErrorKind::CrossesDevices || e.raw_os_error() == Some(18) - // EXDEV on Linux - { - // Fallback: copy then remove - tokio::fs::copy(&temp_path, &final_path) - .await - .map_err(|e| ApiError::internal(format!("Failed to copy file: {}", e)))?; + // Ensure resolved path is still under root (path traversal check) + if !final_path.starts_with(&root_canon) { + return Err(ApiError::bad_request("Path traversal not allowed")); + } - let _ = tokio::fs::remove_file(&temp_path).await; - } else { - return Err(ApiError::internal(format!("Failed to move file: {}", e))); - } + // Move temp file to final location (with cross-filesystem fallback) + if let Err(e) = tokio::fs::rename(&temp_path, &final_path).await { + // Check for cross-filesystem error + if e.kind() == std::io::ErrorKind::CrossesDevices + || e.raw_os_error() == Some(18) + // EXDEV on Linux + { + // Fallback: copy then remove + tokio::fs::copy(&temp_path, &final_path) + .await + .map_err(|e| { + ApiError::internal(format!("Failed to copy file: {}", e)) + })?; + + let _ = tokio::fs::remove_file(&temp_path).await; + } else { + return Err(ApiError::internal(format!("Failed to move file: {}", e))); } + } - tracing::info!( - session_id = %id, - target = %final_path.display(), - "completed chunked upload" - ); + tracing::info!( + session_id = %id, + target = %final_path.display(), + "completed chunked upload" + ); - // Mark session as completed - session.status = UploadStatus::Completed; - state - .storage - .update_upload_session(&session) - .await - .map_err(|e| ApiError::internal(format!("Failed to update session: {}", e)))?; + // Mark session as completed + session.status = UploadStatus::Completed; + state + .storage + .update_upload_session(&session) + .await + .map_err(|e| { + ApiError::internal(format!("Failed to update session: {}", e)) + })?; - // Record the sync change - let entry = SyncLogEntry { - id: Uuid::now_v7(), - sequence: 0, - change_type: SyncChangeType::Created, - media_id: None, - path: session.target_path.clone(), - content_hash: Some(session.expected_hash.clone()), - file_size: Some(session.expected_size), - metadata_json: None, - changed_by_device: Some(session.device_id), - timestamp: Utc::now(), - }; + // Record the sync change + let entry = SyncLogEntry { + id: Uuid::now_v7(), + sequence: 0, + change_type: SyncChangeType::Created, + media_id: None, + path: session.target_path.clone(), + content_hash: Some(session.expected_hash.clone()), + file_size: Some(session.expected_size), + metadata_json: None, + changed_by_device: Some(session.device_id), + timestamp: Utc::now(), + }; - state - .storage - .record_sync_change(&entry) - .await - .map_err(|e| ApiError::internal(format!("Failed to record change: {}", e)))?; + state + .storage + .record_sync_change(&entry) + .await + .map_err(|e| { + ApiError::internal(format!("Failed to record change: {}", e)) + })?; - Ok(StatusCode::OK) + Ok(StatusCode::OK) } /// Cancel an upload session /// DELETE /api/sync/upload/{id} pub async fn cancel_upload( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> ApiResult { - let mut session = state - .storage - .get_upload_session(id) - .await - .map_err(|e| ApiError::not_found(format!("Upload session not found: {}", e)))?; + let mut session = + state.storage.get_upload_session(id).await.map_err(|e| { + ApiError::not_found(format!("Upload session not found: {}", e)) + })?; - // Clean up temp file if manager is available - if let Some(ref manager) = state.chunked_upload_manager { - if let Err(e) = manager.cancel(id).await { - tracing::warn!(session_id = %id, error = %e, "failed to clean up temp file"); - } + // Clean up temp file if manager is available + if let Some(ref manager) = state.chunked_upload_manager { + if let Err(e) = manager.cancel(id).await { + tracing::warn!(session_id = %id, error = %e, "failed to clean up temp file"); } + } - session.status = UploadStatus::Cancelled; - state - .storage - .update_upload_session(&session) - .await - .map_err(|e| ApiError::internal(format!("Failed to cancel session: {}", e)))?; + session.status = UploadStatus::Cancelled; + state + .storage + .update_upload_session(&session) + .await + .map_err(|e| { + ApiError::internal(format!("Failed to cancel session: {}", e)) + })?; - Ok(StatusCode::NO_CONTENT) + Ok(StatusCode::NO_CONTENT) } /// Download a file for sync (supports Range header) /// GET /api/sync/download/{*path} pub async fn download_file( - State(state): State, - Path(path): Path, - headers: HeaderMap, + State(state): State, + Path(path): Path, + headers: HeaderMap, ) -> ApiResult { - let item = state - .storage - .get_media_by_path(FilePath::new(&path)) - .await - .map_err(|e| ApiError::internal(format!("Failed to get media: {}", e)))? - .ok_or_else(|| ApiError::not_found("File not found"))?; + let item = state + .storage + .get_media_by_path(FilePath::new(&path)) + .await + .map_err(|e| ApiError::internal(format!("Failed to get media: {}", e)))? + .ok_or_else(|| ApiError::not_found("File not found"))?; - let file = tokio::fs::File::open(&item.path) - .await - .map_err(|e| ApiError::not_found(format!("File not found: {}", e)))?; + let file = tokio::fs::File::open(&item.path) + .await + .map_err(|e| ApiError::not_found(format!("File not found: {}", e)))?; - let metadata = file - .metadata() - .await - .map_err(|e| ApiError::internal(format!("Failed to get metadata: {}", e)))?; + let metadata = file.metadata().await.map_err(|e| { + ApiError::internal(format!("Failed to get metadata: {}", e)) + })?; - let file_size = metadata.len(); + let file_size = metadata.len(); - // Check for Range header - if let Some(range_header) = headers.get(header::RANGE) { - if let Ok(range_str) = range_header.to_str() { - if let Some(range) = parse_range_header(range_str, file_size) { - // Partial content response - let (start, end) = range; - let length = end - start + 1; + // Check for Range header + if let Some(range_header) = headers.get(header::RANGE) { + if let Ok(range_str) = range_header.to_str() { + if let Some(range) = parse_range_header(range_str, file_size) { + // Partial content response + let (start, end) = range; + let length = end - start + 1; - let file = tokio::fs::File::open(&item.path) - .await - .map_err(|e| ApiError::internal(format!("Failed to reopen file: {}", e)))?; + let file = tokio::fs::File::open(&item.path).await.map_err(|e| { + ApiError::internal(format!("Failed to reopen file: {}", e)) + })?; - let stream = ReaderStream::new(file); - let body = Body::from_stream(stream); + let stream = ReaderStream::new(file); + let body = Body::from_stream(stream); - return Ok(( - StatusCode::PARTIAL_CONTENT, - [ - (header::CONTENT_TYPE, item.media_type.mime_type()), - (header::CONTENT_LENGTH, length.to_string()), - ( - header::CONTENT_RANGE, - format!("bytes {}-{}/{}", start, end, file_size), - ), - (header::ACCEPT_RANGES, "bytes".to_string()), - ], - body, - ) - .into_response()); - } - } + return Ok( + ( + StatusCode::PARTIAL_CONTENT, + [ + (header::CONTENT_TYPE, item.media_type.mime_type()), + (header::CONTENT_LENGTH, length.to_string()), + ( + header::CONTENT_RANGE, + format!("bytes {}-{}/{}", start, end, file_size), + ), + (header::ACCEPT_RANGES, "bytes".to_string()), + ], + body, + ) + .into_response(), + ); + } } + } - // Full content response - let stream = ReaderStream::new(file); - let body = Body::from_stream(stream); + // Full content response + let stream = ReaderStream::new(file); + let body = Body::from_stream(stream); - Ok(( - StatusCode::OK, - [ - (header::CONTENT_TYPE, item.media_type.mime_type()), - (header::CONTENT_LENGTH, file_size.to_string()), - (header::ACCEPT_RANGES, "bytes".to_string()), - ], - body, + Ok( + ( + StatusCode::OK, + [ + (header::CONTENT_TYPE, item.media_type.mime_type()), + (header::CONTENT_LENGTH, file_size.to_string()), + (header::ACCEPT_RANGES, "bytes".to_string()), + ], + body, ) - .into_response()) + .into_response(), + ) } /// Parse HTTP Range header fn parse_range_header(range: &str, file_size: u64) -> Option<(u64, u64)> { - let range = range.strip_prefix("bytes=")?; - let parts: Vec<&str> = range.split('-').collect(); - if parts.len() != 2 { - return None; - } + let range = range.strip_prefix("bytes=")?; + let parts: Vec<&str> = range.split('-').collect(); + if parts.len() != 2 { + return None; + } - let start: u64 = parts[0].parse().ok()?; - let end: u64 = if parts[1].is_empty() { - file_size - 1 - } else { - parts[1].parse().ok()? - }; + let start: u64 = parts[0].parse().ok()?; + let end: u64 = if parts[1].is_empty() { + file_size - 1 + } else { + parts[1].parse().ok()? + }; - if start > end || end >= file_size { - return None; - } + if start > end || end >= file_size { + return None; + } - Some((start, end)) + Some((start, end)) } diff --git a/crates/pinakes-server/src/routes/tags.rs b/crates/pinakes-server/src/routes/tags.rs index 9bfec60..20dbba0 100644 --- a/crates/pinakes-server/src/routes/tags.rs +++ b/crates/pinakes-server/src/routes/tags.rs @@ -1,70 +1,75 @@ -use axum::Json; -use axum::extract::{Path, State}; +use axum::{ + Json, + extract::{Path, State}, +}; +use pinakes_core::model::MediaId; use uuid::Uuid; -use crate::dto::*; -use crate::error::ApiError; -use crate::state::AppState; - -use pinakes_core::model::MediaId; +use crate::{dto::*, error::ApiError, state::AppState}; pub async fn create_tag( - State(state): State, - Json(req): Json, + State(state): State, + Json(req): Json, ) -> Result, ApiError> { - if req.name.is_empty() || req.name.len() > 255 { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( - "tag name must be 1-255 characters".into(), - ), - )); - } - let tag = pinakes_core::tags::create_tag(&state.storage, &req.name, req.parent_id).await?; - Ok(Json(TagResponse::from(tag))) + if req.name.is_empty() || req.name.len() > 255 { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "tag name must be 1-255 characters".into(), + ), + )); + } + let tag = + pinakes_core::tags::create_tag(&state.storage, &req.name, req.parent_id) + .await?; + Ok(Json(TagResponse::from(tag))) } -pub async fn list_tags(State(state): State) -> Result>, ApiError> { - let tags = state.storage.list_tags().await?; - Ok(Json(tags.into_iter().map(TagResponse::from).collect())) +pub async fn list_tags( + State(state): State, +) -> Result>, ApiError> { + let tags = state.storage.list_tags().await?; + Ok(Json(tags.into_iter().map(TagResponse::from).collect())) } pub async fn get_tag( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result, ApiError> { - let tag = state.storage.get_tag(id).await?; - Ok(Json(TagResponse::from(tag))) + let tag = state.storage.get_tag(id).await?; + Ok(Json(TagResponse::from(tag))) } pub async fn delete_tag( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result, ApiError> { - state.storage.delete_tag(id).await?; - Ok(Json(serde_json::json!({"deleted": true}))) + state.storage.delete_tag(id).await?; + Ok(Json(serde_json::json!({"deleted": true}))) } pub async fn tag_media( - State(state): State, - Path(media_id): Path, - Json(req): Json, + State(state): State, + Path(media_id): Path, + Json(req): Json, ) -> Result, ApiError> { - pinakes_core::tags::tag_media(&state.storage, MediaId(media_id), req.tag_id).await?; - Ok(Json(serde_json::json!({"tagged": true}))) + pinakes_core::tags::tag_media(&state.storage, MediaId(media_id), req.tag_id) + .await?; + Ok(Json(serde_json::json!({"tagged": true}))) } pub async fn untag_media( - State(state): State, - Path((media_id, tag_id)): Path<(Uuid, Uuid)>, + State(state): State, + Path((media_id, tag_id)): Path<(Uuid, Uuid)>, ) -> Result, ApiError> { - pinakes_core::tags::untag_media(&state.storage, MediaId(media_id), tag_id).await?; - Ok(Json(serde_json::json!({"untagged": true}))) + pinakes_core::tags::untag_media(&state.storage, MediaId(media_id), tag_id) + .await?; + Ok(Json(serde_json::json!({"untagged": true}))) } pub async fn get_media_tags( - State(state): State, - Path(media_id): Path, + State(state): State, + Path(media_id): Path, ) -> Result>, ApiError> { - let tags = state.storage.get_media_tags(MediaId(media_id)).await?; - Ok(Json(tags.into_iter().map(TagResponse::from).collect())) + let tags = state.storage.get_media_tags(MediaId(media_id)).await?; + Ok(Json(tags.into_iter().map(TagResponse::from).collect())) } diff --git a/crates/pinakes-server/src/routes/transcode.rs b/crates/pinakes-server/src/routes/transcode.rs index 902f3b7..a4af624 100644 --- a/crates/pinakes-server/src/routes/transcode.rs +++ b/crates/pinakes-server/src/routes/transcode.rs @@ -1,63 +1,66 @@ -use axum::Json; -use axum::extract::{Path, Query, State}; +use axum::{ + Json, + extract::{Path, Query, State}, +}; +use pinakes_core::model::MediaId; use uuid::Uuid; -use crate::dto::*; -use crate::error::ApiError; -use crate::state::AppState; - -use pinakes_core::model::MediaId; +use crate::{dto::*, error::ApiError, state::AppState}; pub async fn start_transcode( - State(state): State, - Path(id): Path, - Json(req): Json, + State(state): State, + Path(id): Path, + Json(req): Json, ) -> Result, ApiError> { - let job_id = state - .job_queue - .submit(pinakes_core::jobs::JobKind::Transcode { - media_id: MediaId(id), - profile: req.profile, - }) - .await; - Ok(Json(serde_json::json!({"job_id": job_id.to_string()}))) + let job_id = state + .job_queue + .submit(pinakes_core::jobs::JobKind::Transcode { + media_id: MediaId(id), + profile: req.profile, + }) + .await; + Ok(Json(serde_json::json!({"job_id": job_id.to_string()}))) } pub async fn get_session( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result, ApiError> { - let session = state.storage.get_transcode_session(id).await?; - Ok(Json(TranscodeSessionResponse::from(session))) + let session = state.storage.get_transcode_session(id).await?; + Ok(Json(TranscodeSessionResponse::from(session))) } pub async fn list_sessions( - State(state): State, - Query(params): Query, + State(state): State, + Query(params): Query, ) -> Result>, ApiError> { - let _ = params; // reserved for future filtering - let sessions = state.storage.list_transcode_sessions(None).await?; - Ok(Json( - sessions - .into_iter() - .map(TranscodeSessionResponse::from) - .collect(), - )) + let _ = params; // reserved for future filtering + let sessions = state.storage.list_transcode_sessions(None).await?; + Ok(Json( + sessions + .into_iter() + .map(TranscodeSessionResponse::from) + .collect(), + )) } pub async fn cancel_session( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result, ApiError> { - if let Some(transcode_service) = &state.transcode_service { - transcode_service - .cancel_transcode(id, &state.storage) - .await?; - } else { - state - .storage - .update_transcode_status(id, pinakes_core::transcode::TranscodeStatus::Cancelled, 0.0) - .await?; - } - Ok(Json(serde_json::json!({"cancelled": true}))) + if let Some(transcode_service) = &state.transcode_service { + transcode_service + .cancel_transcode(id, &state.storage) + .await?; + } else { + state + .storage + .update_transcode_status( + id, + pinakes_core::transcode::TranscodeStatus::Cancelled, + 0.0, + ) + .await?; + } + Ok(Json(serde_json::json!({"cancelled": true}))) } diff --git a/crates/pinakes-server/src/routes/upload.rs b/crates/pinakes-server/src/routes/upload.rs index 56d2279..941bb1a 100644 --- a/crates/pinakes-server/src/routes/upload.rs +++ b/crates/pinakes-server/src/routes/upload.rs @@ -1,120 +1,97 @@ use axum::{ - Json, - extract::{Multipart, Path, State}, - http::{StatusCode, header}, - response::IntoResponse, + Json, + extract::{Multipart, Path, State}, + http::{StatusCode, header}, + response::IntoResponse, }; +use pinakes_core::{model::MediaId, upload}; use tokio_util::io::ReaderStream; use uuid::Uuid; -use crate::dto::{ManagedStorageStatsResponse, UploadResponse}; -use crate::error::{ApiError, ApiResult}; -use crate::state::AppState; -use pinakes_core::model::MediaId; -use pinakes_core::upload; +use crate::{ + dto::{ManagedStorageStatsResponse, UploadResponse}, + error::{ApiError, ApiResult}, + state::AppState, +}; /// Upload a file to managed storage /// POST /api/upload pub async fn upload_file( - State(state): State, - mut multipart: Multipart, + State(state): State, + mut multipart: Multipart, ) -> ApiResult> { - let managed_storage = state - .managed_storage - .as_ref() - .ok_or_else(|| ApiError::bad_request("Managed storage is not enabled"))?; + let managed_storage = state + .managed_storage + .as_ref() + .ok_or_else(|| ApiError::bad_request("Managed storage is not enabled"))?; - let config = state.config.read().await; - if !config.managed_storage.enabled { - return Err(ApiError::bad_request("Managed storage is not enabled")); - } - drop(config); + let config = state.config.read().await; + if !config.managed_storage.enabled { + return Err(ApiError::bad_request("Managed storage is not enabled")); + } + drop(config); - // Extract file from multipart - let field = multipart - .next_field() - .await - .map_err(|e| ApiError::bad_request(format!("Failed to read multipart field: {}", e)))? - .ok_or_else(|| ApiError::bad_request("No file provided"))?; - - let original_filename = field - .file_name() - .map(|s| s.to_string()) - .unwrap_or_else(|| "unknown".to_string()); - - let content_type = field - .content_type() - .map(|s| s.to_string()) - .unwrap_or_else(|| "application/octet-stream".to_string()); - - let data = field - .bytes() - .await - .map_err(|e| ApiError::bad_request(format!("Failed to read file data: {}", e)))?; - - // Process the upload - let result = upload::process_upload_bytes( - &state.storage, - managed_storage.as_ref(), - &data, - &original_filename, - Some(&content_type), - ) + // Extract file from multipart + let field = multipart + .next_field() .await - .map_err(|e| ApiError::internal(format!("Upload failed: {}", e)))?; + .map_err(|e| { + ApiError::bad_request(format!("Failed to read multipart field: {}", e)) + })? + .ok_or_else(|| ApiError::bad_request("No file provided"))?; - Ok(Json(result.into())) + let original_filename = field + .file_name() + .map(|s| s.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + let content_type = field + .content_type() + .map(|s| s.to_string()) + .unwrap_or_else(|| "application/octet-stream".to_string()); + + let data = field.bytes().await.map_err(|e| { + ApiError::bad_request(format!("Failed to read file data: {}", e)) + })?; + + // Process the upload + let result = upload::process_upload_bytes( + &state.storage, + managed_storage.as_ref(), + &data, + &original_filename, + Some(&content_type), + ) + .await + .map_err(|e| ApiError::internal(format!("Upload failed: {}", e)))?; + + Ok(Json(result.into())) } /// Download a managed file /// GET /api/media/{id}/download pub async fn download_file( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> ApiResult { - let media_id = MediaId(id); - let item = state - .storage - .get_media(media_id) - .await - .map_err(|e| ApiError::not_found(format!("Media not found: {}", e)))?; + let media_id = MediaId(id); + let item = state + .storage + .get_media(media_id) + .await + .map_err(|e| ApiError::not_found(format!("Media not found: {}", e)))?; - let managed_storage = state - .managed_storage - .as_ref() - .ok_or_else(|| ApiError::bad_request("Managed storage is not enabled"))?; + let managed_storage = state + .managed_storage + .as_ref() + .ok_or_else(|| ApiError::bad_request("Managed storage is not enabled"))?; - // Check if this is a managed file - if item.storage_mode != pinakes_core::model::StorageMode::Managed { - // For external files, stream from their original path - let file = tokio::fs::File::open(&item.path) - .await - .map_err(|e| ApiError::not_found(format!("File not found: {}", e)))?; - - let stream = ReaderStream::new(file); - let body = axum::body::Body::from_stream(stream); - - let content_type = item.media_type.mime_type(); - - let filename = item.original_filename.unwrap_or(item.file_name); - - return Ok(( - [ - (header::CONTENT_TYPE, content_type), - ( - header::CONTENT_DISPOSITION, - format!("attachment; filename=\"{}\"", filename), - ), - ], - body, - )); - } - - // For managed files, stream from content-addressable storage - let file = managed_storage - .open(&item.content_hash) - .await - .map_err(|e| ApiError::not_found(format!("Blob not found: {}", e)))?; + // Check if this is a managed file + if item.storage_mode != pinakes_core::model::StorageMode::Managed { + // For external files, stream from their original path + let file = tokio::fs::File::open(&item.path) + .await + .map_err(|e| ApiError::not_found(format!("File not found: {}", e)))?; let stream = ReaderStream::new(file); let body = axum::body::Body::from_stream(stream); @@ -123,47 +100,76 @@ pub async fn download_file( let filename = item.original_filename.unwrap_or(item.file_name); - Ok(( - [ - (header::CONTENT_TYPE, content_type), - ( - header::CONTENT_DISPOSITION, - format!("attachment; filename=\"{}\"", filename), - ), - ], - body, - )) + return Ok(( + [ + (header::CONTENT_TYPE, content_type), + ( + header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{}\"", filename), + ), + ], + body, + )); + } + + // For managed files, stream from content-addressable storage + let file = managed_storage + .open(&item.content_hash) + .await + .map_err(|e| ApiError::not_found(format!("Blob not found: {}", e)))?; + + let stream = ReaderStream::new(file); + let body = axum::body::Body::from_stream(stream); + + let content_type = item.media_type.mime_type(); + + let filename = item.original_filename.unwrap_or(item.file_name); + + Ok(( + [ + (header::CONTENT_TYPE, content_type), + ( + header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{}\"", filename), + ), + ], + body, + )) } /// Migrate an external file to managed storage /// POST /api/media/{id}/move-to-managed pub async fn move_to_managed( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> ApiResult { - let managed_storage = state - .managed_storage - .as_ref() - .ok_or_else(|| ApiError::bad_request("Managed storage is not enabled"))?; + let managed_storage = state + .managed_storage + .as_ref() + .ok_or_else(|| ApiError::bad_request("Managed storage is not enabled"))?; - let media_id = MediaId(id); - upload::migrate_to_managed(&state.storage, managed_storage.as_ref(), media_id) - .await - .map_err(|e| ApiError::internal(format!("Migration failed: {}", e)))?; + let media_id = MediaId(id); + upload::migrate_to_managed( + &state.storage, + managed_storage.as_ref(), + media_id, + ) + .await + .map_err(|e| ApiError::internal(format!("Migration failed: {}", e)))?; - Ok(StatusCode::NO_CONTENT) + Ok(StatusCode::NO_CONTENT) } /// Get managed storage statistics /// GET /api/managed/stats pub async fn managed_stats( - State(state): State, + State(state): State, ) -> ApiResult> { - let stats = state - .storage - .managed_storage_stats() - .await - .map_err(|e| ApiError::internal(format!("Failed to get stats: {}", e)))?; + let stats = state + .storage + .managed_storage_stats() + .await + .map_err(|e| ApiError::internal(format!("Failed to get stats: {}", e)))?; - Ok(Json(stats.into())) + Ok(Json(stats.into())) } diff --git a/crates/pinakes-server/src/routes/users.rs b/crates/pinakes-server/src/routes/users.rs index 2618efb..8b2a828 100644 --- a/crates/pinakes-server/src/routes/users.rs +++ b/crates/pinakes-server/src/routes/users.rs @@ -1,171 +1,176 @@ -use axum::Json; -use axum::extract::{Path, State}; - -use crate::dto::*; -use crate::error::ApiError; -use crate::state::AppState; - +use axum::{ + Json, + extract::{Path, State}, +}; use pinakes_core::users::{CreateUserRequest, UpdateUserRequest, UserId}; +use crate::{dto::*, error::ApiError, state::AppState}; + /// List all users (admin only) pub async fn list_users( - State(state): State, + State(state): State, ) -> Result>, ApiError> { - let users = state.storage.list_users().await?; - Ok(Json(users.into_iter().map(UserResponse::from).collect())) + let users = state.storage.list_users().await?; + Ok(Json(users.into_iter().map(UserResponse::from).collect())) } /// Create a new user (admin only) pub async fn create_user( - State(state): State, - Json(req): Json, + State(state): State, + Json(req): Json, ) -> Result, ApiError> { - // Validate username - if req.username.is_empty() || req.username.len() > 255 { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( - "username must be 1-255 characters".into(), - ), - )); - } + // Validate username + if req.username.is_empty() || req.username.len() > 255 { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "username must be 1-255 characters".into(), + ), + )); + } - // Validate password - if req.password.len() < 8 { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( - "password must be at least 8 characters".into(), - ), - )); - } + // Validate password + if req.password.len() < 8 { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "password must be at least 8 characters".into(), + ), + )); + } - // Hash password - let password_hash = pinakes_core::users::auth::hash_password(&req.password)?; + // Hash password + let password_hash = pinakes_core::users::auth::hash_password(&req.password)?; - // Create user - rely on DB unique constraint for username to avoid TOCTOU race - let user = state - .storage - .create_user(&req.username, &password_hash, req.role, req.profile) - .await - .map_err(|e| { - // Map unique constraint violations to a user-friendly conflict error - let err_str = e.to_string(); - if err_str.contains("UNIQUE") - || err_str.contains("unique") - || err_str.contains("duplicate key") - { - ApiError(pinakes_core::error::PinakesError::DuplicateHash( - "username already exists".into(), - )) - } else { - ApiError(e) - } - })?; + // Create user - rely on DB unique constraint for username to avoid TOCTOU + // race + let user = state + .storage + .create_user(&req.username, &password_hash, req.role, req.profile) + .await + .map_err(|e| { + // Map unique constraint violations to a user-friendly conflict error + let err_str = e.to_string(); + if err_str.contains("UNIQUE") + || err_str.contains("unique") + || err_str.contains("duplicate key") + { + ApiError(pinakes_core::error::PinakesError::DuplicateHash( + "username already exists".into(), + )) + } else { + ApiError(e) + } + })?; - Ok(Json(UserResponse::from(user))) + Ok(Json(UserResponse::from(user))) } /// Get a specific user by ID pub async fn get_user( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result, ApiError> { - let user_id: UserId = id.parse::().map(UserId::from).map_err(|_| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - "Invalid user ID".into(), - )) + let user_id: UserId = + id.parse::().map(UserId::from).map_err(|_| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + "Invalid user ID".into(), + )) })?; - let user = state.storage.get_user(user_id).await?; - Ok(Json(UserResponse::from(user))) + let user = state.storage.get_user(user_id).await?; + Ok(Json(UserResponse::from(user))) } /// Update a user pub async fn update_user( - State(state): State, - Path(id): Path, - Json(req): Json, + State(state): State, + Path(id): Path, + Json(req): Json, ) -> Result, ApiError> { - let user_id: UserId = id.parse::().map(UserId::from).map_err(|_| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - "Invalid user ID".into(), - )) + let user_id: UserId = + id.parse::().map(UserId::from).map_err(|_| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + "Invalid user ID".into(), + )) })?; - // Hash password if provided - let password_hash = if let Some(ref password) = req.password { - if password.len() < 8 { - return Err(ApiError( - pinakes_core::error::PinakesError::InvalidOperation( - "password must be at least 8 characters".into(), - ), - )); - } - Some(pinakes_core::users::auth::hash_password(password)?) - } else { - None - }; + // Hash password if provided + let password_hash = if let Some(ref password) = req.password { + if password.len() < 8 { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "password must be at least 8 characters".into(), + ), + )); + } + Some(pinakes_core::users::auth::hash_password(password)?) + } else { + None + }; - let user = state - .storage - .update_user(user_id, password_hash.as_deref(), req.role, req.profile) - .await?; + let user = state + .storage + .update_user(user_id, password_hash.as_deref(), req.role, req.profile) + .await?; - Ok(Json(UserResponse::from(user))) + Ok(Json(UserResponse::from(user))) } /// Delete a user (admin only) pub async fn delete_user( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result, ApiError> { - let user_id: UserId = id.parse::().map(UserId::from).map_err(|_| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - "Invalid user ID".into(), - )) + let user_id: UserId = + id.parse::().map(UserId::from).map_err(|_| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + "Invalid user ID".into(), + )) })?; - state.storage.delete_user(user_id).await?; - Ok(Json(serde_json::json!({"deleted": true}))) + state.storage.delete_user(user_id).await?; + Ok(Json(serde_json::json!({"deleted": true}))) } /// Get user's accessible libraries pub async fn get_user_libraries( - State(state): State, - Path(id): Path, + State(state): State, + Path(id): Path, ) -> Result>, ApiError> { - let user_id: UserId = id.parse::().map(UserId::from).map_err(|_| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - "Invalid user ID".into(), - )) + let user_id: UserId = + id.parse::().map(UserId::from).map_err(|_| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + "Invalid user ID".into(), + )) })?; - let libraries = state.storage.get_user_libraries(user_id).await?; - Ok(Json( - libraries - .into_iter() - .map(UserLibraryResponse::from) - .collect(), - )) + let libraries = state.storage.get_user_libraries(user_id).await?; + Ok(Json( + libraries + .into_iter() + .map(UserLibraryResponse::from) + .collect(), + )) } /// Grant library access to a user (admin only) pub async fn grant_library_access( - State(state): State, - Path(id): Path, - Json(req): Json, + State(state): State, + Path(id): Path, + Json(req): Json, ) -> Result, ApiError> { - let user_id: UserId = id.parse::().map(UserId::from).map_err(|_| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - "Invalid user ID".into(), - )) + let user_id: UserId = + id.parse::().map(UserId::from).map_err(|_| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + "Invalid user ID".into(), + )) })?; - state - .storage - .grant_library_access(user_id, &req.root_path, req.permission) - .await?; + state + .storage + .grant_library_access(user_id, &req.root_path, req.permission) + .await?; - Ok(Json(serde_json::json!({"granted": true}))) + Ok(Json(serde_json::json!({"granted": true}))) } /// Revoke library access from a user (admin only) @@ -173,19 +178,20 @@ pub async fn grant_library_access( /// Uses a JSON body instead of a path parameter because root_path may contain /// slashes that conflict with URL routing. pub async fn revoke_library_access( - State(state): State, - Path(id): Path, - Json(req): Json, + State(state): State, + Path(id): Path, + Json(req): Json, ) -> Result, ApiError> { - let user_id: UserId = id.parse::().map(UserId::from).map_err(|_| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - "Invalid user ID".into(), - )) + let user_id: UserId = + id.parse::().map(UserId::from).map_err(|_| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + "Invalid user ID".into(), + )) })?; - state - .storage - .revoke_library_access(user_id, &req.root_path) - .await?; - Ok(Json(serde_json::json!({"revoked": true}))) + state + .storage + .revoke_library_access(user_id, &req.root_path) + .await?; + Ok(Json(serde_json::json!({"revoked": true}))) } diff --git a/crates/pinakes-server/src/routes/webhooks.rs b/crates/pinakes-server/src/routes/webhooks.rs index ce024df..8c900bc 100644 --- a/crates/pinakes-server/src/routes/webhooks.rs +++ b/crates/pinakes-server/src/routes/webhooks.rs @@ -1,40 +1,40 @@ -use axum::Json; -use axum::extract::State; +use axum::{Json, extract::State}; use serde::Serialize; -use crate::error::ApiError; -use crate::state::AppState; +use crate::{error::ApiError, state::AppState}; #[derive(Debug, Serialize)] pub struct WebhookInfo { - pub url: String, - pub events: Vec, + pub url: String, + pub events: Vec, } pub async fn list_webhooks( - State(state): State, + State(state): State, ) -> Result>, ApiError> { - let config = state.config.read().await; - let hooks: Vec = config - .webhooks - .iter() - .map(|h| WebhookInfo { - url: h.url.clone(), - events: h.events.clone(), - }) - .collect(); - Ok(Json(hooks)) + let config = state.config.read().await; + let hooks: Vec = config + .webhooks + .iter() + .map(|h| { + WebhookInfo { + url: h.url.clone(), + events: h.events.clone(), + } + }) + .collect(); + Ok(Json(hooks)) } pub async fn test_webhook( - State(state): State, + State(state): State, ) -> Result, ApiError> { - let config = state.config.read().await; - let count = config.webhooks.len(); - // Emit a test event to all configured webhooks - // In production, the event bus would handle delivery - Ok(Json(serde_json::json!({ - "webhooks_configured": count, - "test_sent": true - }))) + let config = state.config.read().await; + let count = config.webhooks.len(); + // Emit a test event to all configured webhooks + // In production, the event bus would handle delivery + Ok(Json(serde_json::json!({ + "webhooks_configured": count, + "test_sent": true + }))) } diff --git a/crates/pinakes-server/src/state.rs b/crates/pinakes-server/src/state.rs index 3bf99bf..150ad3f 100644 --- a/crates/pinakes-server/src/state.rs +++ b/crates/pinakes-server/src/state.rs @@ -1,33 +1,33 @@ -use std::path::PathBuf; -use std::sync::Arc; +use std::{path::PathBuf, sync::Arc}; +use pinakes_core::{ + cache::CacheLayer, + config::Config, + jobs::JobQueue, + managed_storage::ManagedStorageService, + plugin::PluginManager, + scan::ScanProgress, + scheduler::TaskScheduler, + storage::DynStorageBackend, + sync::ChunkedUploadManager, + transcode::TranscodeService, +}; use tokio::sync::RwLock; -use pinakes_core::cache::CacheLayer; -use pinakes_core::config::Config; -use pinakes_core::jobs::JobQueue; -use pinakes_core::managed_storage::ManagedStorageService; -use pinakes_core::plugin::PluginManager; -use pinakes_core::scan::ScanProgress; -use pinakes_core::scheduler::TaskScheduler; -use pinakes_core::storage::DynStorageBackend; -use pinakes_core::sync::ChunkedUploadManager; -use pinakes_core::transcode::TranscodeService; - // Note: Sessions are now stored in the database via StorageBackend // See storage::SessionData and related methods #[derive(Clone)] pub struct AppState { - pub storage: DynStorageBackend, - pub config: Arc>, - pub config_path: Option, - pub scan_progress: ScanProgress, - pub job_queue: Arc, - pub cache: Arc, - pub scheduler: Arc, - pub plugin_manager: Option>, - pub transcode_service: Option>, - pub managed_storage: Option>, - pub chunked_upload_manager: Option>, + pub storage: DynStorageBackend, + pub config: Arc>, + pub config_path: Option, + pub scan_progress: ScanProgress, + pub job_queue: Arc, + pub cache: Arc, + pub scheduler: Arc, + pub plugin_manager: Option>, + pub transcode_service: Option>, + pub managed_storage: Option>, + pub chunked_upload_manager: Option>, } diff --git a/crates/pinakes-server/tests/api.rs b/crates/pinakes-server/tests/api.rs index be439b5..1a548af 100644 --- a/crates/pinakes-server/tests/api.rs +++ b/crates/pinakes-server/tests/api.rs @@ -1,271 +1,301 @@ -use std::net::SocketAddr; -use std::sync::Arc; +use std::{net::SocketAddr, sync::Arc}; -use axum::body::Body; -use axum::extract::ConnectInfo; -use axum::http::{Request, StatusCode}; +use axum::{ + body::Body, + extract::ConnectInfo, + http::{Request, StatusCode}, +}; use http_body_util::BodyExt; +use pinakes_core::{ + cache::CacheLayer, + config::{ + AccountsConfig, + AnalyticsConfig, + CloudConfig, + Config, + DirectoryConfig, + EnrichmentConfig, + JobsConfig, + ManagedStorageConfig, + PhotoConfig, + PluginsConfig, + ScanningConfig, + ServerConfig, + SharingConfig, + SqliteConfig, + StorageBackendType, + StorageConfig, + SyncConfig, + ThumbnailConfig, + TlsConfig, + TranscodingConfig, + UiConfig, + UserAccount, + UserRole, + WebhookConfig, + }, + jobs::JobQueue, + storage::{StorageBackend, sqlite::SqliteBackend}, +}; use tokio::sync::RwLock; use tower::ServiceExt; -use pinakes_core::cache::CacheLayer; -use pinakes_core::config::{ - AccountsConfig, AnalyticsConfig, CloudConfig, Config, DirectoryConfig, EnrichmentConfig, - JobsConfig, ManagedStorageConfig, PhotoConfig, PluginsConfig, ScanningConfig, ServerConfig, - SharingConfig, SqliteConfig, StorageBackendType, StorageConfig, SyncConfig, ThumbnailConfig, - TlsConfig, TranscodingConfig, UiConfig, UserAccount, UserRole, WebhookConfig, -}; -use pinakes_core::jobs::JobQueue; -use pinakes_core::storage::StorageBackend; -use pinakes_core::storage::sqlite::SqliteBackend; - /// Fake socket address for tests (governor needs ConnectInfo) fn test_addr() -> ConnectInfo { - ConnectInfo("127.0.0.1:9999".parse().unwrap()) + ConnectInfo("127.0.0.1:9999".parse().unwrap()) } /// Build a GET request with ConnectInfo for rate limiter compatibility fn get(uri: &str) -> Request { - let mut req = Request::builder().uri(uri).body(Body::empty()).unwrap(); - req.extensions_mut().insert(test_addr()); - req + let mut req = Request::builder().uri(uri).body(Body::empty()).unwrap(); + req.extensions_mut().insert(test_addr()); + req } /// Build a POST request with ConnectInfo fn post_json(uri: &str, body: &str) -> Request { - let mut req = Request::builder() - .method("POST") - .uri(uri) - .header("content-type", "application/json") - .body(Body::from(body.to_string())) - .unwrap(); - req.extensions_mut().insert(test_addr()); - req + let mut req = Request::builder() + .method("POST") + .uri(uri) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + req.extensions_mut().insert(test_addr()); + req } /// Build a GET request with Bearer auth fn get_authed(uri: &str, token: &str) -> Request { - let mut req = Request::builder() - .uri(uri) - .header("authorization", format!("Bearer {}", token)) - .body(Body::empty()) - .unwrap(); - req.extensions_mut().insert(test_addr()); - req + let mut req = Request::builder() + .uri(uri) + .header("authorization", format!("Bearer {}", token)) + .body(Body::empty()) + .unwrap(); + req.extensions_mut().insert(test_addr()); + req } /// Build a POST JSON request with Bearer auth fn post_json_authed(uri: &str, body: &str, token: &str) -> Request { - let mut req = Request::builder() - .method("POST") - .uri(uri) - .header("content-type", "application/json") - .header("authorization", format!("Bearer {}", token)) - .body(Body::from(body.to_string())) - .unwrap(); - req.extensions_mut().insert(test_addr()); - req + let mut req = Request::builder() + .method("POST") + .uri(uri) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {}", token)) + .body(Body::from(body.to_string())) + .unwrap(); + req.extensions_mut().insert(test_addr()); + req } /// Build a DELETE request with Bearer auth fn delete_authed(uri: &str, token: &str) -> Request { - let mut req = Request::builder() - .method("DELETE") - .uri(uri) - .header("authorization", format!("Bearer {}", token)) - .body(Body::empty()) - .unwrap(); - req.extensions_mut().insert(test_addr()); - req + let mut req = Request::builder() + .method("DELETE") + .uri(uri) + .header("authorization", format!("Bearer {}", token)) + .body(Body::empty()) + .unwrap(); + req.extensions_mut().insert(test_addr()); + req } /// Build a PATCH JSON request with Bearer auth fn patch_json_authed(uri: &str, body: &str, token: &str) -> Request { - let mut req = Request::builder() - .method("PATCH") - .uri(uri) - .header("content-type", "application/json") - .header("authorization", format!("Bearer {}", token)) - .body(Body::from(body.to_string())) - .unwrap(); - req.extensions_mut().insert(test_addr()); - req + let mut req = Request::builder() + .method("PATCH") + .uri(uri) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {}", token)) + .body(Body::from(body.to_string())) + .unwrap(); + req.extensions_mut().insert(test_addr()); + req } fn default_config() -> Config { - Config { - storage: StorageConfig { - backend: StorageBackendType::Sqlite, - sqlite: Some(SqliteConfig { - path: ":memory:".into(), - }), - postgres: None, - }, - directories: DirectoryConfig { roots: vec![] }, - scanning: ScanningConfig { - watch: false, - poll_interval_secs: 300, - ignore_patterns: vec![], - import_concurrency: 8, - }, - server: ServerConfig { - host: "127.0.0.1".to_string(), - port: 3000, - api_key: None, - tls: TlsConfig::default(), - authentication_disabled: true, - }, - ui: UiConfig::default(), - accounts: AccountsConfig::default(), - jobs: JobsConfig::default(), - thumbnails: ThumbnailConfig::default(), - webhooks: Vec::::new(), - scheduled_tasks: vec![], - plugins: PluginsConfig::default(), - transcoding: TranscodingConfig::default(), - enrichment: EnrichmentConfig::default(), - cloud: CloudConfig::default(), - analytics: AnalyticsConfig::default(), - photos: PhotoConfig::default(), - managed_storage: ManagedStorageConfig::default(), - sync: SyncConfig::default(), - sharing: SharingConfig::default(), - } + Config { + storage: StorageConfig { + backend: StorageBackendType::Sqlite, + sqlite: Some(SqliteConfig { + path: ":memory:".into(), + }), + postgres: None, + }, + directories: DirectoryConfig { roots: vec![] }, + scanning: ScanningConfig { + watch: false, + poll_interval_secs: 300, + ignore_patterns: vec![], + import_concurrency: 8, + }, + server: ServerConfig { + host: "127.0.0.1".to_string(), + port: 3000, + api_key: None, + tls: TlsConfig::default(), + authentication_disabled: true, + }, + ui: UiConfig::default(), + accounts: AccountsConfig::default(), + jobs: JobsConfig::default(), + thumbnails: ThumbnailConfig::default(), + webhooks: Vec::::new(), + scheduled_tasks: vec![], + plugins: PluginsConfig::default(), + transcoding: TranscodingConfig::default(), + enrichment: EnrichmentConfig::default(), + cloud: CloudConfig::default(), + analytics: AnalyticsConfig::default(), + photos: PhotoConfig::default(), + managed_storage: ManagedStorageConfig::default(), + sync: SyncConfig::default(), + sharing: SharingConfig::default(), + } } async fn setup_app() -> axum::Router { - let backend = SqliteBackend::in_memory().expect("in-memory SQLite"); - backend.run_migrations().await.expect("migrations"); - let storage = Arc::new(backend) as pinakes_core::storage::DynStorageBackend; + let backend = SqliteBackend::in_memory().expect("in-memory SQLite"); + backend.run_migrations().await.expect("migrations"); + let storage = Arc::new(backend) as pinakes_core::storage::DynStorageBackend; - let config = default_config(); + let config = default_config(); - let job_queue = JobQueue::new(1, |_id, _kind, _cancel, _jobs| tokio::spawn(async {})); - let config = Arc::new(RwLock::new(config)); - let scheduler = pinakes_core::scheduler::TaskScheduler::new( - job_queue.clone(), - tokio_util::sync::CancellationToken::new(), - config.clone(), - None, - ); + let job_queue = + JobQueue::new(1, |_id, _kind, _cancel, _jobs| tokio::spawn(async {})); + let config = Arc::new(RwLock::new(config)); + let scheduler = pinakes_core::scheduler::TaskScheduler::new( + job_queue.clone(), + tokio_util::sync::CancellationToken::new(), + config.clone(), + None, + ); - let state = pinakes_server::state::AppState { - storage, - config, - config_path: None, - scan_progress: pinakes_core::scan::ScanProgress::new(), - job_queue, - cache: Arc::new(CacheLayer::new(60)), - scheduler: Arc::new(scheduler), - plugin_manager: None, - transcode_service: None, - managed_storage: None, - chunked_upload_manager: None, - }; + let state = pinakes_server::state::AppState { + storage, + config, + config_path: None, + scan_progress: pinakes_core::scan::ScanProgress::new(), + job_queue, + cache: Arc::new(CacheLayer::new(60)), + scheduler: Arc::new(scheduler), + plugin_manager: None, + transcode_service: None, + managed_storage: None, + chunked_upload_manager: None, + }; - pinakes_server::app::create_router(state) + pinakes_server::app::create_router(state) } /// Hash a password for test user accounts fn hash_password(password: &str) -> String { - pinakes_core::users::auth::hash_password(password).unwrap() + pinakes_core::users::auth::hash_password(password).unwrap() } /// Set up an app with accounts enabled and three pre-seeded users. /// Returns (Router, admin_token, editor_token, viewer_token). async fn setup_app_with_auth() -> (axum::Router, String, String, String) { - let backend = SqliteBackend::in_memory().expect("in-memory SQLite"); - backend.run_migrations().await.expect("migrations"); - let storage = Arc::new(backend) as pinakes_core::storage::DynStorageBackend; + let backend = SqliteBackend::in_memory().expect("in-memory SQLite"); + backend.run_migrations().await.expect("migrations"); + let storage = Arc::new(backend) as pinakes_core::storage::DynStorageBackend; - // Create users in database so resolve_user_id works - let users_to_create = vec![ - ("admin", "adminpass", UserRole::Admin), - ("editor", "editorpass", UserRole::Editor), - ("viewer", "viewerpass", UserRole::Viewer), - ]; - for (username, password, role) in &users_to_create { - let password_hash = hash_password(password); - storage - .create_user(username, &password_hash, *role, None) - .await - .expect("create user"); - } + // Create users in database so resolve_user_id works + let users_to_create = vec![ + ("admin", "adminpass", UserRole::Admin), + ("editor", "editorpass", UserRole::Editor), + ("viewer", "viewerpass", UserRole::Viewer), + ]; + for (username, password, role) in &users_to_create { + let password_hash = hash_password(password); + storage + .create_user(username, &password_hash, *role, None) + .await + .expect("create user"); + } - let mut config = default_config(); - config.server.authentication_disabled = false; // Enable authentication for these tests - config.accounts.enabled = true; - config.accounts.users = vec![ - UserAccount { - username: "admin".to_string(), - password_hash: hash_password("adminpass"), - role: UserRole::Admin, - }, - UserAccount { - username: "editor".to_string(), - password_hash: hash_password("editorpass"), - role: UserRole::Editor, - }, - UserAccount { - username: "viewer".to_string(), - password_hash: hash_password("viewerpass"), - role: UserRole::Viewer, - }, - ]; + let mut config = default_config(); + config.server.authentication_disabled = false; // Enable authentication for these tests + config.accounts.enabled = true; + config.accounts.users = vec![ + UserAccount { + username: "admin".to_string(), + password_hash: hash_password("adminpass"), + role: UserRole::Admin, + }, + UserAccount { + username: "editor".to_string(), + password_hash: hash_password("editorpass"), + role: UserRole::Editor, + }, + UserAccount { + username: "viewer".to_string(), + password_hash: hash_password("viewerpass"), + role: UserRole::Viewer, + }, + ]; - let job_queue = JobQueue::new(1, |_id, _kind, _cancel, _jobs| tokio::spawn(async {})); - let config = Arc::new(RwLock::new(config)); - let scheduler = pinakes_core::scheduler::TaskScheduler::new( - job_queue.clone(), - tokio_util::sync::CancellationToken::new(), - config.clone(), - None, - ); + let job_queue = + JobQueue::new(1, |_id, _kind, _cancel, _jobs| tokio::spawn(async {})); + let config = Arc::new(RwLock::new(config)); + let scheduler = pinakes_core::scheduler::TaskScheduler::new( + job_queue.clone(), + tokio_util::sync::CancellationToken::new(), + config.clone(), + None, + ); - let state = pinakes_server::state::AppState { - storage, - config, - config_path: None, - scan_progress: pinakes_core::scan::ScanProgress::new(), - job_queue, - cache: Arc::new(CacheLayer::new(60)), - scheduler: Arc::new(scheduler), - plugin_manager: None, - transcode_service: None, - managed_storage: None, - chunked_upload_manager: None, - }; + let state = pinakes_server::state::AppState { + storage, + config, + config_path: None, + scan_progress: pinakes_core::scan::ScanProgress::new(), + job_queue, + cache: Arc::new(CacheLayer::new(60)), + scheduler: Arc::new(scheduler), + plugin_manager: None, + transcode_service: None, + managed_storage: None, + chunked_upload_manager: None, + }; - let app = pinakes_server::app::create_router(state); + let app = pinakes_server::app::create_router(state); - // Login each user to get tokens - let admin_token = login_user(app.clone(), "admin", "adminpass").await; - let editor_token = login_user(app.clone(), "editor", "editorpass").await; - let viewer_token = login_user(app.clone(), "viewer", "viewerpass").await; + // Login each user to get tokens + let admin_token = login_user(app.clone(), "admin", "adminpass").await; + let editor_token = login_user(app.clone(), "editor", "editorpass").await; + let viewer_token = login_user(app.clone(), "viewer", "viewerpass").await; - (app, admin_token, editor_token, viewer_token) + (app, admin_token, editor_token, viewer_token) } -async fn login_user(app: axum::Router, username: &str, password: &str) -> String { - let body = format!(r#"{{"username":"{}","password":"{}"}}"#, username, password); - let response = app - .oneshot(post_json("/api/v1/auth/login", &body)) - .await - .unwrap(); - assert_eq!( - response.status(), - StatusCode::OK, - "login failed for user {}", - username - ); - let body = response.into_body().collect().await.unwrap().to_bytes(); - let result: serde_json::Value = serde_json::from_slice(&body).unwrap(); - result["token"].as_str().unwrap().to_string() +async fn login_user( + app: axum::Router, + username: &str, + password: &str, +) -> String { + let body = + format!(r#"{{"username":"{}","password":"{}"}}"#, username, password); + let response = app + .oneshot(post_json("/api/v1/auth/login", &body)) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::OK, + "login failed for user {}", + username + ); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let result: serde_json::Value = serde_json::from_slice(&body).unwrap(); + result["token"].as_str().unwrap().to_string() } -async fn response_body(response: axum::response::Response) -> serde_json::Value { - let body = response.into_body().collect().await.unwrap().to_bytes(); - serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null) +async fn response_body( + response: axum::response::Response, +) -> serde_json::Value { + let body = response.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null) } // =================================================================== @@ -274,214 +304,215 @@ async fn response_body(response: axum::response::Response) -> serde_json::Value #[tokio::test] async fn test_list_media_empty() { - let app = setup_app().await; + let app = setup_app().await; - let response = app.oneshot(get("/api/v1/media")).await.unwrap(); + let response = app.oneshot(get("/api/v1/media")).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = response.into_body().collect().await.unwrap().to_bytes(); - let items: Vec = serde_json::from_slice(&body).unwrap(); - assert_eq!(items.len(), 0); + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let items: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(items.len(), 0); } #[tokio::test] async fn test_create_and_list_tags() { - let app = setup_app().await; + let app = setup_app().await; - // Create a tag - let response = app - .clone() - .oneshot(post_json("/api/v1/tags", r#"{"name":"Music"}"#)) - .await - .unwrap(); + // Create a tag + let response = app + .clone() + .oneshot(post_json("/api/v1/tags", r#"{"name":"Music"}"#)) + .await + .unwrap(); - assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.status(), StatusCode::OK); - // List tags - let response = app.oneshot(get("/api/v1/tags")).await.unwrap(); + // List tags + let response = app.oneshot(get("/api/v1/tags")).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = response.into_body().collect().await.unwrap().to_bytes(); - let tags: Vec = serde_json::from_slice(&body).unwrap(); - assert_eq!(tags.len(), 1); - assert_eq!(tags[0]["name"], "Music"); + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let tags: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(tags.len(), 1); + assert_eq!(tags[0]["name"], "Music"); } #[tokio::test] async fn test_search_empty() { - let app = setup_app().await; + let app = setup_app().await; - let response = app.oneshot(get("/api/v1/search?q=test")).await.unwrap(); + let response = app.oneshot(get("/api/v1/search?q=test")).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = response.into_body().collect().await.unwrap().to_bytes(); - let result: serde_json::Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(result["total_count"], 0); + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let result: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(result["total_count"], 0); } #[tokio::test] async fn test_media_not_found() { - let app = setup_app().await; + let app = setup_app().await; - let response = app - .oneshot(get("/api/v1/media/00000000-0000-0000-0000-000000000000")) - .await - .unwrap(); + let response = app + .oneshot(get("/api/v1/media/00000000-0000-0000-0000-000000000000")) + .await + .unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn test_collections_crud() { - let app = setup_app().await; + let app = setup_app().await; - // Create collection - let response = app - .clone() - .oneshot(post_json( - "/api/v1/collections", - r#"{"name":"Favorites","kind":"manual"}"#, - )) - .await - .unwrap(); + // Create collection + let response = app + .clone() + .oneshot(post_json( + "/api/v1/collections", + r#"{"name":"Favorites","kind":"manual"}"#, + )) + .await + .unwrap(); - assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.status(), StatusCode::OK); - // List collections - let response = app.oneshot(get("/api/v1/collections")).await.unwrap(); + // List collections + let response = app.oneshot(get("/api/v1/collections")).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = response.into_body().collect().await.unwrap().to_bytes(); - let cols: Vec = serde_json::from_slice(&body).unwrap(); - assert_eq!(cols.len(), 1); - assert_eq!(cols[0]["name"], "Favorites"); + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let cols: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(cols.len(), 1); + assert_eq!(cols[0]["name"], "Favorites"); } #[tokio::test] async fn test_statistics_endpoint() { - let app = setup_app().await; + let app = setup_app().await; - let response = app.oneshot(get("/api/v1/statistics")).await.unwrap(); + let response = app.oneshot(get("/api/v1/statistics")).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = response.into_body().collect().await.unwrap().to_bytes(); - let stats: serde_json::Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(stats["total_media"], 0); - assert_eq!(stats["total_size_bytes"], 0); + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let stats: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(stats["total_media"], 0); + assert_eq!(stats["total_size_bytes"], 0); } #[tokio::test] async fn test_scheduled_tasks_endpoint() { - let app = setup_app().await; + let app = setup_app().await; - let response = app.oneshot(get("/api/v1/tasks/scheduled")).await.unwrap(); + let response = app.oneshot(get("/api/v1/tasks/scheduled")).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = response.into_body().collect().await.unwrap().to_bytes(); - let tasks: Vec = serde_json::from_slice(&body).unwrap(); - assert!(!tasks.is_empty(), "should have default scheduled tasks"); - // Verify structure of first task - assert!(tasks[0]["id"].is_string()); - assert!(tasks[0]["name"].is_string()); - assert!(tasks[0]["schedule"].is_string()); + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let tasks: Vec = serde_json::from_slice(&body).unwrap(); + assert!(!tasks.is_empty(), "should have default scheduled tasks"); + // Verify structure of first task + assert!(tasks[0]["id"].is_string()); + assert!(tasks[0]["name"].is_string()); + assert!(tasks[0]["schedule"].is_string()); } #[tokio::test] async fn test_user_management_crud() { - let app = setup_app().await; + let app = setup_app().await; - // Create a user - let response = app - .clone() - .oneshot(post_json( - "/api/v1/users", - r#"{"username":"testuser","password":"password123","role":"viewer"}"#, - )) - .await - .unwrap(); + // Create a user + let response = app + .clone() + .oneshot(post_json( + "/api/v1/users", + r#"{"username":"testuser","password":"password123","role":"viewer"}"#, + )) + .await + .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = response.into_body().collect().await.unwrap().to_bytes(); - let user: serde_json::Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(user["username"], "testuser"); - assert_eq!(user["role"], "viewer"); - let user_id = user["id"].as_str().unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let user: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(user["username"], "testuser"); + assert_eq!(user["role"], "viewer"); + let user_id = user["id"].as_str().unwrap(); - // List users - let response = app.clone().oneshot(get("/api/v1/users")).await.unwrap(); + // List users + let response = app.clone().oneshot(get("/api/v1/users")).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = response.into_body().collect().await.unwrap().to_bytes(); - let users: Vec = serde_json::from_slice(&body).unwrap(); - assert_eq!(users.len(), 1); - assert_eq!(users[0]["username"], "testuser"); + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let users: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(users.len(), 1); + assert_eq!(users[0]["username"], "testuser"); - // Get specific user - let response = app - .clone() - .oneshot(get(&format!("/api/v1/users/{}", user_id))) - .await - .unwrap(); + // Get specific user + let response = app + .clone() + .oneshot(get(&format!("/api/v1/users/{}", user_id))) + .await + .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = response.into_body().collect().await.unwrap().to_bytes(); - let retrieved_user: serde_json::Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(retrieved_user["username"], "testuser"); + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let retrieved_user: serde_json::Value = + serde_json::from_slice(&body).unwrap(); + assert_eq!(retrieved_user["username"], "testuser"); - // Delete user - let mut req = Request::builder() - .method("DELETE") - .uri(&format!("/api/v1/users/{}", user_id)) - .body(Body::empty()) - .unwrap(); - req.extensions_mut().insert(test_addr()); + // Delete user + let mut req = Request::builder() + .method("DELETE") + .uri(&format!("/api/v1/users/{}", user_id)) + .body(Body::empty()) + .unwrap(); + req.extensions_mut().insert(test_addr()); - let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); + let response = app.clone().oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); - // Verify user is deleted - let response = app - .oneshot(get(&format!("/api/v1/users/{}", user_id))) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); + // Verify user is deleted + let response = app + .oneshot(get(&format!("/api/v1/users/{}", user_id))) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn test_health_endpoint() { - let app = setup_app().await; + let app = setup_app().await; - // Health endpoint should be publicly accessible - let response = app.oneshot(get("/api/v1/health")).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); + // Health endpoint should be publicly accessible + let response = app.oneshot(get("/api/v1/health")).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); } #[tokio::test] async fn test_user_duplicate_username() { - let app = setup_app().await; + let app = setup_app().await; - // Create first user - let response = app - .clone() - .oneshot(post_json( - "/api/v1/users", - r#"{"username":"duplicate","password":"password1","role":"viewer"}"#, - )) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); + // Create first user + let response = app + .clone() + .oneshot(post_json( + "/api/v1/users", + r#"{"username":"duplicate","password":"password1","role":"viewer"}"#, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); - // Try to create user with same username - let response = app - .oneshot(post_json( - "/api/v1/users", - r#"{"username":"duplicate","password":"password2","role":"viewer"}"#, - )) - .await - .unwrap(); + // Try to create user with same username + let response = app + .oneshot(post_json( + "/api/v1/users", + r#"{"username":"duplicate","password":"password2","role":"viewer"}"#, + )) + .await + .unwrap(); - // Should fail with conflict (409) for duplicate username - assert_eq!(response.status(), StatusCode::CONFLICT); + // Should fail with conflict (409) for duplicate username + assert_eq!(response.status(), StatusCode::CONFLICT); } // =================================================================== @@ -490,106 +521,106 @@ async fn test_user_duplicate_username() { #[tokio::test] async fn test_unauthenticated_request_rejected() { - let (app, _, _, _) = setup_app_with_auth().await; + let (app, ..) = setup_app_with_auth().await; - // Request without Bearer token - let response = app.oneshot(get("/api/v1/media")).await.unwrap(); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + // Request without Bearer token + let response = app.oneshot(get("/api/v1/media")).await.unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn test_invalid_token_rejected() { - let (app, _, _, _) = setup_app_with_auth().await; + let (app, ..) = setup_app_with_auth().await; - let response = app - .oneshot(get_authed("/api/v1/media", "totally-invalid-token")) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let response = app + .oneshot(get_authed("/api/v1/media", "totally-invalid-token")) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn test_login_valid_credentials() { - let (app, _, _, _) = setup_app_with_auth().await; + let (app, ..) = setup_app_with_auth().await; - let response = app - .oneshot(post_json( - "/api/v1/auth/login", - r#"{"username":"admin","password":"adminpass"}"#, - )) - .await - .unwrap(); + let response = app + .oneshot(post_json( + "/api/v1/auth/login", + r#"{"username":"admin","password":"adminpass"}"#, + )) + .await + .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = response_body(response).await; - assert!(body["token"].is_string()); - assert_eq!(body["username"], "admin"); - assert_eq!(body["role"], "admin"); + assert_eq!(response.status(), StatusCode::OK); + let body = response_body(response).await; + assert!(body["token"].is_string()); + assert_eq!(body["username"], "admin"); + assert_eq!(body["role"], "admin"); } #[tokio::test] async fn test_login_invalid_password() { - let (app, _, _, _) = setup_app_with_auth().await; + let (app, ..) = setup_app_with_auth().await; - let response = app - .oneshot(post_json( - "/api/v1/auth/login", - r#"{"username":"admin","password":"wrongpassword"}"#, - )) - .await - .unwrap(); + let response = app + .oneshot(post_json( + "/api/v1/auth/login", + r#"{"username":"admin","password":"wrongpassword"}"#, + )) + .await + .unwrap(); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn test_login_unknown_user() { - let (app, _, _, _) = setup_app_with_auth().await; + let (app, ..) = setup_app_with_auth().await; - let response = app - .oneshot(post_json( - "/api/v1/auth/login", - r#"{"username":"nonexistent","password":"whatever"}"#, - )) - .await - .unwrap(); + let response = app + .oneshot(post_json( + "/api/v1/auth/login", + r#"{"username":"nonexistent","password":"whatever"}"#, + )) + .await + .unwrap(); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn test_auth_me_endpoint() { - let (app, admin_token, _, _) = setup_app_with_auth().await; + let (app, admin_token, ..) = setup_app_with_auth().await; - let response = app - .oneshot(get_authed("/api/v1/auth/me", &admin_token)) - .await - .unwrap(); + let response = app + .oneshot(get_authed("/api/v1/auth/me", &admin_token)) + .await + .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = response_body(response).await; - assert_eq!(body["username"], "admin"); - assert_eq!(body["role"], "admin"); + assert_eq!(response.status(), StatusCode::OK); + let body = response_body(response).await; + assert_eq!(body["username"], "admin"); + assert_eq!(body["role"], "admin"); } #[tokio::test] async fn test_logout() { - let (app, admin_token, _, _) = setup_app_with_auth().await; + let (app, admin_token, ..) = setup_app_with_auth().await; - // Logout - let response = app - .clone() - .oneshot(post_json_authed("/api/v1/auth/logout", "", &admin_token)) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); + // Logout + let response = app + .clone() + .oneshot(post_json_authed("/api/v1/auth/logout", "", &admin_token)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); - // Subsequent requests with same token should fail - let response = app - .oneshot(get_authed("/api/v1/media", &admin_token)) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + // Subsequent requests with same token should fail + let response = app + .oneshot(get_authed("/api/v1/media", &admin_token)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } // =================================================================== @@ -598,88 +629,88 @@ async fn test_logout() { #[tokio::test] async fn test_viewer_cannot_access_editor_routes() { - let (app, _, _, viewer_token) = setup_app_with_auth().await; + let (app, _, _, viewer_token) = setup_app_with_auth().await; - // POST /tags is an editor-only route - let response = app - .oneshot(post_json_authed( - "/api/v1/tags", - r#"{"name":"test"}"#, - &viewer_token, - )) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::FORBIDDEN); + // POST /tags is an editor-only route + let response = app + .oneshot(post_json_authed( + "/api/v1/tags", + r#"{"name":"test"}"#, + &viewer_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); } #[tokio::test] async fn test_viewer_cannot_access_admin_routes() { - let (app, _, _, viewer_token) = setup_app_with_auth().await; + let (app, _, _, viewer_token) = setup_app_with_auth().await; - // GET /users is an admin-only route - let response = app - .oneshot(get_authed("/api/v1/users", &viewer_token)) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::FORBIDDEN); + // GET /users is an admin-only route + let response = app + .oneshot(get_authed("/api/v1/users", &viewer_token)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); } #[tokio::test] async fn test_editor_cannot_access_admin_routes() { - let (app, _, editor_token, _) = setup_app_with_auth().await; + let (app, _, editor_token, _) = setup_app_with_auth().await; - let response = app - .oneshot(get_authed("/api/v1/users", &editor_token)) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::FORBIDDEN); + let response = app + .oneshot(get_authed("/api/v1/users", &editor_token)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); } #[tokio::test] async fn test_editor_can_write() { - let (app, _, editor_token, _) = setup_app_with_auth().await; + let (app, _, editor_token, _) = setup_app_with_auth().await; - let response = app - .oneshot(post_json_authed( - "/api/v1/tags", - r#"{"name":"EditorTag"}"#, - &editor_token, - )) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); + let response = app + .oneshot(post_json_authed( + "/api/v1/tags", + r#"{"name":"EditorTag"}"#, + &editor_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); } #[tokio::test] async fn test_admin_can_access_all() { - let (app, admin_token, _, _) = setup_app_with_auth().await; + let (app, admin_token, ..) = setup_app_with_auth().await; - // Viewer route - let response = app - .clone() - .oneshot(get_authed("/api/v1/media", &admin_token)) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); + // Viewer route + let response = app + .clone() + .oneshot(get_authed("/api/v1/media", &admin_token)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); - // Editor route - let response = app - .clone() - .oneshot(post_json_authed( - "/api/v1/tags", - r#"{"name":"AdminTag"}"#, - &admin_token, - )) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); + // Editor route + let response = app + .clone() + .oneshot(post_json_authed( + "/api/v1/tags", + r#"{"name":"AdminTag"}"#, + &admin_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); - // Admin route - let response = app - .oneshot(get_authed("/api/v1/users", &admin_token)) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); + // Admin route + let response = app + .oneshot(get_authed("/api/v1/users", &admin_token)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); } // =================================================================== @@ -688,60 +719,60 @@ async fn test_admin_can_access_all() { #[tokio::test] async fn test_rating_invalid_stars_zero() { - let (app, _, editor_token, _) = setup_app_with_auth().await; + let (app, _, editor_token, _) = setup_app_with_auth().await; - let response = app - .oneshot(post_json_authed( - "/api/v1/media/00000000-0000-0000-0000-000000000000/ratings", - r#"{"stars":0}"#, - &editor_token, - )) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let response = app + .oneshot(post_json_authed( + "/api/v1/media/00000000-0000-0000-0000-000000000000/ratings", + r#"{"stars":0}"#, + &editor_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn test_rating_invalid_stars_six() { - let (app, _, editor_token, _) = setup_app_with_auth().await; + let (app, _, editor_token, _) = setup_app_with_auth().await; - let response = app - .oneshot(post_json_authed( - "/api/v1/media/00000000-0000-0000-0000-000000000000/ratings", - r#"{"stars":6}"#, - &editor_token, - )) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let response = app + .oneshot(post_json_authed( + "/api/v1/media/00000000-0000-0000-0000-000000000000/ratings", + r#"{"stars":6}"#, + &editor_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn test_comment_empty_text() { - let (app, _, editor_token, _) = setup_app_with_auth().await; + let (app, _, editor_token, _) = setup_app_with_auth().await; - let response = app - .oneshot(post_json_authed( - "/api/v1/media/00000000-0000-0000-0000-000000000000/comments", - r#"{"text":""}"#, - &editor_token, - )) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let response = app + .oneshot(post_json_authed( + "/api/v1/media/00000000-0000-0000-0000-000000000000/comments", + r#"{"text":""}"#, + &editor_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn test_favorites_list_empty() { - let (app, _, _, viewer_token) = setup_app_with_auth().await; + let (app, _, _, viewer_token) = setup_app_with_auth().await; - let response = app - .oneshot(get_authed("/api/v1/favorites", &viewer_token)) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = response_body(response).await; - assert!(body.as_array().unwrap().is_empty()); + let response = app + .oneshot(get_authed("/api/v1/favorites", &viewer_token)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_body(response).await; + assert!(body.as_array().unwrap().is_empty()); } // =================================================================== @@ -750,83 +781,83 @@ async fn test_favorites_list_empty() { #[tokio::test] async fn test_playlist_crud() { - let (app, _, editor_token, _) = setup_app_with_auth().await; + let (app, _, editor_token, _) = setup_app_with_auth().await; - // Create - let response = app - .clone() - .oneshot(post_json_authed( - "/api/v1/playlists", - r#"{"name":"My Playlist"}"#, - &editor_token, - )) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = response_body(response).await; - let playlist_id = body["id"].as_str().unwrap().to_string(); - assert_eq!(body["name"], "My Playlist"); + // Create + let response = app + .clone() + .oneshot(post_json_authed( + "/api/v1/playlists", + r#"{"name":"My Playlist"}"#, + &editor_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_body(response).await; + let playlist_id = body["id"].as_str().unwrap().to_string(); + assert_eq!(body["name"], "My Playlist"); - // List - let response = app - .clone() - .oneshot(get_authed("/api/v1/playlists", &editor_token)) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = response_body(response).await; - assert_eq!(body.as_array().unwrap().len(), 1); + // List + let response = app + .clone() + .oneshot(get_authed("/api/v1/playlists", &editor_token)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_body(response).await; + assert_eq!(body.as_array().unwrap().len(), 1); - // Get - let response = app - .clone() - .oneshot(get_authed( - &format!("/api/v1/playlists/{}", playlist_id), - &editor_token, - )) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); + // Get + let response = app + .clone() + .oneshot(get_authed( + &format!("/api/v1/playlists/{}", playlist_id), + &editor_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); - // Update - let response = app - .clone() - .oneshot(patch_json_authed( - &format!("/api/v1/playlists/{}", playlist_id), - r#"{"name":"Updated Playlist","description":"A test description"}"#, - &editor_token, - )) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = response_body(response).await; - assert_eq!(body["name"], "Updated Playlist"); + // Update + let response = app + .clone() + .oneshot(patch_json_authed( + &format!("/api/v1/playlists/{}", playlist_id), + r#"{"name":"Updated Playlist","description":"A test description"}"#, + &editor_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_body(response).await; + assert_eq!(body["name"], "Updated Playlist"); - // Delete - let response = app - .clone() - .oneshot(delete_authed( - &format!("/api/v1/playlists/{}", playlist_id), - &editor_token, - )) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); + // Delete + let response = app + .clone() + .oneshot(delete_authed( + &format!("/api/v1/playlists/{}", playlist_id), + &editor_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); } #[tokio::test] async fn test_playlist_empty_name() { - let (app, _, editor_token, _) = setup_app_with_auth().await; + let (app, _, editor_token, _) = setup_app_with_auth().await; - let response = app - .oneshot(post_json_authed( - "/api/v1/playlists", - r#"{"name":""}"#, - &editor_token, - )) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let response = app + .oneshot(post_json_authed( + "/api/v1/playlists", + r#"{"name":""}"#, + &editor_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); } // =================================================================== @@ -835,34 +866,34 @@ async fn test_playlist_empty_name() { #[tokio::test] async fn test_most_viewed_empty() { - let (app, _, _, viewer_token) = setup_app_with_auth().await; + let (app, _, _, viewer_token) = setup_app_with_auth().await; - let response = app - .oneshot(get_authed("/api/v1/analytics/most-viewed", &viewer_token)) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = response_body(response).await; - assert!(body.as_array().unwrap().is_empty()); + let response = app + .oneshot(get_authed("/api/v1/analytics/most-viewed", &viewer_token)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_body(response).await; + assert!(body.as_array().unwrap().is_empty()); } #[tokio::test] async fn test_record_event_and_query() { - let (app, _, editor_token, _) = setup_app_with_auth().await; + let (app, _, editor_token, _) = setup_app_with_auth().await; - // Record an event - let response = app - .clone() - .oneshot(post_json_authed( - "/api/v1/analytics/events", - r#"{"event_type":"view","duration_secs":5.0}"#, - &editor_token, - )) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = response_body(response).await; - assert_eq!(body["recorded"], true); + // Record an event + let response = app + .clone() + .oneshot(post_json_authed( + "/api/v1/analytics/events", + r#"{"event_type":"view","duration_secs":5.0}"#, + &editor_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_body(response).await; + assert_eq!(body["recorded"], true); } // =================================================================== @@ -871,52 +902,53 @@ async fn test_record_event_and_query() { #[tokio::test] async fn test_transcode_session_not_found() { - let (app, _, _, viewer_token) = setup_app_with_auth().await; + let (app, _, _, viewer_token) = setup_app_with_auth().await; - let response = app - .oneshot(get_authed( - "/api/v1/transcode/00000000-0000-0000-0000-000000000000", - &viewer_token, - )) - .await - .unwrap(); - // Should be 404 or 500 (not found in DB) - assert!( - response.status() == StatusCode::NOT_FOUND - || response.status() == StatusCode::INTERNAL_SERVER_ERROR - ); + let response = app + .oneshot(get_authed( + "/api/v1/transcode/00000000-0000-0000-0000-000000000000", + &viewer_token, + )) + .await + .unwrap(); + // Should be 404 or 500 (not found in DB) + assert!( + response.status() == StatusCode::NOT_FOUND + || response.status() == StatusCode::INTERNAL_SERVER_ERROR + ); } #[tokio::test] async fn test_transcode_list_empty() { - let (app, _, _, viewer_token) = setup_app_with_auth().await; + let (app, _, _, viewer_token) = setup_app_with_auth().await; - let response = app - .oneshot(get_authed("/api/v1/transcode", &viewer_token)) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = response_body(response).await; - assert!(body.as_array().unwrap().is_empty()); + let response = app + .oneshot(get_authed("/api/v1/transcode", &viewer_token)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_body(response).await; + assert!(body.as_array().unwrap().is_empty()); } #[tokio::test] async fn test_hls_segment_no_session() { - let (app, _, _, viewer_token) = setup_app_with_auth().await; + let (app, _, _, viewer_token) = setup_app_with_auth().await; - let response = app - .oneshot(get_authed( - "/api/v1/media/00000000-0000-0000-0000-000000000000/stream/hls/720p/segment0.ts", - &viewer_token, - )) - .await - .unwrap(); - // Should fail because media doesn't exist or no transcode session - assert!( - response.status() == StatusCode::BAD_REQUEST - || response.status() == StatusCode::NOT_FOUND - || response.status() == StatusCode::INTERNAL_SERVER_ERROR - ); + let response = app + .oneshot(get_authed( + "/api/v1/media/00000000-0000-0000-0000-000000000000/stream/hls/720p/\ + segment0.ts", + &viewer_token, + )) + .await + .unwrap(); + // Should fail because media doesn't exist or no transcode session + assert!( + response.status() == StatusCode::BAD_REQUEST + || response.status() == StatusCode::NOT_FOUND + || response.status() == StatusCode::INTERNAL_SERVER_ERROR + ); } // =================================================================== @@ -925,21 +957,21 @@ async fn test_hls_segment_no_session() { #[tokio::test] async fn test_subtitles_list() { - let (app, _, _, viewer_token) = setup_app_with_auth().await; + let (app, _, _, viewer_token) = setup_app_with_auth().await; - // Should return empty for nonexistent media (or not found) - let response = app - .oneshot(get_authed( - "/api/v1/media/00000000-0000-0000-0000-000000000000/subtitles", - &viewer_token, - )) - .await - .unwrap(); - assert!( - response.status() == StatusCode::OK - || response.status() == StatusCode::NOT_FOUND - || response.status() == StatusCode::INTERNAL_SERVER_ERROR - ); + // Should return empty for nonexistent media (or not found) + let response = app + .oneshot(get_authed( + "/api/v1/media/00000000-0000-0000-0000-000000000000/subtitles", + &viewer_token, + )) + .await + .unwrap(); + assert!( + response.status() == StatusCode::OK + || response.status() == StatusCode::NOT_FOUND + || response.status() == StatusCode::INTERNAL_SERVER_ERROR + ); } // =================================================================== @@ -948,11 +980,12 @@ async fn test_subtitles_list() { #[tokio::test] async fn test_health_public() { - let (app, _, _, _) = setup_app_with_auth().await; + let (app, ..) = setup_app_with_auth().await; - // Health endpoint should be accessible without auth even when accounts enabled - let response = app.oneshot(get("/api/v1/health")).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); + // Health endpoint should be accessible without auth even when accounts + // enabled + let response = app.oneshot(get("/api/v1/health")).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); } // =================================================================== @@ -961,47 +994,48 @@ async fn test_health_public() { #[tokio::test] async fn test_invalid_uuid_in_path() { - let (app, _, _, viewer_token) = setup_app_with_auth().await; + let (app, _, _, viewer_token) = setup_app_with_auth().await; - let response = app - .oneshot(get_authed("/api/v1/media/not-a-uuid", &viewer_token)) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let response = app + .oneshot(get_authed("/api/v1/media/not-a-uuid", &viewer_token)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn test_oversized_comment() { - let (app, _, editor_token, _) = setup_app_with_auth().await; + let (app, _, editor_token, _) = setup_app_with_auth().await; - let long_text: String = "x".repeat(10_001); - let body = format!(r#"{{"text":"{}"}}"#, long_text); - let response = app - .oneshot(post_json_authed( - "/api/v1/media/00000000-0000-0000-0000-000000000000/comments", - &body, - &editor_token, - )) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let long_text: String = "x".repeat(10_001); + let body = format!(r#"{{"text":"{}"}}"#, long_text); + let response = app + .oneshot(post_json_authed( + "/api/v1/media/00000000-0000-0000-0000-000000000000/comments", + &body, + &editor_token, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn test_share_link_expired() { - // Uses no-auth setup since share links are complex to test with auth - // (need real media items). Verify the expire check logic works. - let app = setup_app().await; + // Uses no-auth setup since share links are complex to test with auth + // (need real media items). Verify the expire check logic works. + let app = setup_app().await; - // First import a dummy file to get a media_id — but we can't without a real file. - // So let's test the public share access endpoint with a nonexistent token. - let response = app - .oneshot(get("/api/v1/s/nonexistent_token")) - .await - .unwrap(); - // Should fail with not found or internal error (no such share link) - assert!( - response.status() == StatusCode::NOT_FOUND - || response.status() == StatusCode::INTERNAL_SERVER_ERROR - ); + // First import a dummy file to get a media_id — but we can't without a real + // file. So let's test the public share access endpoint with a nonexistent + // token. + let response = app + .oneshot(get("/api/v1/s/nonexistent_token")) + .await + .unwrap(); + // Should fail with not found or internal error (no such share link) + assert!( + response.status() == StatusCode::NOT_FOUND + || response.status() == StatusCode::INTERNAL_SERVER_ERROR + ); } diff --git a/crates/pinakes-server/tests/plugin.rs b/crates/pinakes-server/tests/plugin.rs index 62cbc62..ef36f4f 100644 --- a/crates/pinakes-server/tests/plugin.rs +++ b/crates/pinakes-server/tests/plugin.rs @@ -1,219 +1,243 @@ -use std::net::SocketAddr; -use std::sync::Arc; +use std::{net::SocketAddr, sync::Arc}; -use axum::body::Body; -use axum::extract::ConnectInfo; -use axum::http::{Request, StatusCode}; +use axum::{ + body::Body, + extract::ConnectInfo, + http::{Request, StatusCode}, +}; use http_body_util::BodyExt; +use pinakes_core::{ + cache::CacheLayer, + config::{ + AccountsConfig, + AnalyticsConfig, + CloudConfig, + Config, + DirectoryConfig, + EnrichmentConfig, + JobsConfig, + ManagedStorageConfig, + PhotoConfig, + PluginsConfig, + ScanningConfig, + ServerConfig, + SharingConfig, + SqliteConfig, + StorageBackendType, + StorageConfig, + SyncConfig, + ThumbnailConfig, + TlsConfig, + TranscodingConfig, + UiConfig, + WebhookConfig, + }, + jobs::JobQueue, + plugin::PluginManager, + storage::{StorageBackend, sqlite::SqliteBackend}, +}; use tokio::sync::RwLock; use tower::ServiceExt; -use pinakes_core::cache::CacheLayer; -use pinakes_core::config::{ - AccountsConfig, AnalyticsConfig, CloudConfig, Config, DirectoryConfig, EnrichmentConfig, - JobsConfig, ManagedStorageConfig, PhotoConfig, PluginsConfig, ScanningConfig, ServerConfig, - SharingConfig, SqliteConfig, StorageBackendType, StorageConfig, SyncConfig, ThumbnailConfig, - TlsConfig, TranscodingConfig, UiConfig, WebhookConfig, -}; -use pinakes_core::jobs::JobQueue; -use pinakes_core::plugin::PluginManager; -use pinakes_core::storage::StorageBackend; -use pinakes_core::storage::sqlite::SqliteBackend; - /// Fake socket address for tests (governor needs ConnectInfo) fn test_addr() -> ConnectInfo { - ConnectInfo("127.0.0.1:9999".parse().unwrap()) + ConnectInfo("127.0.0.1:9999".parse().unwrap()) } /// Build a GET request with ConnectInfo for rate limiter compatibility fn get(uri: &str) -> Request { - let mut req = Request::builder().uri(uri).body(Body::empty()).unwrap(); - req.extensions_mut().insert(test_addr()); - req + let mut req = Request::builder().uri(uri).body(Body::empty()).unwrap(); + req.extensions_mut().insert(test_addr()); + req } -async fn setup_app_with_plugins() -> (axum::Router, Arc, tempfile::TempDir) { - let backend = SqliteBackend::in_memory().expect("in-memory SQLite"); - backend.run_migrations().await.expect("migrations"); - let storage = Arc::new(backend) as pinakes_core::storage::DynStorageBackend; +async fn setup_app_with_plugins() +-> (axum::Router, Arc, tempfile::TempDir) { + let backend = SqliteBackend::in_memory().expect("in-memory SQLite"); + backend.run_migrations().await.expect("migrations"); + let storage = Arc::new(backend) as pinakes_core::storage::DynStorageBackend; - // Create temp directories for plugin manager (automatically cleaned up when TempDir drops) - let temp_dir = tempfile::TempDir::new().expect("create temp dir"); - let data_dir = temp_dir.path().join("data"); - let cache_dir = temp_dir.path().join("cache"); - std::fs::create_dir_all(&data_dir).expect("create data dir"); - std::fs::create_dir_all(&cache_dir).expect("create cache dir"); + // Create temp directories for plugin manager (automatically cleaned up when + // TempDir drops) + let temp_dir = tempfile::TempDir::new().expect("create temp dir"); + let data_dir = temp_dir.path().join("data"); + let cache_dir = temp_dir.path().join("cache"); + std::fs::create_dir_all(&data_dir).expect("create data dir"); + std::fs::create_dir_all(&cache_dir).expect("create cache dir"); - let plugin_config = PluginsConfig { - enabled: true, - data_dir: data_dir.clone(), - cache_dir: cache_dir.clone(), - plugin_dirs: vec![], - enable_hot_reload: false, - allow_unsigned: true, - max_concurrent_ops: 2, - plugin_timeout_secs: 10, - }; + let plugin_config = PluginsConfig { + enabled: true, + data_dir: data_dir.clone(), + cache_dir: cache_dir.clone(), + plugin_dirs: vec![], + enable_hot_reload: false, + allow_unsigned: true, + max_concurrent_ops: 2, + plugin_timeout_secs: 10, + }; - let plugin_manager = PluginManager::new(data_dir, cache_dir, plugin_config.clone().into()) - .expect("create plugin manager"); - let plugin_manager = Arc::new(plugin_manager); + let plugin_manager = + PluginManager::new(data_dir, cache_dir, plugin_config.clone().into()) + .expect("create plugin manager"); + let plugin_manager = Arc::new(plugin_manager); - let config = Config { - storage: StorageConfig { - backend: StorageBackendType::Sqlite, - sqlite: Some(SqliteConfig { - path: ":memory:".into(), - }), - postgres: None, - }, - directories: DirectoryConfig { roots: vec![] }, - scanning: ScanningConfig { - watch: false, - poll_interval_secs: 300, - ignore_patterns: vec![], - import_concurrency: 8, - }, - server: ServerConfig { - host: "127.0.0.1".to_string(), - port: 3000, - api_key: None, - tls: TlsConfig::default(), - authentication_disabled: true, - }, - ui: UiConfig::default(), - accounts: AccountsConfig::default(), - jobs: JobsConfig::default(), - thumbnails: ThumbnailConfig::default(), - webhooks: Vec::::new(), - scheduled_tasks: vec![], - plugins: plugin_config, - transcoding: TranscodingConfig::default(), - enrichment: EnrichmentConfig::default(), - cloud: CloudConfig::default(), - analytics: AnalyticsConfig::default(), - photos: PhotoConfig::default(), - managed_storage: ManagedStorageConfig::default(), - sync: SyncConfig::default(), - sharing: SharingConfig::default(), - }; + let config = Config { + storage: StorageConfig { + backend: StorageBackendType::Sqlite, + sqlite: Some(SqliteConfig { + path: ":memory:".into(), + }), + postgres: None, + }, + directories: DirectoryConfig { roots: vec![] }, + scanning: ScanningConfig { + watch: false, + poll_interval_secs: 300, + ignore_patterns: vec![], + import_concurrency: 8, + }, + server: ServerConfig { + host: "127.0.0.1".to_string(), + port: 3000, + api_key: None, + tls: TlsConfig::default(), + authentication_disabled: true, + }, + ui: UiConfig::default(), + accounts: AccountsConfig::default(), + jobs: JobsConfig::default(), + thumbnails: ThumbnailConfig::default(), + webhooks: Vec::::new(), + scheduled_tasks: vec![], + plugins: plugin_config, + transcoding: TranscodingConfig::default(), + enrichment: EnrichmentConfig::default(), + cloud: CloudConfig::default(), + analytics: AnalyticsConfig::default(), + photos: PhotoConfig::default(), + managed_storage: ManagedStorageConfig::default(), + sync: SyncConfig::default(), + sharing: SharingConfig::default(), + }; - let job_queue = JobQueue::new(1, |_id, _kind, _cancel, _jobs| tokio::spawn(async {})); - let config = Arc::new(RwLock::new(config)); - let scheduler = pinakes_core::scheduler::TaskScheduler::new( - job_queue.clone(), - tokio_util::sync::CancellationToken::new(), - config.clone(), - None, - ); + let job_queue = + JobQueue::new(1, |_id, _kind, _cancel, _jobs| tokio::spawn(async {})); + let config = Arc::new(RwLock::new(config)); + let scheduler = pinakes_core::scheduler::TaskScheduler::new( + job_queue.clone(), + tokio_util::sync::CancellationToken::new(), + config.clone(), + None, + ); - let state = pinakes_server::state::AppState { - storage, - config, - config_path: None, - scan_progress: pinakes_core::scan::ScanProgress::new(), - job_queue, - cache: Arc::new(CacheLayer::new(60)), - scheduler: Arc::new(scheduler), - plugin_manager: Some(plugin_manager.clone()), - transcode_service: None, - managed_storage: None, - chunked_upload_manager: None, - }; + let state = pinakes_server::state::AppState { + storage, + config, + config_path: None, + scan_progress: pinakes_core::scan::ScanProgress::new(), + job_queue, + cache: Arc::new(CacheLayer::new(60)), + scheduler: Arc::new(scheduler), + plugin_manager: Some(plugin_manager.clone()), + transcode_service: None, + managed_storage: None, + chunked_upload_manager: None, + }; - let router = pinakes_server::app::create_router(state); - (router, plugin_manager, temp_dir) + let router = pinakes_server::app::create_router(state); + (router, plugin_manager, temp_dir) } #[tokio::test] async fn test_list_plugins_empty() { - let (app, _pm, _tmp) = setup_app_with_plugins().await; + let (app, _pm, _tmp) = setup_app_with_plugins().await; - let response = app.oneshot(get("/api/v1/plugins")).await.unwrap(); + let response = app.oneshot(get("/api/v1/plugins")).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = response.into_body().collect().await.unwrap().to_bytes(); - let plugins: Vec = serde_json::from_slice(&body).unwrap(); - assert_eq!(plugins.len(), 0, "should start with no plugins loaded"); + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let plugins: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(plugins.len(), 0, "should start with no plugins loaded"); } #[tokio::test] async fn test_plugin_manager_exists() { - let (app, _pm, _tmp) = setup_app_with_plugins().await; + let (app, _pm, _tmp) = setup_app_with_plugins().await; - // Verify plugin manager is accessible - let plugins = _pm.list_plugins().await; - assert_eq!(plugins.len(), 0); + // Verify plugin manager is accessible + let plugins = _pm.list_plugins().await; + assert_eq!(plugins.len(), 0); - // Verify API endpoint works - let response = app.oneshot(get("/api/v1/plugins")).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); + // Verify API endpoint works + let response = app.oneshot(get("/api/v1/plugins")).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); } #[tokio::test] async fn test_plugin_not_found() { - let (app, _pm, _tmp) = setup_app_with_plugins().await; + let (app, _pm, _tmp) = setup_app_with_plugins().await; - let response = app - .oneshot(get("/api/v1/plugins/nonexistent")) - .await - .unwrap(); + let response = app + .oneshot(get("/api/v1/plugins/nonexistent")) + .await + .unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn test_plugin_enable_disable() { - let (app, pm, _tmp) = setup_app_with_plugins().await; + let (app, pm, _tmp) = setup_app_with_plugins().await; - // Verify plugin manager is initialized - assert!(pm.list_plugins().await.is_empty()); + // Verify plugin manager is initialized + assert!(pm.list_plugins().await.is_empty()); - // For this test, we would need to actually load a plugin first - // Since we don't have a real WASM plugin loaded, we'll just verify - // the endpoints exist and return appropriate errors + // For this test, we would need to actually load a plugin first + // Since we don't have a real WASM plugin loaded, we'll just verify + // the endpoints exist and return appropriate errors - let mut req = Request::builder() - .method("POST") - .uri("/api/v1/plugins/test-plugin/enable") - .body(Body::empty()) - .unwrap(); - req.extensions_mut().insert(test_addr()); + let mut req = Request::builder() + .method("POST") + .uri("/api/v1/plugins/test-plugin/enable") + .body(Body::empty()) + .unwrap(); + req.extensions_mut().insert(test_addr()); - let response = app.clone().oneshot(req).await.unwrap(); + let response = app.clone().oneshot(req).await.unwrap(); - // Should be NOT_FOUND since plugin doesn't exist - assert_eq!(response.status(), StatusCode::NOT_FOUND); + // Should be NOT_FOUND since plugin doesn't exist + assert_eq!(response.status(), StatusCode::NOT_FOUND); - // Test disable endpoint - let mut req = Request::builder() - .method("POST") - .uri("/api/v1/plugins/test-plugin/disable") - .body(Body::empty()) - .unwrap(); - req.extensions_mut().insert(test_addr()); + // Test disable endpoint + let mut req = Request::builder() + .method("POST") + .uri("/api/v1/plugins/test-plugin/disable") + .body(Body::empty()) + .unwrap(); + req.extensions_mut().insert(test_addr()); - let response = app.oneshot(req).await.unwrap(); + let response = app.oneshot(req).await.unwrap(); - // Should also be NOT_FOUND - assert_eq!(response.status(), StatusCode::NOT_FOUND); + // Should also be NOT_FOUND + assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn test_plugin_uninstall_not_found() { - let (app, _pm, _tmp) = setup_app_with_plugins().await; + let (app, _pm, _tmp) = setup_app_with_plugins().await; - let mut req = Request::builder() - .method("DELETE") - .uri("/api/v1/plugins/nonexistent") - .body(Body::empty()) - .unwrap(); - req.extensions_mut().insert(test_addr()); + let mut req = Request::builder() + .method("DELETE") + .uri("/api/v1/plugins/nonexistent") + .body(Body::empty()) + .unwrap(); + req.extensions_mut().insert(test_addr()); - let response = app.oneshot(req).await.unwrap(); + let response = app.oneshot(req).await.unwrap(); - // Expect 400 or 404 when plugin doesn't exist - assert!( - response.status() == StatusCode::BAD_REQUEST || response.status() == StatusCode::NOT_FOUND - ); + // Expect 400 or 404 when plugin doesn't exist + assert!( + response.status() == StatusCode::BAD_REQUEST + || response.status() == StatusCode::NOT_FOUND + ); } diff --git a/crates/pinakes-tui/src/app.rs b/crates/pinakes-tui/src/app.rs index dcfa3fb..c4111e2 100644 --- a/crates/pinakes-tui/src/app.rs +++ b/crates/pinakes-tui/src/app.rs @@ -1,1324 +1,1384 @@ -use std::collections::HashSet; -use std::time::Duration; +use std::{collections::HashSet, time::Duration}; use anyhow::Result; -use crossterm::execute; -use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; -use ratatui::Terminal; -use ratatui::backend::CrosstermBackend; +use crossterm::{ + execute, + terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{Terminal, backend::CrosstermBackend}; -use crate::client::{ApiClient, AuditEntryResponse}; -use crate::event::{ApiResult, AppEvent, EventHandler}; -use crate::input::{self, Action}; -use crate::ui; +use crate::{ + client::{ApiClient, AuditEntryResponse}, + event::{ApiResult, AppEvent, EventHandler}, + input::{self, Action}, + ui, +}; #[derive(Debug, Clone, Copy, PartialEq)] pub enum View { - Library, - Search, - Detail, - Tags, - Collections, - Audit, - Import, - Settings, - Duplicates, - Database, - MetadataEdit, - Queue, - Statistics, - Tasks, + Library, + Search, + Detail, + Tags, + Collections, + Audit, + Import, + Settings, + Duplicates, + Database, + MetadataEdit, + Queue, + Statistics, + Tasks, } pub struct AppState { - pub current_view: View, - pub media_list: Vec, - pub selected_index: Option, - pub selected_media: Option, - pub search_input: String, - pub search_results: Vec, - pub search_selected: Option, - pub search_total_count: u64, - pub tags: Vec, - pub all_tags: Vec, - pub tag_selected: Option, - pub collections: Vec, - pub collection_selected: Option, - pub audit_log: Vec, - pub audit_selected: Option, - pub input_mode: bool, - pub import_input: String, - pub status_message: Option, - pub should_quit: bool, - pub page_offset: u64, - pub page_size: u64, - pub total_media_count: u64, - pub server_url: String, - // Multi-select support - pub selected_items: HashSet, - pub selection_mode: bool, - // Duplicates view - pub duplicate_groups: Vec, - pub duplicates_selected: Option, - // Database view - pub database_stats: Option>, - // Metadata edit view - pub edit_title: String, - pub edit_artist: String, - pub edit_album: String, - pub edit_genre: String, - pub edit_year: String, - pub edit_description: String, - pub edit_field_index: Option, - // Queue view - pub play_queue: Vec, - pub queue_current_index: Option, - pub queue_selected: Option, - pub queue_repeat: u8, - pub queue_shuffle: bool, - // Statistics view - pub library_stats: Option, - // Scheduled tasks view - pub scheduled_tasks: Vec, - pub scheduled_tasks_selected: Option, + pub current_view: View, + pub media_list: Vec, + pub selected_index: Option, + pub selected_media: Option, + pub search_input: String, + pub search_results: Vec, + pub search_selected: Option, + pub search_total_count: u64, + pub tags: Vec, + pub all_tags: Vec, + pub tag_selected: Option, + pub collections: Vec, + pub collection_selected: Option, + pub audit_log: Vec, + pub audit_selected: Option, + pub input_mode: bool, + pub import_input: String, + pub status_message: Option, + pub should_quit: bool, + pub page_offset: u64, + pub page_size: u64, + pub total_media_count: u64, + pub server_url: String, + // Multi-select support + pub selected_items: HashSet, + pub selection_mode: bool, + // Duplicates view + pub duplicate_groups: Vec, + pub duplicates_selected: Option, + // Database view + pub database_stats: Option>, + // Metadata edit view + pub edit_title: String, + pub edit_artist: String, + pub edit_album: String, + pub edit_genre: String, + pub edit_year: String, + pub edit_description: String, + pub edit_field_index: Option, + // Queue view + pub play_queue: Vec, + pub queue_current_index: Option, + pub queue_selected: Option, + pub queue_repeat: u8, + pub queue_shuffle: bool, + // Statistics view + pub library_stats: Option, + // Scheduled tasks view + pub scheduled_tasks: Vec, + pub scheduled_tasks_selected: Option, } #[derive(Clone)] pub struct QueueItem { - pub media_id: String, - pub title: String, - pub artist: Option, - pub media_type: String, + pub media_id: String, + pub title: String, + pub artist: Option, + pub media_type: String, } impl AppState { - fn new(server_url: &str) -> Self { - Self { - current_view: View::Library, - media_list: Vec::new(), - selected_index: None, - selected_media: None, - search_input: String::new(), - search_results: Vec::new(), - search_selected: None, - search_total_count: 0, - tags: Vec::new(), - all_tags: Vec::new(), - tag_selected: None, - collections: Vec::new(), - collection_selected: None, - audit_log: Vec::new(), - audit_selected: None, - input_mode: false, - import_input: String::new(), - status_message: None, - should_quit: false, - duplicate_groups: Vec::new(), - duplicates_selected: None, - database_stats: None, - edit_title: String::new(), - edit_artist: String::new(), - edit_album: String::new(), - edit_genre: String::new(), - edit_year: String::new(), - edit_description: String::new(), - edit_field_index: None, - play_queue: Vec::new(), - queue_current_index: None, - queue_selected: None, - queue_repeat: 0, - queue_shuffle: false, - library_stats: None, - scheduled_tasks: Vec::new(), - scheduled_tasks_selected: None, - page_offset: 0, - page_size: 50, - total_media_count: 0, - server_url: server_url.to_string(), - // Multi-select - selected_items: HashSet::new(), - selection_mode: false, - } + fn new(server_url: &str) -> Self { + Self { + current_view: View::Library, + media_list: Vec::new(), + selected_index: None, + selected_media: None, + search_input: String::new(), + search_results: Vec::new(), + search_selected: None, + search_total_count: 0, + tags: Vec::new(), + all_tags: Vec::new(), + tag_selected: None, + collections: Vec::new(), + collection_selected: None, + audit_log: Vec::new(), + audit_selected: None, + input_mode: false, + import_input: String::new(), + status_message: None, + should_quit: false, + duplicate_groups: Vec::new(), + duplicates_selected: None, + database_stats: None, + edit_title: String::new(), + edit_artist: String::new(), + edit_album: String::new(), + edit_genre: String::new(), + edit_year: String::new(), + edit_description: String::new(), + edit_field_index: None, + play_queue: Vec::new(), + queue_current_index: None, + queue_selected: None, + queue_repeat: 0, + queue_shuffle: false, + library_stats: None, + scheduled_tasks: Vec::new(), + scheduled_tasks_selected: None, + page_offset: 0, + page_size: 50, + total_media_count: 0, + server_url: server_url.to_string(), + // Multi-select + selected_items: HashSet::new(), + selection_mode: false, } + } } pub async fn run(server_url: &str) -> Result<()> { - let client = ApiClient::new(server_url); - let mut state = AppState::new(server_url); + let client = ApiClient::new(server_url); + let mut state = AppState::new(server_url); - // Initial data load - match client.list_media(0, state.page_size).await { - Ok(items) => { - state.total_media_count = items.len() as u64; - if !items.is_empty() { - state.selected_index = Some(0); - } - state.media_list = items; - } - Err(e) => { - state.status_message = Some(format!("Failed to connect: {e}")); - } + // Initial data load + match client.list_media(0, state.page_size).await { + Ok(items) => { + state.total_media_count = items.len() as u64; + if !items.is_empty() { + state.selected_index = Some(0); + } + state.media_list = items; + }, + Err(e) => { + state.status_message = Some(format!("Failed to connect: {e}")); + }, + } + + // Setup terminal + terminal::enable_raw_mode()?; + let mut stdout = std::io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let mut events = EventHandler::new(Duration::from_millis(250)); + let event_sender = events.sender(); + + // Main loop + while !state.should_quit { + terminal.draw(|f| ui::render(f, &state))?; + + if let Some(event) = events.next().await { + match event { + AppEvent::Key(key) => { + let action = + input::handle_key(key, state.input_mode, &state.current_view); + handle_action(&client, &mut state, action, &event_sender).await; + }, + AppEvent::Tick => {}, + AppEvent::ApiResult(result) => { + handle_api_result(&mut state, result); + }, + } } + } - // Setup terminal - terminal::enable_raw_mode()?; - let mut stdout = std::io::stdout(); - execute!(stdout, EnterAlternateScreen)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; + // Restore terminal + terminal::disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; - let mut events = EventHandler::new(Duration::from_millis(250)); - let event_sender = events.sender(); - - // Main loop - while !state.should_quit { - terminal.draw(|f| ui::render(f, &state))?; - - if let Some(event) = events.next().await { - match event { - AppEvent::Key(key) => { - let action = input::handle_key(key, state.input_mode, &state.current_view); - handle_action(&client, &mut state, action, &event_sender).await; - } - AppEvent::Tick => {} - AppEvent::ApiResult(result) => { - handle_api_result(&mut state, result); - } - } - } - } - - // Restore terminal - terminal::disable_raw_mode()?; - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; - terminal.show_cursor()?; - - Ok(()) + Ok(()) } fn handle_api_result(state: &mut AppState, result: ApiResult) { - match result { - ApiResult::MediaList(items) => { - if !items.is_empty() && state.selected_index.is_none() { - state.selected_index = Some(0); - } - state.total_media_count = state.page_offset + items.len() as u64; - state.media_list = items; - } - ApiResult::SearchResults(resp) => { - state.search_total_count = resp.total_count; - state.search_results = resp.items; - if !state.search_results.is_empty() { - state.search_selected = Some(0); - } - } - ApiResult::AllTags(tags) => { - // All tags in the system (for Tags view) - state.tags = tags; - if !state.tags.is_empty() { - state.tag_selected = Some(0); - } - } - ApiResult::Collections(cols) => { - state.collections = cols; - if !state.collections.is_empty() { - state.collection_selected = Some(0); - } - } - ApiResult::ImportDone(resp) => { - if resp.was_duplicate { - state.status_message = - Some(format!("Import: file already exists ({})", resp.media_id)); - } else { - state.status_message = Some(format!("Imported: {}", resp.media_id)); - } - } - ApiResult::ScanDone(results) => { - let total: usize = results.iter().map(|r| r.files_processed).sum(); - let found: usize = results.iter().map(|r| r.files_found).sum(); - let errors: Vec = results.into_iter().flat_map(|r| r.errors).collect(); - if errors.is_empty() { - state.status_message = - Some(format!("Scan complete: {total}/{found} files processed")); - } else { - state.status_message = Some(format!( - "Scan complete: {total}/{found} files, {} errors", - errors.len() - )); - } - } - ApiResult::AuditLog(entries) => { - state.audit_log = entries; - if !state.audit_log.is_empty() { - state.audit_selected = Some(0); - } - } - ApiResult::Duplicates(groups) => { - if !groups.is_empty() { - state.duplicates_selected = Some(0); - } - state.status_message = Some(format!("Found {} duplicate groups", groups.len())); - state.duplicate_groups = groups; - } - ApiResult::DatabaseStats(stats) => { - state.database_stats = Some(vec![ - ("Media".to_string(), stats.media_count.to_string()), - ("Tags".to_string(), stats.tag_count.to_string()), - ( - "Collections".to_string(), - stats.collection_count.to_string(), - ), - ("Audit entries".to_string(), stats.audit_count.to_string()), - ( - "Database size".to_string(), - crate::ui::format_size(stats.database_size_bytes), - ), - ("Backend".to_string(), stats.backend_name), - ]); - state.status_message = None; - } - ApiResult::Statistics(stats) => { - state.library_stats = Some(stats); - state.status_message = None; - } - ApiResult::ScheduledTasks(tasks) => { - if !tasks.is_empty() && state.scheduled_tasks_selected.is_none() { - state.scheduled_tasks_selected = Some(0); - } - state.scheduled_tasks = tasks; - state.status_message = None; - } - ApiResult::MediaUpdated => { - state.status_message = Some("Media updated".into()); - } - ApiResult::Error(msg) => { - state.status_message = Some(format!("Error: {msg}")); - } - } + match result { + ApiResult::MediaList(items) => { + if !items.is_empty() && state.selected_index.is_none() { + state.selected_index = Some(0); + } + state.total_media_count = state.page_offset + items.len() as u64; + state.media_list = items; + }, + ApiResult::SearchResults(resp) => { + state.search_total_count = resp.total_count; + state.search_results = resp.items; + if !state.search_results.is_empty() { + state.search_selected = Some(0); + } + }, + ApiResult::AllTags(tags) => { + // All tags in the system (for Tags view) + state.tags = tags; + if !state.tags.is_empty() { + state.tag_selected = Some(0); + } + }, + ApiResult::Collections(cols) => { + state.collections = cols; + if !state.collections.is_empty() { + state.collection_selected = Some(0); + } + }, + ApiResult::ImportDone(resp) => { + if resp.was_duplicate { + state.status_message = + Some(format!("Import: file already exists ({})", resp.media_id)); + } else { + state.status_message = Some(format!("Imported: {}", resp.media_id)); + } + }, + ApiResult::ScanDone(results) => { + let total: usize = results.iter().map(|r| r.files_processed).sum(); + let found: usize = results.iter().map(|r| r.files_found).sum(); + let errors: Vec = + results.into_iter().flat_map(|r| r.errors).collect(); + if errors.is_empty() { + state.status_message = + Some(format!("Scan complete: {total}/{found} files processed")); + } else { + state.status_message = Some(format!( + "Scan complete: {total}/{found} files, {} errors", + errors.len() + )); + } + }, + ApiResult::AuditLog(entries) => { + state.audit_log = entries; + if !state.audit_log.is_empty() { + state.audit_selected = Some(0); + } + }, + ApiResult::Duplicates(groups) => { + if !groups.is_empty() { + state.duplicates_selected = Some(0); + } + state.status_message = + Some(format!("Found {} duplicate groups", groups.len())); + state.duplicate_groups = groups; + }, + ApiResult::DatabaseStats(stats) => { + state.database_stats = Some(vec![ + ("Media".to_string(), stats.media_count.to_string()), + ("Tags".to_string(), stats.tag_count.to_string()), + ( + "Collections".to_string(), + stats.collection_count.to_string(), + ), + ("Audit entries".to_string(), stats.audit_count.to_string()), + ( + "Database size".to_string(), + crate::ui::format_size(stats.database_size_bytes), + ), + ("Backend".to_string(), stats.backend_name), + ]); + state.status_message = None; + }, + ApiResult::Statistics(stats) => { + state.library_stats = Some(stats); + state.status_message = None; + }, + ApiResult::ScheduledTasks(tasks) => { + if !tasks.is_empty() && state.scheduled_tasks_selected.is_none() { + state.scheduled_tasks_selected = Some(0); + } + state.scheduled_tasks = tasks; + state.status_message = None; + }, + ApiResult::MediaUpdated => { + state.status_message = Some("Media updated".into()); + }, + ApiResult::Error(msg) => { + state.status_message = Some(format!("Error: {msg}")); + }, + } } async fn handle_action( - client: &ApiClient, - state: &mut AppState, - action: Action, - event_sender: &tokio::sync::mpsc::UnboundedSender, + client: &ApiClient, + state: &mut AppState, + action: Action, + event_sender: &tokio::sync::mpsc::UnboundedSender, ) { - match action { - Action::Quit => state.should_quit = true, - Action::NavigateDown => { - let len = match state.current_view { - View::Search => state.search_results.len(), - View::Tags => state.tags.len(), - View::Collections => state.collections.len(), - View::Audit => state.audit_log.len(), - _ => state.media_list.len(), - }; - if len > 0 { - let idx = match state.current_view { - View::Search => &mut state.search_selected, - View::Tags => &mut state.tag_selected, - View::Collections => &mut state.collection_selected, - View::Audit => &mut state.audit_selected, - _ => &mut state.selected_index, - }; - *idx = Some(idx.map(|i| (i + 1).min(len - 1)).unwrap_or(0)); - } - } - Action::NavigateUp => { - let idx = match state.current_view { - View::Search => &mut state.search_selected, - View::Tags => &mut state.tag_selected, - View::Collections => &mut state.collection_selected, - View::Audit => &mut state.audit_selected, - _ => &mut state.selected_index, - }; - *idx = Some(idx.map(|i| i.saturating_sub(1)).unwrap_or(0)); - } - Action::GoTop => { - let idx = match state.current_view { - View::Search => &mut state.search_selected, - View::Tags => &mut state.tag_selected, - View::Collections => &mut state.collection_selected, - View::Audit => &mut state.audit_selected, - _ => &mut state.selected_index, - }; - *idx = Some(0); - } - Action::GoBottom => { - let len = match state.current_view { - View::Search => state.search_results.len(), - View::Tags => state.tags.len(), - View::Collections => state.collections.len(), - View::Audit => state.audit_log.len(), - _ => state.media_list.len(), - }; - if len > 0 { - let idx = match state.current_view { - View::Search => &mut state.search_selected, - View::Tags => &mut state.tag_selected, - View::Collections => &mut state.collection_selected, - View::Audit => &mut state.audit_selected, - _ => &mut state.selected_index, - }; - *idx = Some(len - 1); - } - } - Action::Select => { - if state.input_mode { - state.input_mode = false; - match state.current_view { - View::Search => { - let query = state.search_input.clone(); - state.status_message = Some("Searching...".into()); - let client = client.clone(); - let tx = event_sender.clone(); - let page_size = state.page_size; - tokio::spawn(async move { - match client.search(&query, 0, page_size).await { - Ok(results) => { - if let Err(e) = tx.send(AppEvent::ApiResult( - ApiResult::SearchResults(results), - )) { - tracing::warn!("failed to send event: {e}"); - } - } - Err(e) => { - if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error( - format!("Search: {e}"), - ))) { - tracing::warn!("failed to send event: {e}"); - } - } - } - }); - } - View::Import => { - let path = state.import_input.clone(); - if !path.is_empty() { - state.status_message = Some("Importing...".into()); - let client = client.clone(); - let tx = event_sender.clone(); - let page_size = state.page_size; - tokio::spawn(async move { - match client.import_file(&path).await { - Ok(resp) => { - if let Err(e) = tx - .send(AppEvent::ApiResult(ApiResult::ImportDone(resp))) - { - tracing::warn!("failed to send event: {e}"); - } - // Also refresh the media list - if let Ok(items) = client.list_media(0, page_size).await - && let Err(e) = tx.send(AppEvent::ApiResult( - ApiResult::MediaList(items), - )) - { - tracing::warn!("failed to send event: {e}"); - } - } - Err(e) => { - if let Err(e) = tx.send(AppEvent::ApiResult( - ApiResult::Error(format!("Import: {e}")), - )) { - tracing::warn!("failed to send event: {e}"); - } - } - } - }); - state.import_input.clear(); - } - state.current_view = View::Library; - } - View::Tags => { - // Create a new tag using the entered name - let name = state.search_input.clone(); - if !name.is_empty() { - match client.create_tag(&name, None).await { - Ok(tag) => { - state.tags.push(tag); - state.status_message = Some(format!("Created tag: {name}")); - } - Err(e) => { - state.status_message = Some(format!("Create tag error: {e}")); - } - } - state.search_input.clear(); - } - } - _ => {} - } - } else { - // Open detail view for the selected item - let item = match state.current_view { - View::Search => state - .search_selected - .and_then(|i| state.search_results.get(i)) - .cloned(), - _ => state - .selected_index - .and_then(|i| state.media_list.get(i)) - .cloned(), - }; - if let Some(media) = item { - match client.get_media(&media.id).await { - Ok(full_media) => { - // Fetch tags for this media item - let media_tags = client.get_media_tags(&full_media.id).await.ok(); - // Also fetch all tags for tag/untag operations - let all_tags = client.list_tags().await.ok(); - state.selected_media = Some(full_media); - if let Some(tags) = media_tags { - state.tags = tags; - } - if let Some(all) = all_tags { - state.all_tags = all; - } - state.current_view = View::Detail; - } - Err(_) => { - state.selected_media = Some(media); - state.current_view = View::Detail; - } - } - } - } - } - Action::Back => { - if state.input_mode { - state.input_mode = false; - } else { - state.current_view = View::Library; - state.status_message = None; - } - } - Action::Search => { - state.current_view = View::Search; - state.input_mode = true; - } - Action::Import => { - state.current_view = View::Import; - state.input_mode = true; - state.import_input.clear(); - } - Action::Open => { - if let Some(ref media) = state.selected_media { - match client.open_media(&media.id).await { - Ok(_) => state.status_message = Some("Opened file".into()), - Err(e) => state.status_message = Some(format!("Open error: {e}")), - } - } else if let Some(idx) = state.selected_index - && let Some(media) = state.media_list.get(idx) - { - match client.open_media(&media.id).await { - Ok(_) => state.status_message = Some("Opened file".into()), - Err(e) => state.status_message = Some(format!("Open error: {e}")), - } - } - } - Action::Delete => { - if let Some(idx) = state.selected_index - && let Some(media) = state.media_list.get(idx).cloned() - { - match client.delete_media(&media.id).await { - Ok(_) => { - state.media_list.remove(idx); - if state.media_list.is_empty() { - state.selected_index = None; - } else if idx >= state.media_list.len() { - state.selected_index = Some(state.media_list.len() - 1); - } - state.status_message = Some("Deleted".into()); - } - Err(e) => state.status_message = Some(format!("Delete error: {e}")), - } - } - } - Action::TagView => { - state.current_view = View::Tags; - match client.list_tags().await { - Ok(tags) => { - if !tags.is_empty() { - state.tag_selected = Some(0); - } - state.tags = tags; - } - Err(e) => state.status_message = Some(format!("Tags error: {e}")), - } - } - Action::CollectionView => { - state.current_view = View::Collections; - match client.list_collections().await { - Ok(cols) => { - if !cols.is_empty() { - state.collection_selected = Some(0); - } - state.collections = cols; - } - Err(e) => state.status_message = Some(format!("Collections error: {e}")), - } - } - Action::AuditView => { - state.current_view = View::Audit; - match client.list_audit(0, state.page_size).await { - Ok(entries) => { - if !entries.is_empty() { - state.audit_selected = Some(0); - } - state.audit_log = entries; - } - Err(e) => state.status_message = Some(format!("Audit error: {e}")), - } - } - Action::SettingsView => { - state.current_view = View::Settings; - } - Action::DuplicatesView => { - state.current_view = View::Duplicates; - state.status_message = Some("Loading duplicates...".into()); + match action { + Action::Quit => state.should_quit = true, + Action::NavigateDown => { + let len = match state.current_view { + View::Search => state.search_results.len(), + View::Tags => state.tags.len(), + View::Collections => state.collections.len(), + View::Audit => state.audit_log.len(), + _ => state.media_list.len(), + }; + if len > 0 { + let idx = match state.current_view { + View::Search => &mut state.search_selected, + View::Tags => &mut state.tag_selected, + View::Collections => &mut state.collection_selected, + View::Audit => &mut state.audit_selected, + _ => &mut state.selected_index, + }; + *idx = Some(idx.map(|i| (i + 1).min(len - 1)).unwrap_or(0)); + } + }, + Action::NavigateUp => { + let idx = match state.current_view { + View::Search => &mut state.search_selected, + View::Tags => &mut state.tag_selected, + View::Collections => &mut state.collection_selected, + View::Audit => &mut state.audit_selected, + _ => &mut state.selected_index, + }; + *idx = Some(idx.map(|i| i.saturating_sub(1)).unwrap_or(0)); + }, + Action::GoTop => { + let idx = match state.current_view { + View::Search => &mut state.search_selected, + View::Tags => &mut state.tag_selected, + View::Collections => &mut state.collection_selected, + View::Audit => &mut state.audit_selected, + _ => &mut state.selected_index, + }; + *idx = Some(0); + }, + Action::GoBottom => { + let len = match state.current_view { + View::Search => state.search_results.len(), + View::Tags => state.tags.len(), + View::Collections => state.collections.len(), + View::Audit => state.audit_log.len(), + _ => state.media_list.len(), + }; + if len > 0 { + let idx = match state.current_view { + View::Search => &mut state.search_selected, + View::Tags => &mut state.tag_selected, + View::Collections => &mut state.collection_selected, + View::Audit => &mut state.audit_selected, + _ => &mut state.selected_index, + }; + *idx = Some(len - 1); + } + }, + Action::Select => { + if state.input_mode { + state.input_mode = false; + match state.current_view { + View::Search => { + let query = state.search_input.clone(); + state.status_message = Some("Searching...".into()); let client = client.clone(); let tx = event_sender.clone(); - tokio::spawn(async move { - match client.find_duplicates().await { - Ok(groups) => { - if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Duplicates(groups))) - { - tracing::warn!("failed to send event: {e}"); - } - } - Err(e) => { - if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( - "Duplicates: {e}" - )))) { - tracing::warn!("failed to send event: {e}"); - } - } - } - }); - } - Action::DatabaseView => { - state.current_view = View::Database; - state.status_message = Some("Loading stats...".into()); - let client = client.clone(); - let tx = event_sender.clone(); - tokio::spawn(async move { - match client.database_stats().await { - Ok(stats) => { - if let Err(e) = - tx.send(AppEvent::ApiResult(ApiResult::DatabaseStats(stats))) - { - tracing::warn!("failed to send event: {e}"); - } - } - Err(e) => { - if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( - "Database stats: {e}" - )))) { - tracing::warn!("failed to send event: {e}"); - } - } - } - // Also fetch background jobs info - match client.list_jobs().await { - Ok(jobs) => { - tracing::debug!("Found {} background jobs", jobs.len()); - for job in &jobs { - tracing::debug!( - "Job {}: kind={:?}, status={:?}, created={}, updated={}", - job.id, - job.kind, - job.status, - job.created_at, - job.updated_at - ); - } - } - Err(e) => tracing::warn!("Failed to list jobs: {}", e), - } - }); - } - Action::QueueView => { - state.current_view = View::Queue; - } - Action::StatisticsView => { - state.current_view = View::Statistics; - state.status_message = Some("Loading statistics...".into()); - let client = client.clone(); - let tx = event_sender.clone(); - tokio::spawn(async move { - match client.library_statistics().await { - Ok(stats) => { - if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Statistics(stats))) { - tracing::warn!("failed to send event: {e}"); - } - } - Err(e) => { - if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( - "Statistics: {e}" - )))) { - tracing::warn!("failed to send event: {e}"); - } - } - } - }); - } - Action::TasksView => { - state.current_view = View::Tasks; - state.status_message = Some("Loading tasks...".into()); - let client = client.clone(); - let tx = event_sender.clone(); - tokio::spawn(async move { - match client.list_scheduled_tasks().await { - Ok(tasks) => { - if let Err(e) = - tx.send(AppEvent::ApiResult(ApiResult::ScheduledTasks(tasks))) - { - tracing::warn!("failed to send event: {e}"); - } - } - Err(e) => { - if let Err(e) = - tx.send(AppEvent::ApiResult(ApiResult::Error(format!("Tasks: {e}")))) - { - tracing::warn!("failed to send event: {e}"); - } - } - } - }); - } - Action::ScanTrigger => { - state.status_message = Some("Scanning...".into()); - let client = client.clone(); - let tx = event_sender.clone(); - tokio::spawn(async move { - match client.trigger_scan(None).await { - Ok(results) => { - if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::ScanDone(results))) { - tracing::warn!("failed to send event: {e}"); - } - } - Err(e) => { - if let Err(e) = - tx.send(AppEvent::ApiResult(ApiResult::Error(format!("Scan: {e}")))) - { - tracing::warn!("failed to send event: {e}"); - } - } - } - }); - } - Action::Refresh => { - // Reload data for the current view asynchronously - state.status_message = Some("Refreshing...".into()); - let client = client.clone(); - let tx = event_sender.clone(); - let page_offset = state.page_offset; let page_size = state.page_size; - let view = state.current_view; tokio::spawn(async move { - match view { - View::Library | View::Detail | View::Import | View::Settings => { - match client.list_media(page_offset, page_size).await { - Ok(items) => { - if let Err(e) = - tx.send(AppEvent::ApiResult(ApiResult::MediaList(items))) - { - tracing::warn!("failed to send event: {e}"); - } - } - Err(e) => { - if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error( - format!("Refresh: {e}"), - ))) { - tracing::warn!("failed to send event: {e}"); - } - } - } - } - View::Tags => match client.list_tags().await { - Ok(tags) => { - if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::AllTags(tags))) { - tracing::warn!("failed to send event: {e}"); - } - } - Err(e) => { - if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( - "Refresh: {e}" - )))) { - tracing::warn!("failed to send event: {e}"); - } - } - }, - View::Collections => match client.list_collections().await { - Ok(cols) => { - if let Err(e) = - tx.send(AppEvent::ApiResult(ApiResult::Collections(cols))) - { - tracing::warn!("failed to send event: {e}"); - } - } - Err(e) => { - if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( - "Refresh: {e}" - )))) { - tracing::warn!("failed to send event: {e}"); - } - } - }, - View::Audit => match client.list_audit(0, page_size).await { - Ok(entries) => { - if let Err(e) = - tx.send(AppEvent::ApiResult(ApiResult::AuditLog(entries))) - { - tracing::warn!("failed to send event: {e}"); - } - } - Err(e) => { - if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( - "Refresh: {e}" - )))) { - tracing::warn!("failed to send event: {e}"); - } - } - }, - View::Search => { - // Nothing to refresh for search without a query - } - View::Duplicates => match client.find_duplicates().await { - Ok(groups) => { - if let Err(e) = - tx.send(AppEvent::ApiResult(ApiResult::Duplicates(groups))) - { - tracing::warn!("failed to send event: {e}"); - } - } - Err(e) => { - if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( - "Refresh: {e}" - )))) { - tracing::warn!("failed to send event: {e}"); - } - } - }, - View::Database => match client.database_stats().await { - Ok(stats) => { - if let Err(e) = - tx.send(AppEvent::ApiResult(ApiResult::DatabaseStats(stats))) - { - tracing::warn!("failed to send event: {e}"); - } - } - Err(e) => { - if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( - "Refresh: {e}" - )))) { - tracing::warn!("failed to send event: {e}"); - } - } - }, - View::Statistics => match client.library_statistics().await { - Ok(stats) => { - if let Err(e) = - tx.send(AppEvent::ApiResult(ApiResult::Statistics(stats))) - { - tracing::warn!("failed to send event: {e}"); - } - } - Err(e) => { - if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( - "Refresh: {e}" - )))) { - tracing::warn!("failed to send event: {e}"); - } - } - }, - View::Tasks => match client.list_scheduled_tasks().await { - Ok(tasks) => { - if let Err(e) = - tx.send(AppEvent::ApiResult(ApiResult::ScheduledTasks(tasks))) - { - tracing::warn!("failed to send event: {e}"); - } - } - Err(e) => { - if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( - "Refresh: {e}" - )))) { - tracing::warn!("failed to send event: {e}"); - } - } - }, - View::MetadataEdit | View::Queue => { - // No generic refresh for these views - } - } + match client.search(&query, 0, page_size).await { + Ok(results) => { + if let Err(e) = tx.send(AppEvent::ApiResult( + ApiResult::SearchResults(results), + )) { + tracing::warn!("failed to send event: {e}"); + } + }, + Err(e) => { + if let Err(e) = tx.send(AppEvent::ApiResult( + ApiResult::Error(format!("Search: {e}")), + )) { + tracing::warn!("failed to send event: {e}"); + } + }, + } }); - } - Action::NextTab => { - state.current_view = match state.current_view { - View::Library => View::Search, - View::Search => View::Tags, - View::Tags => View::Collections, - View::Collections => View::Audit, - View::Audit => View::Queue, - View::Queue => View::Statistics, - View::Statistics => View::Tasks, - View::Tasks => View::Library, - View::Detail - | View::Import - | View::Settings - | View::Duplicates - | View::Database - | View::MetadataEdit => View::Library, - }; - } - Action::PrevTab => { - state.current_view = match state.current_view { - View::Library => View::Tasks, - View::Search => View::Library, - View::Tags => View::Search, - View::Collections => View::Tags, - View::Audit => View::Collections, - View::Queue => View::Audit, - View::Statistics => View::Queue, - View::Tasks => View::Statistics, - View::Detail - | View::Import - | View::Settings - | View::Duplicates - | View::Database - | View::MetadataEdit => View::Library, - }; - } - Action::PageDown => { - state.page_offset += state.page_size; - match client.list_media(state.page_offset, state.page_size).await { - Ok(items) => { - if items.is_empty() { - state.page_offset = state.page_offset.saturating_sub(state.page_size); - } else { - state.total_media_count = state.page_offset + items.len() as u64; - state.media_list = items; - state.selected_index = Some(0); + }, + View::Import => { + let path = state.import_input.clone(); + if !path.is_empty() { + state.status_message = Some("Importing...".into()); + let client = client.clone(); + let tx = event_sender.clone(); + let page_size = state.page_size; + tokio::spawn(async move { + match client.import_file(&path).await { + Ok(resp) => { + if let Err(e) = + tx.send(AppEvent::ApiResult(ApiResult::ImportDone(resp))) + { + tracing::warn!("failed to send event: {e}"); } - } - Err(e) => state.status_message = Some(format!("Load error: {e}")), - } - } - Action::PageUp => { - if state.page_offset > 0 { - state.page_offset = state.page_offset.saturating_sub(state.page_size); - match client.list_media(state.page_offset, state.page_size).await { - Ok(items) => { - state.total_media_count = state.page_offset + items.len() as u64; - state.media_list = items; - state.selected_index = Some(0); + // Also refresh the media list + if let Ok(items) = client.list_media(0, page_size).await + && let Err(e) = tx + .send(AppEvent::ApiResult(ApiResult::MediaList(items))) + { + tracing::warn!("failed to send event: {e}"); } - Err(e) => state.status_message = Some(format!("Load error: {e}")), - } - } - } - Action::CreateTag => { - if state.current_view == View::Tags { - state.input_mode = true; - state.search_input.clear(); - state.status_message = Some("Enter tag name:".into()); - } - } - Action::DeleteSelected => match state.current_view { - View::Tags => { - if let Some(idx) = state.tag_selected - && let Some(tag) = state.tags.get(idx).cloned() - { - match client.delete_tag(&tag.id).await { - Ok(_) => { - state.tags.remove(idx); - if state.tags.is_empty() { - state.tag_selected = None; - } else if idx >= state.tags.len() { - state.tag_selected = Some(state.tags.len() - 1); - } - state.status_message = Some(format!("Deleted tag: {}", tag.name)); - } - Err(e) => state.status_message = Some(format!("Delete error: {e}")), + }, + Err(e) => { + if let Err(e) = tx.send(AppEvent::ApiResult( + ApiResult::Error(format!("Import: {e}")), + )) { + tracing::warn!("failed to send event: {e}"); } + }, } + }); + state.import_input.clear(); } - View::Collections => { - if let Some(idx) = state.collection_selected - && let Some(col) = state.collections.get(idx).cloned() - { - match client.delete_collection(&col.id).await { - Ok(_) => { - state.collections.remove(idx); - if state.collections.is_empty() { - state.collection_selected = None; - } else if idx >= state.collections.len() { - state.collection_selected = Some(state.collections.len() - 1); - } - state.status_message = - Some(format!("Deleted collection: {}", col.name)); - } - Err(e) => state.status_message = Some(format!("Delete error: {e}")), - } - } + state.current_view = View::Library; + }, + View::Tags => { + // Create a new tag using the entered name + let name = state.search_input.clone(); + if !name.is_empty() { + match client.create_tag(&name, None).await { + Ok(tag) => { + state.tags.push(tag); + state.status_message = Some(format!("Created tag: {name}")); + }, + Err(e) => { + state.status_message = Some(format!("Create tag error: {e}")); + }, + } + state.search_input.clear(); } - _ => {} + }, + _ => {}, + } + } else { + // Open detail view for the selected item + let item = match state.current_view { + View::Search => { + state + .search_selected + .and_then(|i| state.search_results.get(i)) + .cloned() + }, + _ => { + state + .selected_index + .and_then(|i| state.media_list.get(i)) + .cloned() + }, + }; + if let Some(media) = item { + match client.get_media(&media.id).await { + Ok(full_media) => { + // Fetch tags for this media item + let media_tags = client.get_media_tags(&full_media.id).await.ok(); + // Also fetch all tags for tag/untag operations + let all_tags = client.list_tags().await.ok(); + state.selected_media = Some(full_media); + if let Some(tags) = media_tags { + state.tags = tags; + } + if let Some(all) = all_tags { + state.all_tags = all; + } + state.current_view = View::Detail; + }, + Err(_) => { + state.selected_media = Some(media); + state.current_view = View::Detail; + }, + } + } + } + }, + Action::Back => { + if state.input_mode { + state.input_mode = false; + } else { + state.current_view = View::Library; + state.status_message = None; + } + }, + Action::Search => { + state.current_view = View::Search; + state.input_mode = true; + }, + Action::Import => { + state.current_view = View::Import; + state.input_mode = true; + state.import_input.clear(); + }, + Action::Open => { + if let Some(ref media) = state.selected_media { + match client.open_media(&media.id).await { + Ok(_) => state.status_message = Some("Opened file".into()), + Err(e) => state.status_message = Some(format!("Open error: {e}")), + } + } else if let Some(idx) = state.selected_index + && let Some(media) = state.media_list.get(idx) + { + match client.open_media(&media.id).await { + Ok(_) => state.status_message = Some("Opened file".into()), + Err(e) => state.status_message = Some(format!("Open error: {e}")), + } + } + }, + Action::Delete => { + if let Some(idx) = state.selected_index + && let Some(media) = state.media_list.get(idx).cloned() + { + match client.delete_media(&media.id).await { + Ok(_) => { + state.media_list.remove(idx); + if state.media_list.is_empty() { + state.selected_index = None; + } else if idx >= state.media_list.len() { + state.selected_index = Some(state.media_list.len() - 1); + } + state.status_message = Some("Deleted".into()); + }, + Err(e) => state.status_message = Some(format!("Delete error: {e}")), + } + } + }, + Action::TagView => { + state.current_view = View::Tags; + match client.list_tags().await { + Ok(tags) => { + if !tags.is_empty() { + state.tag_selected = Some(0); + } + state.tags = tags; }, - Action::Char(c) => { - if state.input_mode { - match state.current_view { - View::Import => state.import_input.push(c), - _ => state.search_input.push(c), - } - } - } - Action::Backspace => { - if state.input_mode { - match state.current_view { - View::Import => { - state.import_input.pop(); - } - _ => { - state.search_input.pop(); - } - } - } - } - Action::TagMedia => { - // Tag the currently selected media with the currently selected tag - if state.current_view == View::Detail { - if let (Some(media), Some(tag_idx)) = (&state.selected_media, state.tag_selected) { - if let Some(tag) = state.all_tags.get(tag_idx) { - let media_id = media.id.clone(); - let tag_id = tag.id.clone(); - let tag_name = tag.name.clone(); - match client.tag_media(&media_id, &tag_id).await { - Ok(_) => { - state.status_message = Some(format!("Tagged with: {tag_name}")); - // Refresh media tags - if let Ok(tags) = client.get_media_tags(&media_id).await { - state.tags = tags; - } - } - Err(e) => { - state.status_message = Some(format!("Tag error: {e}")); - } - } - } - } else { - state.status_message = Some("Select a media item and tag first".into()); - } - } - } - Action::UntagMedia => { - // Untag the currently selected media from the currently selected tag - if state.current_view == View::Detail { - if let (Some(media), Some(tag_idx)) = (&state.selected_media, state.tag_selected) { - if let Some(tag) = state.tags.get(tag_idx) { - let media_id = media.id.clone(); - let tag_id = tag.id.clone(); - let tag_name = tag.name.clone(); - match client.untag_media(&media_id, &tag_id).await { - Ok(_) => { - state.status_message = Some(format!("Removed tag: {tag_name}")); - // Refresh media tags - if let Ok(tags) = client.get_media_tags(&media_id).await { - state.tags = tags; - } - } - Err(e) => { - state.status_message = Some(format!("Untag error: {e}")); - } - } - } - } else { - state.status_message = Some("Select a media item and tag first".into()); - } - } - } - Action::Help => { - state.status_message = Some( - "?: Help q: Quit /: Search i: Import o: Open t: Tags c: Collections a: Audit s: Scan S: Settings r: Refresh Home/End: Top/Bottom".into() - ); - } - Action::Edit => { - if state.current_view == View::Detail - && let Some(ref media) = state.selected_media + Err(e) => state.status_message = Some(format!("Tags error: {e}")), + } + }, + Action::CollectionView => { + state.current_view = View::Collections; + match client.list_collections().await { + Ok(cols) => { + if !cols.is_empty() { + state.collection_selected = Some(0); + } + state.collections = cols; + }, + Err(e) => { + state.status_message = Some(format!("Collections error: {e}")) + }, + } + }, + Action::AuditView => { + state.current_view = View::Audit; + match client.list_audit(0, state.page_size).await { + Ok(entries) => { + if !entries.is_empty() { + state.audit_selected = Some(0); + } + state.audit_log = entries; + }, + Err(e) => state.status_message = Some(format!("Audit error: {e}")), + } + }, + Action::SettingsView => { + state.current_view = View::Settings; + }, + Action::DuplicatesView => { + state.current_view = View::Duplicates; + state.status_message = Some("Loading duplicates...".into()); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.find_duplicates().await { + Ok(groups) => { + if let Err(e) = + tx.send(AppEvent::ApiResult(ApiResult::Duplicates(groups))) { - // Populate edit fields from selected media - state.edit_title = media.title.clone().unwrap_or_default(); - state.edit_artist = media.artist.clone().unwrap_or_default(); - state.edit_album = media.album.clone().unwrap_or_default(); - state.edit_genre = media.genre.clone().unwrap_or_default(); - state.edit_year = media.year.map(|y| y.to_string()).unwrap_or_default(); - state.edit_description = media.description.clone().unwrap_or_default(); - state.edit_field_index = Some(0); - state.input_mode = true; - state.current_view = View::MetadataEdit; + tracing::warn!("failed to send event: {e}"); } - } - Action::Vacuum => { - if state.current_view == View::Database { - state.status_message = Some("Vacuuming database...".to_string()); - let client = client.clone(); - let tx = event_sender.clone(); - tokio::spawn(async move { - match client.vacuum_database().await { - Ok(()) => { - tracing::info!("Database vacuum completed"); - // Refresh stats after vacuum - if let Ok(stats) = client.database_stats().await { - let _ = - tx.send(AppEvent::ApiResult(ApiResult::DatabaseStats(stats))); - } - } - Err(e) => { - tracing::error!("Vacuum failed: {}", e); - let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( - "Vacuum failed: {e}" - )))); - } - } - }); + }, + Err(e) => { + if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error( + format!("Duplicates: {e}"), + ))) { + tracing::warn!("failed to send event: {e}"); } + }, } - Action::Toggle => { - if state.current_view == View::Tasks - && let Some(idx) = state.scheduled_tasks_selected - && let Some(task) = state.scheduled_tasks.get(idx) + }); + }, + Action::DatabaseView => { + state.current_view = View::Database; + state.status_message = Some("Loading stats...".into()); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.database_stats().await { + Ok(stats) => { + if let Err(e) = + tx.send(AppEvent::ApiResult(ApiResult::DatabaseStats(stats))) { - let task_id = task.id.clone(); - let client = client.clone(); - let tx = event_sender.clone(); - tokio::spawn(async move { - match client.toggle_scheduled_task(&task_id).await { - Ok(()) => { - // Refresh tasks list - if let Ok(tasks) = client.list_scheduled_tasks().await { - let _ = - tx.send(AppEvent::ApiResult(ApiResult::ScheduledTasks(tasks))); - } - } - Err(e) => { - tracing::error!("Failed to toggle task: {}", e); - let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( - "Toggle task failed: {e}" - )))); - } - } - }); + tracing::warn!("failed to send event: {e}"); } + }, + Err(e) => { + if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error( + format!("Database stats: {e}"), + ))) { + tracing::warn!("failed to send event: {e}"); + } + }, } - Action::RunNow => { - if state.current_view == View::Tasks - && let Some(idx) = state.scheduled_tasks_selected - && let Some(task) = state.scheduled_tasks.get(idx) + // Also fetch background jobs info + match client.list_jobs().await { + Ok(jobs) => { + tracing::debug!("Found {} background jobs", jobs.len()); + for job in &jobs { + tracing::debug!( + "Job {}: kind={:?}, status={:?}, created={}, updated={}", + job.id, + job.kind, + job.status, + job.created_at, + job.updated_at + ); + } + }, + Err(e) => tracing::warn!("Failed to list jobs: {}", e), + } + }); + }, + Action::QueueView => { + state.current_view = View::Queue; + }, + Action::StatisticsView => { + state.current_view = View::Statistics; + state.status_message = Some("Loading statistics...".into()); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.library_statistics().await { + Ok(stats) => { + if let Err(e) = + tx.send(AppEvent::ApiResult(ApiResult::Statistics(stats))) { - let task_id = task.id.clone(); - let task_name = task.name.clone(); - state.status_message = Some(format!("Running task: {task_name}...")); - let client = client.clone(); - let tx = event_sender.clone(); - tokio::spawn(async move { - match client.run_task_now(&task_id).await { - Ok(()) => { - // Refresh tasks list - if let Ok(tasks) = client.list_scheduled_tasks().await { - let _ = - tx.send(AppEvent::ApiResult(ApiResult::ScheduledTasks(tasks))); - } - } - Err(e) => { - tracing::error!("Failed to run task: {}", e); - let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( - "Run task failed: {e}" - )))); - } - } - }); + tracing::warn!("failed to send event: {e}"); } + }, + Err(e) => { + if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error( + format!("Statistics: {e}"), + ))) { + tracing::warn!("failed to send event: {e}"); + } + }, } - Action::Save => { - if state.current_view == View::MetadataEdit - && let Some(ref media) = state.selected_media + }); + }, + Action::TasksView => { + state.current_view = View::Tasks; + state.status_message = Some("Loading tasks...".into()); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.list_scheduled_tasks().await { + Ok(tasks) => { + if let Err(e) = + tx.send(AppEvent::ApiResult(ApiResult::ScheduledTasks(tasks))) { - let updates = serde_json::json!({ - "title": if state.edit_title.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_title.clone()) }, - "artist": if state.edit_artist.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_artist.clone()) }, - "album": if state.edit_album.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_album.clone()) }, - "genre": if state.edit_genre.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_genre.clone()) }, - "year": state.edit_year.parse::().ok(), - "description": if state.edit_description.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_description.clone()) }, - }); - let media_id = media.id.clone(); - let client = client.clone(); - let tx = event_sender.clone(); - state.status_message = Some("Saving...".to_string()); - tokio::spawn(async move { - match client.update_media(&media_id, updates).await { - Ok(_) => { - let _ = tx.send(AppEvent::ApiResult(ApiResult::MediaUpdated)); - } - Err(e) => { - tracing::error!("Failed to update media: {}", e); - let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( - "Update failed: {e}" - )))); - } - } - }); - state.input_mode = false; - state.current_view = View::Detail; + tracing::warn!("failed to send event: {e}"); } + }, + Err(e) => { + if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error( + format!("Tasks: {e}"), + ))) { + tracing::warn!("failed to send event: {e}"); + } + }, } - Action::ToggleSelection => { - // Toggle selection of current item - let item_id = match state.current_view { - View::Search => state - .search_selected - .and_then(|i| state.search_results.get(i)) - .map(|m| m.id.clone()), - View::Library => state - .selected_index - .and_then(|i| state.media_list.get(i)) - .map(|m| m.id.clone()), - _ => None, - }; - if let Some(id) = item_id { - if state.selected_items.contains(&id) { - state.selected_items.remove(&id); - } else { - state.selected_items.insert(id); + }); + }, + Action::ScanTrigger => { + state.status_message = Some("Scanning...".into()); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.trigger_scan(None).await { + Ok(results) => { + if let Err(e) = + tx.send(AppEvent::ApiResult(ApiResult::ScanDone(results))) + { + tracing::warn!("failed to send event: {e}"); + } + }, + Err(e) => { + if let Err(e) = tx + .send(AppEvent::ApiResult(ApiResult::Error(format!("Scan: {e}")))) + { + tracing::warn!("failed to send event: {e}"); + } + }, + } + }); + }, + Action::Refresh => { + // Reload data for the current view asynchronously + state.status_message = Some("Refreshing...".into()); + let client = client.clone(); + let tx = event_sender.clone(); + let page_offset = state.page_offset; + let page_size = state.page_size; + let view = state.current_view; + tokio::spawn(async move { + match view { + View::Library | View::Detail | View::Import | View::Settings => { + match client.list_media(page_offset, page_size).await { + Ok(items) => { + if let Err(e) = + tx.send(AppEvent::ApiResult(ApiResult::MediaList(items))) + { + tracing::warn!("failed to send event: {e}"); } - let count = state.selected_items.len(); - state.status_message = Some(format!("{} item(s) selected", count)); + }, + Err(e) => { + if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error( + format!("Refresh: {e}"), + ))) { + tracing::warn!("failed to send event: {e}"); + } + }, } - } - Action::SelectAll => { - // Select all items in current view - let items: Vec = match state.current_view { - View::Search => state.search_results.iter().map(|m| m.id.clone()).collect(), - View::Library => state.media_list.iter().map(|m| m.id.clone()).collect(), - _ => Vec::new(), - }; - for id in items { - state.selected_items.insert(id); + }, + View::Tags => { + match client.list_tags().await { + Ok(tags) => { + if let Err(e) = + tx.send(AppEvent::ApiResult(ApiResult::AllTags(tags))) + { + tracing::warn!("failed to send event: {e}"); + } + }, + Err(e) => { + if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error( + format!("Refresh: {e}"), + ))) { + tracing::warn!("failed to send event: {e}"); + } + }, } - let count = state.selected_items.len(); - state.status_message = Some(format!("{} item(s) selected", count)); + }, + View::Collections => { + match client.list_collections().await { + Ok(cols) => { + if let Err(e) = + tx.send(AppEvent::ApiResult(ApiResult::Collections(cols))) + { + tracing::warn!("failed to send event: {e}"); + } + }, + Err(e) => { + if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error( + format!("Refresh: {e}"), + ))) { + tracing::warn!("failed to send event: {e}"); + } + }, + } + }, + View::Audit => { + match client.list_audit(0, page_size).await { + Ok(entries) => { + if let Err(e) = + tx.send(AppEvent::ApiResult(ApiResult::AuditLog(entries))) + { + tracing::warn!("failed to send event: {e}"); + } + }, + Err(e) => { + if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error( + format!("Refresh: {e}"), + ))) { + tracing::warn!("failed to send event: {e}"); + } + }, + } + }, + View::Search => { + // Nothing to refresh for search without a query + }, + View::Duplicates => { + match client.find_duplicates().await { + Ok(groups) => { + if let Err(e) = + tx.send(AppEvent::ApiResult(ApiResult::Duplicates(groups))) + { + tracing::warn!("failed to send event: {e}"); + } + }, + Err(e) => { + if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error( + format!("Refresh: {e}"), + ))) { + tracing::warn!("failed to send event: {e}"); + } + }, + } + }, + View::Database => { + match client.database_stats().await { + Ok(stats) => { + if let Err(e) = + tx.send(AppEvent::ApiResult(ApiResult::DatabaseStats(stats))) + { + tracing::warn!("failed to send event: {e}"); + } + }, + Err(e) => { + if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error( + format!("Refresh: {e}"), + ))) { + tracing::warn!("failed to send event: {e}"); + } + }, + } + }, + View::Statistics => { + match client.library_statistics().await { + Ok(stats) => { + if let Err(e) = + tx.send(AppEvent::ApiResult(ApiResult::Statistics(stats))) + { + tracing::warn!("failed to send event: {e}"); + } + }, + Err(e) => { + if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error( + format!("Refresh: {e}"), + ))) { + tracing::warn!("failed to send event: {e}"); + } + }, + } + }, + View::Tasks => { + match client.list_scheduled_tasks().await { + Ok(tasks) => { + if let Err(e) = + tx.send(AppEvent::ApiResult(ApiResult::ScheduledTasks(tasks))) + { + tracing::warn!("failed to send event: {e}"); + } + }, + Err(e) => { + if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error( + format!("Refresh: {e}"), + ))) { + tracing::warn!("failed to send event: {e}"); + } + }, + } + }, + View::MetadataEdit | View::Queue => { + // No generic refresh for these views + }, } - Action::ClearSelection => { - state.selected_items.clear(); - state.selection_mode = false; - state.status_message = Some("Selection cleared".into()); + }); + }, + Action::NextTab => { + state.current_view = match state.current_view { + View::Library => View::Search, + View::Search => View::Tags, + View::Tags => View::Collections, + View::Collections => View::Audit, + View::Audit => View::Queue, + View::Queue => View::Statistics, + View::Statistics => View::Tasks, + View::Tasks => View::Library, + View::Detail + | View::Import + | View::Settings + | View::Duplicates + | View::Database + | View::MetadataEdit => View::Library, + }; + }, + Action::PrevTab => { + state.current_view = match state.current_view { + View::Library => View::Tasks, + View::Search => View::Library, + View::Tags => View::Search, + View::Collections => View::Tags, + View::Audit => View::Collections, + View::Queue => View::Audit, + View::Statistics => View::Queue, + View::Tasks => View::Statistics, + View::Detail + | View::Import + | View::Settings + | View::Duplicates + | View::Database + | View::MetadataEdit => View::Library, + }; + }, + Action::PageDown => { + state.page_offset += state.page_size; + match client.list_media(state.page_offset, state.page_size).await { + Ok(items) => { + if items.is_empty() { + state.page_offset = + state.page_offset.saturating_sub(state.page_size); + } else { + state.total_media_count = state.page_offset + items.len() as u64; + state.media_list = items; + state.selected_index = Some(0); + } + }, + Err(e) => state.status_message = Some(format!("Load error: {e}")), + } + }, + Action::PageUp => { + if state.page_offset > 0 { + state.page_offset = state.page_offset.saturating_sub(state.page_size); + match client.list_media(state.page_offset, state.page_size).await { + Ok(items) => { + state.total_media_count = state.page_offset + items.len() as u64; + state.media_list = items; + state.selected_index = Some(0); + }, + Err(e) => state.status_message = Some(format!("Load error: {e}")), } - Action::ToggleSelectionMode => { - state.selection_mode = !state.selection_mode; - if state.selection_mode { + } + }, + Action::CreateTag => { + if state.current_view == View::Tags { + state.input_mode = true; + state.search_input.clear(); + state.status_message = Some("Enter tag name:".into()); + } + }, + Action::DeleteSelected => { + match state.current_view { + View::Tags => { + if let Some(idx) = state.tag_selected + && let Some(tag) = state.tags.get(idx).cloned() + { + match client.delete_tag(&tag.id).await { + Ok(_) => { + state.tags.remove(idx); + if state.tags.is_empty() { + state.tag_selected = None; + } else if idx >= state.tags.len() { + state.tag_selected = Some(state.tags.len() - 1); + } state.status_message = - Some("Selection mode: ON (Space to toggle, u to clear)".into()); - } else { - state.status_message = Some("Selection mode: OFF".into()); + Some(format!("Deleted tag: {}", tag.name)); + }, + Err(e) => { + state.status_message = Some(format!("Delete error: {e}")) + }, } - } - Action::BatchDelete => { - if state.selected_items.is_empty() { - state.status_message = Some("No items selected".into()); - } else { - let count = state.selected_items.len(); - let ids: Vec = state.selected_items.iter().cloned().collect(); - state.status_message = Some(format!("Deleting {} item(s)...", count)); - let client = client.clone(); - let tx = event_sender.clone(); - let page_offset = state.page_offset; - let page_size = state.page_size; - tokio::spawn(async move { - let mut deleted = 0; - let mut errors = Vec::new(); - for id in &ids { - match client.delete_media(id).await { - Ok(_) => deleted += 1, - Err(e) => errors.push(format!("{}: {}", id, e)), - } - } - // Refresh the media list - if let Ok(items) = client.list_media(page_offset, page_size).await { - let _ = tx.send(AppEvent::ApiResult(ApiResult::MediaList(items))); - } - if errors.is_empty() { - let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( - "Deleted {} item(s)", - deleted - )))); - } else { - let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( - "Deleted {} item(s), {} error(s)", - deleted, - errors.len() - )))); - } - }); - state.selected_items.clear(); - } - } - Action::BatchTag => { - if state.selected_items.is_empty() { - state.status_message = Some("No items selected".into()); - } else if state.all_tags.is_empty() { - // Load tags first - match client.list_tags().await { - Ok(tags) => { - state.all_tags = tags; - if state.all_tags.is_empty() { - state.status_message = - Some("No tags available. Create a tag first.".into()); - } else { - state.tag_selected = Some(0); - state.status_message = Some(format!( - "{} item(s) selected. Press +/- to tag/untag with selected tag.", - state.selected_items.len() - )); - } - } - Err(e) => state.status_message = Some(format!("Failed to load tags: {e}")), + } + }, + View::Collections => { + if let Some(idx) = state.collection_selected + && let Some(col) = state.collections.get(idx).cloned() + { + match client.delete_collection(&col.id).await { + Ok(_) => { + state.collections.remove(idx); + if state.collections.is_empty() { + state.collection_selected = None; + } else if idx >= state.collections.len() { + state.collection_selected = Some(state.collections.len() - 1); } - } else if let Some(tag_idx) = state.tag_selected - && let Some(tag) = state.all_tags.get(tag_idx) - { - let count = state.selected_items.len(); - let ids: Vec = state.selected_items.iter().cloned().collect(); - let tag_id = tag.id.clone(); - let tag_name = tag.name.clone(); state.status_message = - Some(format!("Tagging {} item(s) with '{}'...", count, tag_name)); - let client = client.clone(); - let tx = event_sender.clone(); - tokio::spawn(async move { - let mut tagged = 0; - let mut errors = Vec::new(); - for id in &ids { - match client.tag_media(id, &tag_id).await { - Ok(_) => tagged += 1, - Err(e) => errors.push(format!("{}: {}", id, e)), - } - } - if errors.is_empty() { - let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( - "Tagged {} item(s) with '{}'", - tagged, tag_name - )))); - } else { - let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( - "Tagged {} item(s), {} error(s)", - tagged, - errors.len() - )))); - } - }); - } else { - state.status_message = Some("Select a tag first (use t to view tags)".into()); + Some(format!("Deleted collection: {}", col.name)); + }, + Err(e) => { + state.status_message = Some(format!("Delete error: {e}")) + }, } + } + }, + _ => {}, + } + }, + Action::Char(c) => { + if state.input_mode { + match state.current_view { + View::Import => state.import_input.push(c), + _ => state.search_input.push(c), } - Action::NavigateLeft | Action::NavigateRight | Action::None => {} - } + } + }, + Action::Backspace => { + if state.input_mode { + match state.current_view { + View::Import => { + state.import_input.pop(); + }, + _ => { + state.search_input.pop(); + }, + } + } + }, + Action::TagMedia => { + // Tag the currently selected media with the currently selected tag + if state.current_view == View::Detail { + if let (Some(media), Some(tag_idx)) = + (&state.selected_media, state.tag_selected) + { + if let Some(tag) = state.all_tags.get(tag_idx) { + let media_id = media.id.clone(); + let tag_id = tag.id.clone(); + let tag_name = tag.name.clone(); + match client.tag_media(&media_id, &tag_id).await { + Ok(_) => { + state.status_message = Some(format!("Tagged with: {tag_name}")); + // Refresh media tags + if let Ok(tags) = client.get_media_tags(&media_id).await { + state.tags = tags; + } + }, + Err(e) => { + state.status_message = Some(format!("Tag error: {e}")); + }, + } + } + } else { + state.status_message = + Some("Select a media item and tag first".into()); + } + } + }, + Action::UntagMedia => { + // Untag the currently selected media from the currently selected tag + if state.current_view == View::Detail { + if let (Some(media), Some(tag_idx)) = + (&state.selected_media, state.tag_selected) + { + if let Some(tag) = state.tags.get(tag_idx) { + let media_id = media.id.clone(); + let tag_id = tag.id.clone(); + let tag_name = tag.name.clone(); + match client.untag_media(&media_id, &tag_id).await { + Ok(_) => { + state.status_message = Some(format!("Removed tag: {tag_name}")); + // Refresh media tags + if let Ok(tags) = client.get_media_tags(&media_id).await { + state.tags = tags; + } + }, + Err(e) => { + state.status_message = Some(format!("Untag error: {e}")); + }, + } + } + } else { + state.status_message = + Some("Select a media item and tag first".into()); + } + } + }, + Action::Help => { + state.status_message = Some( + "?: Help q: Quit /: Search i: Import o: Open t: Tags c: \ + Collections a: Audit s: Scan S: Settings r: Refresh Home/End: \ + Top/Bottom" + .into(), + ); + }, + Action::Edit => { + if state.current_view == View::Detail + && let Some(ref media) = state.selected_media + { + // Populate edit fields from selected media + state.edit_title = media.title.clone().unwrap_or_default(); + state.edit_artist = media.artist.clone().unwrap_or_default(); + state.edit_album = media.album.clone().unwrap_or_default(); + state.edit_genre = media.genre.clone().unwrap_or_default(); + state.edit_year = media.year.map(|y| y.to_string()).unwrap_or_default(); + state.edit_description = media.description.clone().unwrap_or_default(); + state.edit_field_index = Some(0); + state.input_mode = true; + state.current_view = View::MetadataEdit; + } + }, + Action::Vacuum => { + if state.current_view == View::Database { + state.status_message = Some("Vacuuming database...".to_string()); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.vacuum_database().await { + Ok(()) => { + tracing::info!("Database vacuum completed"); + // Refresh stats after vacuum + if let Ok(stats) = client.database_stats().await { + let _ = + tx.send(AppEvent::ApiResult(ApiResult::DatabaseStats(stats))); + } + }, + Err(e) => { + tracing::error!("Vacuum failed: {}", e); + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Vacuum failed: {e}" + )))); + }, + } + }); + } + }, + Action::Toggle => { + if state.current_view == View::Tasks + && let Some(idx) = state.scheduled_tasks_selected + && let Some(task) = state.scheduled_tasks.get(idx) + { + let task_id = task.id.clone(); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.toggle_scheduled_task(&task_id).await { + Ok(()) => { + // Refresh tasks list + if let Ok(tasks) = client.list_scheduled_tasks().await { + let _ = tx + .send(AppEvent::ApiResult(ApiResult::ScheduledTasks(tasks))); + } + }, + Err(e) => { + tracing::error!("Failed to toggle task: {}", e); + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Toggle task failed: {e}" + )))); + }, + } + }); + } + }, + Action::RunNow => { + if state.current_view == View::Tasks + && let Some(idx) = state.scheduled_tasks_selected + && let Some(task) = state.scheduled_tasks.get(idx) + { + let task_id = task.id.clone(); + let task_name = task.name.clone(); + state.status_message = Some(format!("Running task: {task_name}...")); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.run_task_now(&task_id).await { + Ok(()) => { + // Refresh tasks list + if let Ok(tasks) = client.list_scheduled_tasks().await { + let _ = tx + .send(AppEvent::ApiResult(ApiResult::ScheduledTasks(tasks))); + } + }, + Err(e) => { + tracing::error!("Failed to run task: {}", e); + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Run task failed: {e}" + )))); + }, + } + }); + } + }, + Action::Save => { + if state.current_view == View::MetadataEdit + && let Some(ref media) = state.selected_media + { + let updates = serde_json::json!({ + "title": if state.edit_title.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_title.clone()) }, + "artist": if state.edit_artist.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_artist.clone()) }, + "album": if state.edit_album.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_album.clone()) }, + "genre": if state.edit_genre.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_genre.clone()) }, + "year": state.edit_year.parse::().ok(), + "description": if state.edit_description.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(state.edit_description.clone()) }, + }); + let media_id = media.id.clone(); + let client = client.clone(); + let tx = event_sender.clone(); + state.status_message = Some("Saving...".to_string()); + tokio::spawn(async move { + match client.update_media(&media_id, updates).await { + Ok(_) => { + let _ = tx.send(AppEvent::ApiResult(ApiResult::MediaUpdated)); + }, + Err(e) => { + tracing::error!("Failed to update media: {}", e); + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Update failed: {e}" + )))); + }, + } + }); + state.input_mode = false; + state.current_view = View::Detail; + } + }, + Action::ToggleSelection => { + // Toggle selection of current item + let item_id = match state.current_view { + View::Search => { + state + .search_selected + .and_then(|i| state.search_results.get(i)) + .map(|m| m.id.clone()) + }, + View::Library => { + state + .selected_index + .and_then(|i| state.media_list.get(i)) + .map(|m| m.id.clone()) + }, + _ => None, + }; + if let Some(id) = item_id { + if state.selected_items.contains(&id) { + state.selected_items.remove(&id); + } else { + state.selected_items.insert(id); + } + let count = state.selected_items.len(); + state.status_message = Some(format!("{} item(s) selected", count)); + } + }, + Action::SelectAll => { + // Select all items in current view + let items: Vec = match state.current_view { + View::Search => { + state.search_results.iter().map(|m| m.id.clone()).collect() + }, + View::Library => { + state.media_list.iter().map(|m| m.id.clone()).collect() + }, + _ => Vec::new(), + }; + for id in items { + state.selected_items.insert(id); + } + let count = state.selected_items.len(); + state.status_message = Some(format!("{} item(s) selected", count)); + }, + Action::ClearSelection => { + state.selected_items.clear(); + state.selection_mode = false; + state.status_message = Some("Selection cleared".into()); + }, + Action::ToggleSelectionMode => { + state.selection_mode = !state.selection_mode; + if state.selection_mode { + state.status_message = + Some("Selection mode: ON (Space to toggle, u to clear)".into()); + } else { + state.status_message = Some("Selection mode: OFF".into()); + } + }, + Action::BatchDelete => { + if state.selected_items.is_empty() { + state.status_message = Some("No items selected".into()); + } else { + let count = state.selected_items.len(); + let ids: Vec = state.selected_items.iter().cloned().collect(); + state.status_message = Some(format!("Deleting {} item(s)...", count)); + let client = client.clone(); + let tx = event_sender.clone(); + let page_offset = state.page_offset; + let page_size = state.page_size; + tokio::spawn(async move { + let mut deleted = 0; + let mut errors = Vec::new(); + for id in &ids { + match client.delete_media(id).await { + Ok(_) => deleted += 1, + Err(e) => errors.push(format!("{}: {}", id, e)), + } + } + // Refresh the media list + if let Ok(items) = client.list_media(page_offset, page_size).await { + let _ = tx.send(AppEvent::ApiResult(ApiResult::MediaList(items))); + } + if errors.is_empty() { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Deleted {} item(s)", + deleted + )))); + } else { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Deleted {} item(s), {} error(s)", + deleted, + errors.len() + )))); + } + }); + state.selected_items.clear(); + } + }, + Action::BatchTag => { + if state.selected_items.is_empty() { + state.status_message = Some("No items selected".into()); + } else if state.all_tags.is_empty() { + // Load tags first + match client.list_tags().await { + Ok(tags) => { + state.all_tags = tags; + if state.all_tags.is_empty() { + state.status_message = + Some("No tags available. Create a tag first.".into()); + } else { + state.tag_selected = Some(0); + state.status_message = Some(format!( + "{} item(s) selected. Press +/- to tag/untag with selected \ + tag.", + state.selected_items.len() + )); + } + }, + Err(e) => { + state.status_message = Some(format!("Failed to load tags: {e}")) + }, + } + } else if let Some(tag_idx) = state.tag_selected + && let Some(tag) = state.all_tags.get(tag_idx) + { + let count = state.selected_items.len(); + let ids: Vec = state.selected_items.iter().cloned().collect(); + let tag_id = tag.id.clone(); + let tag_name = tag.name.clone(); + state.status_message = + Some(format!("Tagging {} item(s) with '{}'...", count, tag_name)); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + let mut tagged = 0; + let mut errors = Vec::new(); + for id in &ids { + match client.tag_media(id, &tag_id).await { + Ok(_) => tagged += 1, + Err(e) => errors.push(format!("{}: {}", id, e)), + } + } + if errors.is_empty() { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Tagged {} item(s) with '{}'", + tagged, tag_name + )))); + } else { + let _ = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Tagged {} item(s), {} error(s)", + tagged, + errors.len() + )))); + } + }); + } else { + state.status_message = + Some("Select a tag first (use t to view tags)".into()); + } + }, + Action::NavigateLeft | Action::NavigateRight | Action::None => {}, + } } diff --git a/crates/pinakes-tui/src/client.rs b/crates/pinakes-tui/src/client.rs index aab93fc..291d4cb 100644 --- a/crates/pinakes-tui/src/client.rs +++ b/crates/pinakes-tui/src/client.rs @@ -1,456 +1,491 @@ +use std::collections::HashMap; + use anyhow::Result; use reqwest::Client; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; #[derive(Clone)] pub struct ApiClient { - client: Client, - base_url: String, + client: Client, + base_url: String, } // Response types (mirror server DTOs) #[derive(Debug, Clone, Deserialize, Serialize)] pub struct MediaResponse { - pub id: String, - pub path: String, - pub file_name: String, - pub media_type: String, - pub content_hash: String, - pub file_size: u64, - pub title: Option, - pub artist: Option, - pub album: Option, - pub genre: Option, - pub year: Option, - pub duration_secs: Option, - pub description: Option, - #[serde(default)] - pub has_thumbnail: bool, - pub custom_fields: HashMap, - pub created_at: String, - pub updated_at: String, + pub id: String, + pub path: String, + pub file_name: String, + pub media_type: String, + pub content_hash: String, + pub file_size: u64, + pub title: Option, + pub artist: Option, + pub album: Option, + pub genre: Option, + pub year: Option, + pub duration_secs: Option, + pub description: Option, + #[serde(default)] + pub has_thumbnail: bool, + pub custom_fields: HashMap, + pub created_at: String, + pub updated_at: String, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct CustomFieldResponse { - pub field_type: String, - pub value: String, + pub field_type: String, + pub value: String, } #[derive(Debug, Clone, Deserialize)] pub struct ImportResponse { - pub media_id: String, - pub was_duplicate: bool, + pub media_id: String, + pub was_duplicate: bool, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct TagResponse { - pub id: String, - pub name: String, - pub parent_id: Option, - pub created_at: String, + pub id: String, + pub name: String, + pub parent_id: Option, + pub created_at: String, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct CollectionResponse { - pub id: String, - pub name: String, - pub description: Option, - pub kind: String, - pub filter_query: Option, - pub created_at: String, - pub updated_at: String, + pub id: String, + pub name: String, + pub description: Option, + pub kind: String, + pub filter_query: Option, + pub created_at: String, + pub updated_at: String, } #[derive(Debug, Clone, Deserialize)] pub struct SearchResponse { - pub items: Vec, - pub total_count: u64, + pub items: Vec, + pub total_count: u64, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct AuditEntryResponse { - pub id: String, - pub media_id: Option, - pub action: String, - pub details: Option, - pub timestamp: String, + pub id: String, + pub media_id: Option, + pub action: String, + pub details: Option, + pub timestamp: String, } #[derive(Debug, Clone, Deserialize)] pub struct ScanResponse { - pub files_found: usize, - pub files_processed: usize, - pub errors: Vec, + pub files_found: usize, + pub files_processed: usize, + pub errors: Vec, } #[derive(Debug, Clone, Deserialize)] pub struct DatabaseStatsResponse { - pub media_count: u64, - pub tag_count: u64, - pub collection_count: u64, - pub audit_count: u64, - pub database_size_bytes: u64, - pub backend_name: String, + pub media_count: u64, + pub tag_count: u64, + pub collection_count: u64, + pub audit_count: u64, + pub database_size_bytes: u64, + pub backend_name: String, } #[derive(Debug, Clone, Deserialize)] pub struct DuplicateGroupResponse { - pub content_hash: String, - pub items: Vec, + pub content_hash: String, + pub items: Vec, } /// Background job response from the API. #[derive(Debug, Clone, Deserialize)] pub struct JobResponse { - pub id: String, - pub kind: serde_json::Value, - pub status: serde_json::Value, - pub created_at: String, - pub updated_at: String, + pub id: String, + pub kind: serde_json::Value, + pub status: serde_json::Value, + pub created_at: String, + pub updated_at: String, } #[derive(Debug, Clone, Deserialize)] pub struct ScheduledTaskResponse { - pub id: String, - pub name: String, - pub schedule: String, - pub enabled: bool, - pub last_run: Option, - pub next_run: Option, - pub last_status: Option, + pub id: String, + pub name: String, + pub schedule: String, + pub enabled: bool, + pub last_run: Option, + pub next_run: Option, + pub last_status: Option, } #[derive(Debug, Clone, Deserialize)] pub struct LibraryStatisticsResponse { - pub total_media: u64, - pub total_size_bytes: u64, - pub avg_file_size_bytes: u64, - pub media_by_type: Vec, - pub storage_by_type: Vec, - pub newest_item: Option, - pub oldest_item: Option, - pub top_tags: Vec, - pub top_collections: Vec, - pub total_tags: u64, - pub total_collections: u64, - pub total_duplicates: u64, + pub total_media: u64, + pub total_size_bytes: u64, + pub avg_file_size_bytes: u64, + pub media_by_type: Vec, + pub storage_by_type: Vec, + pub newest_item: Option, + pub oldest_item: Option, + pub top_tags: Vec, + pub top_collections: Vec, + pub total_tags: u64, + pub total_collections: u64, + pub total_duplicates: u64, } #[derive(Debug, Clone, Deserialize)] pub struct TypeCount { - pub name: String, - pub count: u64, + pub name: String, + pub count: u64, } impl ApiClient { - pub fn new(base_url: &str) -> Self { - Self { - client: Client::new(), - base_url: base_url.trim_end_matches('/').to_string(), - } + pub fn new(base_url: &str) -> Self { + Self { + client: Client::new(), + base_url: base_url.trim_end_matches('/').to_string(), } + } - fn url(&self, path: &str) -> String { - format!("{}/api/v1{}", self.base_url, path) - } + fn url(&self, path: &str) -> String { + format!("{}/api/v1{}", self.base_url, path) + } - pub async fn list_media(&self, offset: u64, limit: u64) -> Result> { - let resp = self - .client - .get(self.url("/media")) - .query(&[("offset", offset.to_string()), ("limit", limit.to_string())]) - .send() - .await? - .error_for_status()? - .json() - .await?; - Ok(resp) - } + pub async fn list_media( + &self, + offset: u64, + limit: u64, + ) -> Result> { + let resp = self + .client + .get(self.url("/media")) + .query(&[("offset", offset.to_string()), ("limit", limit.to_string())]) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } - pub async fn get_media(&self, id: &str) -> Result { - let resp = self - .client - .get(self.url(&format!("/media/{id}"))) - .send() - .await? - .error_for_status()? - .json() - .await?; - Ok(resp) - } + pub async fn get_media(&self, id: &str) -> Result { + let resp = self + .client + .get(self.url(&format!("/media/{id}"))) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } - pub async fn import_file(&self, path: &str) -> Result { - let resp = self - .client - .post(self.url("/media/import")) - .json(&serde_json::json!({"path": path})) - .send() - .await? - .error_for_status()? - .json() - .await?; - Ok(resp) - } + pub async fn import_file(&self, path: &str) -> Result { + let resp = self + .client + .post(self.url("/media/import")) + .json(&serde_json::json!({"path": path})) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } - pub async fn delete_media(&self, id: &str) -> Result<()> { - self.client - .delete(self.url(&format!("/media/{id}"))) - .send() - .await? - .error_for_status()?; - Ok(()) - } + pub async fn delete_media(&self, id: &str) -> Result<()> { + self + .client + .delete(self.url(&format!("/media/{id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } - pub async fn open_media(&self, id: &str) -> Result<()> { - self.client - .post(self.url(&format!("/media/{id}/open"))) - .send() - .await? - .error_for_status()?; - Ok(()) - } + pub async fn open_media(&self, id: &str) -> Result<()> { + self + .client + .post(self.url(&format!("/media/{id}/open"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } - pub async fn search(&self, query: &str, offset: u64, limit: u64) -> Result { - let resp = self - .client - .get(self.url("/search")) - .query(&[ - ("q", query.to_string()), - ("offset", offset.to_string()), - ("limit", limit.to_string()), - ]) - .send() - .await? - .error_for_status()? - .json() - .await?; - Ok(resp) - } + pub async fn search( + &self, + query: &str, + offset: u64, + limit: u64, + ) -> Result { + let resp = self + .client + .get(self.url("/search")) + .query(&[ + ("q", query.to_string()), + ("offset", offset.to_string()), + ("limit", limit.to_string()), + ]) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } - pub async fn list_tags(&self) -> Result> { - let resp = self - .client - .get(self.url("/tags")) - .send() - .await? - .error_for_status()? - .json() - .await?; - Ok(resp) - } + pub async fn list_tags(&self) -> Result> { + let resp = self + .client + .get(self.url("/tags")) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } - pub async fn create_tag(&self, name: &str, parent_id: Option<&str>) -> Result { - let mut body = serde_json::json!({"name": name}); - if let Some(pid) = parent_id { - body["parent_id"] = serde_json::Value::String(pid.to_string()); - } - let resp = self - .client - .post(self.url("/tags")) - .json(&body) - .send() - .await? - .error_for_status()? - .json() - .await?; - Ok(resp) + pub async fn create_tag( + &self, + name: &str, + parent_id: Option<&str>, + ) -> Result { + let mut body = serde_json::json!({"name": name}); + if let Some(pid) = parent_id { + body["parent_id"] = serde_json::Value::String(pid.to_string()); } + let resp = self + .client + .post(self.url("/tags")) + .json(&body) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } - pub async fn delete_tag(&self, id: &str) -> Result<()> { - self.client - .delete(self.url(&format!("/tags/{id}"))) - .send() - .await? - .error_for_status()?; - Ok(()) - } + pub async fn delete_tag(&self, id: &str) -> Result<()> { + self + .client + .delete(self.url(&format!("/tags/{id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } - pub async fn tag_media(&self, media_id: &str, tag_id: &str) -> Result<()> { - self.client - .post(self.url(&format!("/media/{media_id}/tags"))) - .json(&serde_json::json!({"tag_id": tag_id})) - .send() - .await? - .error_for_status()?; - Ok(()) - } + pub async fn tag_media(&self, media_id: &str, tag_id: &str) -> Result<()> { + self + .client + .post(self.url(&format!("/media/{media_id}/tags"))) + .json(&serde_json::json!({"tag_id": tag_id})) + .send() + .await? + .error_for_status()?; + Ok(()) + } - pub async fn untag_media(&self, media_id: &str, tag_id: &str) -> Result<()> { - self.client - .delete(self.url(&format!("/media/{media_id}/tags/{tag_id}"))) - .send() - .await? - .error_for_status()?; - Ok(()) - } + pub async fn untag_media(&self, media_id: &str, tag_id: &str) -> Result<()> { + self + .client + .delete(self.url(&format!("/media/{media_id}/tags/{tag_id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } - pub async fn get_media_tags(&self, media_id: &str) -> Result> { - let resp = self - .client - .get(self.url(&format!("/media/{media_id}/tags"))) - .send() - .await? - .error_for_status()? - .json() - .await?; - Ok(resp) - } + pub async fn get_media_tags( + &self, + media_id: &str, + ) -> Result> { + let resp = self + .client + .get(self.url(&format!("/media/{media_id}/tags"))) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } - pub async fn list_collections(&self) -> Result> { - let resp = self - .client - .get(self.url("/collections")) - .send() - .await? - .error_for_status()? - .json() - .await?; - Ok(resp) - } + pub async fn list_collections(&self) -> Result> { + let resp = self + .client + .get(self.url("/collections")) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } - pub async fn delete_collection(&self, id: &str) -> Result<()> { - self.client - .delete(self.url(&format!("/collections/{id}"))) - .send() - .await? - .error_for_status()?; - Ok(()) - } + pub async fn delete_collection(&self, id: &str) -> Result<()> { + self + .client + .delete(self.url(&format!("/collections/{id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } - pub async fn trigger_scan(&self, path: Option<&str>) -> Result> { - let body = match path { - Some(p) => serde_json::json!({"path": p}), - None => serde_json::json!({"path": null}), - }; - let resp = self - .client - .post(self.url("/scan")) - .json(&body) - .send() - .await? - .error_for_status()? - .json() - .await?; - Ok(resp) - } + pub async fn trigger_scan( + &self, + path: Option<&str>, + ) -> Result> { + let body = match path { + Some(p) => serde_json::json!({"path": p}), + None => serde_json::json!({"path": null}), + }; + let resp = self + .client + .post(self.url("/scan")) + .json(&body) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } - pub async fn list_audit(&self, offset: u64, limit: u64) -> Result> { - let resp = self - .client - .get(self.url("/audit")) - .query(&[("offset", offset.to_string()), ("limit", limit.to_string())]) - .send() - .await? - .error_for_status()? - .json() - .await?; - Ok(resp) - } + pub async fn list_audit( + &self, + offset: u64, + limit: u64, + ) -> Result> { + let resp = self + .client + .get(self.url("/audit")) + .query(&[("offset", offset.to_string()), ("limit", limit.to_string())]) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } - pub async fn find_duplicates(&self) -> Result> { - let resp = self - .client - .get(self.url("/duplicates")) - .send() - .await? - .error_for_status()? - .json() - .await?; - Ok(resp) - } + pub async fn find_duplicates(&self) -> Result> { + let resp = self + .client + .get(self.url("/duplicates")) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } - pub async fn database_stats(&self) -> Result { - let resp = self - .client - .get(self.url("/database/stats")) - .send() - .await? - .error_for_status()? - .json() - .await?; - Ok(resp) - } + pub async fn database_stats(&self) -> Result { + let resp = self + .client + .get(self.url("/database/stats")) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } - pub async fn list_jobs(&self) -> Result> { - let resp = self - .client - .get(self.url("/jobs")) - .send() - .await? - .error_for_status()? - .json() - .await?; - Ok(resp) - } + pub async fn list_jobs(&self) -> Result> { + let resp = self + .client + .get(self.url("/jobs")) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } - pub async fn vacuum_database(&self) -> Result<()> { - self.client - .post(self.url("/database/vacuum")) - .json(&serde_json::json!({})) - .send() - .await? - .error_for_status()?; - Ok(()) - } + pub async fn vacuum_database(&self) -> Result<()> { + self + .client + .post(self.url("/database/vacuum")) + .json(&serde_json::json!({})) + .send() + .await? + .error_for_status()?; + Ok(()) + } - pub async fn update_media( - &self, - id: &str, - updates: serde_json::Value, - ) -> Result { - let resp = self - .client - .patch(self.url(&format!("/media/{id}"))) - .json(&updates) - .send() - .await? - .error_for_status()? - .json() - .await?; - Ok(resp) - } + pub async fn update_media( + &self, + id: &str, + updates: serde_json::Value, + ) -> Result { + let resp = self + .client + .patch(self.url(&format!("/media/{id}"))) + .json(&updates) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } - pub async fn library_statistics(&self) -> Result { - let resp = self - .client - .get(self.url("/statistics")) - .send() - .await? - .error_for_status()? - .json() - .await?; - Ok(resp) - } + pub async fn library_statistics(&self) -> Result { + let resp = self + .client + .get(self.url("/statistics")) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } - pub async fn list_scheduled_tasks(&self) -> Result> { - let resp = self - .client - .get(self.url("/tasks/scheduled")) - .send() - .await? - .error_for_status()? - .json() - .await?; - Ok(resp) - } + pub async fn list_scheduled_tasks( + &self, + ) -> Result> { + let resp = self + .client + .get(self.url("/tasks/scheduled")) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } - pub async fn toggle_scheduled_task(&self, id: &str) -> Result<()> { - self.client - .post(self.url(&format!("/tasks/scheduled/{id}/toggle"))) - .send() - .await? - .error_for_status()?; - Ok(()) - } + pub async fn toggle_scheduled_task(&self, id: &str) -> Result<()> { + self + .client + .post(self.url(&format!("/tasks/scheduled/{id}/toggle"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } - pub async fn run_task_now(&self, id: &str) -> Result<()> { - self.client - .post(self.url(&format!("/tasks/scheduled/{id}/run-now"))) - .send() - .await? - .error_for_status()?; - Ok(()) - } + pub async fn run_task_now(&self, id: &str) -> Result<()> { + self + .client + .post(self.url(&format!("/tasks/scheduled/{id}/run-now"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } } diff --git a/crates/pinakes-tui/src/event.rs b/crates/pinakes-tui/src/event.rs index a28af48..b37a8a6 100644 --- a/crates/pinakes-tui/src/event.rs +++ b/crates/pinakes-tui/src/event.rs @@ -5,68 +5,68 @@ use tokio::sync::mpsc; #[derive(Debug)] pub enum AppEvent { - Key(KeyEvent), - Tick, - ApiResult(ApiResult), + Key(KeyEvent), + Tick, + ApiResult(ApiResult), } #[derive(Debug)] pub enum ApiResult { - MediaList(Vec), - SearchResults(crate::client::SearchResponse), - AllTags(Vec), - Collections(Vec), - ImportDone(crate::client::ImportResponse), - ScanDone(Vec), - AuditLog(Vec), - Duplicates(Vec), - DatabaseStats(crate::client::DatabaseStatsResponse), - Statistics(crate::client::LibraryStatisticsResponse), - ScheduledTasks(Vec), - MediaUpdated, - Error(String), + MediaList(Vec), + SearchResults(crate::client::SearchResponse), + AllTags(Vec), + Collections(Vec), + ImportDone(crate::client::ImportResponse), + ScanDone(Vec), + AuditLog(Vec), + Duplicates(Vec), + DatabaseStats(crate::client::DatabaseStatsResponse), + Statistics(crate::client::LibraryStatisticsResponse), + ScheduledTasks(Vec), + MediaUpdated, + Error(String), } pub struct EventHandler { - tx: mpsc::UnboundedSender, - rx: mpsc::UnboundedReceiver, + tx: mpsc::UnboundedSender, + rx: mpsc::UnboundedReceiver, } impl EventHandler { - pub fn new(tick_rate: Duration) -> Self { - let (tx, rx) = mpsc::unbounded_channel(); - let event_tx = tx.clone(); + pub fn new(tick_rate: Duration) -> Self { + let (tx, rx) = mpsc::unbounded_channel(); + let event_tx = tx.clone(); - std::thread::spawn(move || { - loop { - match event::poll(tick_rate) { - Ok(true) => { - if let Ok(CrosstermEvent::Key(key)) = event::read() - && event_tx.send(AppEvent::Key(key)).is_err() - { - break; - } - } - Ok(false) => { - if event_tx.send(AppEvent::Tick).is_err() { - break; - } - } - Err(e) => { - tracing::warn!(error = %e, "event poll failed"); - } - } + std::thread::spawn(move || { + loop { + match event::poll(tick_rate) { + Ok(true) => { + if let Ok(CrosstermEvent::Key(key)) = event::read() + && event_tx.send(AppEvent::Key(key)).is_err() + { + break; } - }); + }, + Ok(false) => { + if event_tx.send(AppEvent::Tick).is_err() { + break; + } + }, + Err(e) => { + tracing::warn!(error = %e, "event poll failed"); + }, + } + } + }); - Self { tx, rx } - } + Self { tx, rx } + } - pub fn sender(&self) -> mpsc::UnboundedSender { - self.tx.clone() - } + pub fn sender(&self) -> mpsc::UnboundedSender { + self.tx.clone() + } - pub async fn next(&mut self) -> Option { - self.rx.recv().await - } + pub async fn next(&mut self) -> Option { + self.rx.recv().await + } } diff --git a/crates/pinakes-tui/src/input.rs b/crates/pinakes-tui/src/input.rs index 30c9482..1a881da 100644 --- a/crates/pinakes-tui/src/input.rs +++ b/crates/pinakes-tui/src/input.rs @@ -3,148 +3,179 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::app::View; pub enum Action { - Quit, - NavigateUp, - NavigateDown, - NavigateLeft, - NavigateRight, - Select, - Back, - Search, - Import, - Delete, - DeleteSelected, - Open, - TagView, - CollectionView, - AuditView, - SettingsView, - DuplicatesView, - DatabaseView, - QueueView, - StatisticsView, - TasksView, - ScanTrigger, - Refresh, - NextTab, - PrevTab, - PageUp, - PageDown, - GoTop, - GoBottom, - CreateTag, - TagMedia, - UntagMedia, - Help, - Edit, - Vacuum, - Toggle, - RunNow, - Save, - Char(char), - Backspace, - // Multi-select actions - ToggleSelection, - SelectAll, - ClearSelection, - ToggleSelectionMode, - BatchDelete, - BatchTag, - None, + Quit, + NavigateUp, + NavigateDown, + NavigateLeft, + NavigateRight, + Select, + Back, + Search, + Import, + Delete, + DeleteSelected, + Open, + TagView, + CollectionView, + AuditView, + SettingsView, + DuplicatesView, + DatabaseView, + QueueView, + StatisticsView, + TasksView, + ScanTrigger, + Refresh, + NextTab, + PrevTab, + PageUp, + PageDown, + GoTop, + GoBottom, + CreateTag, + TagMedia, + UntagMedia, + Help, + Edit, + Vacuum, + Toggle, + RunNow, + Save, + Char(char), + Backspace, + // Multi-select actions + ToggleSelection, + SelectAll, + ClearSelection, + ToggleSelectionMode, + BatchDelete, + BatchTag, + None, } -pub fn handle_key(key: KeyEvent, in_input_mode: bool, current_view: &View) -> Action { - if in_input_mode { - match (key.code, key.modifiers) { - (KeyCode::Esc, _) => Action::Back, - (KeyCode::Enter, _) => Action::Select, - (KeyCode::Char('s'), KeyModifiers::CONTROL) => match current_view { - View::MetadataEdit => Action::Save, - _ => Action::Select, - }, - (KeyCode::Char(c), _) => Action::Char(c), - (KeyCode::Backspace, _) => Action::Backspace, - _ => Action::None, - } - } else { - match (key.code, key.modifiers) { - (KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => Action::Quit, - (KeyCode::Up | KeyCode::Char('k'), _) => Action::NavigateUp, - (KeyCode::Down | KeyCode::Char('j'), _) => Action::NavigateDown, - (KeyCode::Left | KeyCode::Char('h'), _) => Action::NavigateLeft, - (KeyCode::Right | KeyCode::Char('l'), _) => Action::NavigateRight, - (KeyCode::Home, _) => Action::GoTop, - (KeyCode::End, _) => Action::GoBottom, - (KeyCode::Enter, _) => Action::Select, - (KeyCode::Esc, _) => Action::Back, - (KeyCode::Char('/'), _) => Action::Search, - (KeyCode::Char('?'), _) => Action::Help, - (KeyCode::Char('i'), _) => Action::Import, - (KeyCode::Char('d'), _) => match current_view { - View::Tags | View::Collections => Action::DeleteSelected, - _ => Action::Delete, - }, - (KeyCode::Char('o'), _) => Action::Open, - (KeyCode::Char('e'), _) => match current_view { - View::Detail => Action::Edit, - _ => Action::None, - }, - (KeyCode::Char('t'), _) => match current_view { - View::Tasks => Action::Toggle, - _ => Action::TagView, - }, - (KeyCode::Char('c'), _) => Action::CollectionView, - // Multi-select: Ctrl+A for SelectAll (must come before plain 'a') - (KeyCode::Char('a'), KeyModifiers::CONTROL) => match current_view { - View::Library | View::Search => Action::SelectAll, - _ => Action::None, - }, - (KeyCode::Char('a'), _) => Action::AuditView, - (KeyCode::Char('S'), _) => Action::SettingsView, - (KeyCode::Char('B'), _) => Action::DatabaseView, - (KeyCode::Char('Q'), _) => Action::QueueView, - (KeyCode::Char('X'), _) => Action::StatisticsView, - // Use plain D/T for views in non-library contexts, keep for batch ops in library/search - (KeyCode::Char('D'), _) => match current_view { - View::Library | View::Search => Action::BatchDelete, - _ => Action::DuplicatesView, - }, - (KeyCode::Char('T'), _) => match current_view { - View::Library | View::Search => Action::BatchTag, - _ => Action::TasksView, - }, - // Ctrl+S must come before plain 's' to ensure proper precedence - (KeyCode::Char('s'), KeyModifiers::CONTROL) => match current_view { - View::MetadataEdit => Action::Save, - _ => Action::None, - }, - (KeyCode::Char('s'), _) => Action::ScanTrigger, - (KeyCode::Char('r'), _) => Action::Refresh, - (KeyCode::Char('n'), _) => Action::CreateTag, - (KeyCode::Char('+'), _) => Action::TagMedia, - (KeyCode::Char('-'), _) => Action::UntagMedia, - (KeyCode::Char('v'), _) => match current_view { - View::Database => Action::Vacuum, - _ => Action::ToggleSelectionMode, - }, - (KeyCode::Char('x'), _) => match current_view { - View::Tasks => Action::RunNow, - _ => Action::None, - }, - (KeyCode::Tab, _) => Action::NextTab, - (KeyCode::BackTab, _) => Action::PrevTab, - (KeyCode::PageUp, _) => Action::PageUp, - (KeyCode::PageDown, _) => Action::PageDown, - // Multi-select keys - (KeyCode::Char(' '), _) => match current_view { - View::Library | View::Search => Action::ToggleSelection, - _ => Action::None, - }, - (KeyCode::Char('u'), _) => match current_view { - View::Library | View::Search => Action::ClearSelection, - _ => Action::None, - }, - _ => Action::None, +pub fn handle_key( + key: KeyEvent, + in_input_mode: bool, + current_view: &View, +) -> Action { + if in_input_mode { + match (key.code, key.modifiers) { + (KeyCode::Esc, _) => Action::Back, + (KeyCode::Enter, _) => Action::Select, + (KeyCode::Char('s'), KeyModifiers::CONTROL) => { + match current_view { + View::MetadataEdit => Action::Save, + _ => Action::Select, } + }, + (KeyCode::Char(c), _) => Action::Char(c), + (KeyCode::Backspace, _) => Action::Backspace, + _ => Action::None, } + } else { + match (key.code, key.modifiers) { + (KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => { + Action::Quit + }, + (KeyCode::Up | KeyCode::Char('k'), _) => Action::NavigateUp, + (KeyCode::Down | KeyCode::Char('j'), _) => Action::NavigateDown, + (KeyCode::Left | KeyCode::Char('h'), _) => Action::NavigateLeft, + (KeyCode::Right | KeyCode::Char('l'), _) => Action::NavigateRight, + (KeyCode::Home, _) => Action::GoTop, + (KeyCode::End, _) => Action::GoBottom, + (KeyCode::Enter, _) => Action::Select, + (KeyCode::Esc, _) => Action::Back, + (KeyCode::Char('/'), _) => Action::Search, + (KeyCode::Char('?'), _) => Action::Help, + (KeyCode::Char('i'), _) => Action::Import, + (KeyCode::Char('d'), _) => { + match current_view { + View::Tags | View::Collections => Action::DeleteSelected, + _ => Action::Delete, + } + }, + (KeyCode::Char('o'), _) => Action::Open, + (KeyCode::Char('e'), _) => { + match current_view { + View::Detail => Action::Edit, + _ => Action::None, + } + }, + (KeyCode::Char('t'), _) => { + match current_view { + View::Tasks => Action::Toggle, + _ => Action::TagView, + } + }, + (KeyCode::Char('c'), _) => Action::CollectionView, + // Multi-select: Ctrl+A for SelectAll (must come before plain 'a') + (KeyCode::Char('a'), KeyModifiers::CONTROL) => { + match current_view { + View::Library | View::Search => Action::SelectAll, + _ => Action::None, + } + }, + (KeyCode::Char('a'), _) => Action::AuditView, + (KeyCode::Char('S'), _) => Action::SettingsView, + (KeyCode::Char('B'), _) => Action::DatabaseView, + (KeyCode::Char('Q'), _) => Action::QueueView, + (KeyCode::Char('X'), _) => Action::StatisticsView, + // Use plain D/T for views in non-library contexts, keep for batch ops in + // library/search + (KeyCode::Char('D'), _) => { + match current_view { + View::Library | View::Search => Action::BatchDelete, + _ => Action::DuplicatesView, + } + }, + (KeyCode::Char('T'), _) => { + match current_view { + View::Library | View::Search => Action::BatchTag, + _ => Action::TasksView, + } + }, + // Ctrl+S must come before plain 's' to ensure proper precedence + (KeyCode::Char('s'), KeyModifiers::CONTROL) => { + match current_view { + View::MetadataEdit => Action::Save, + _ => Action::None, + } + }, + (KeyCode::Char('s'), _) => Action::ScanTrigger, + (KeyCode::Char('r'), _) => Action::Refresh, + (KeyCode::Char('n'), _) => Action::CreateTag, + (KeyCode::Char('+'), _) => Action::TagMedia, + (KeyCode::Char('-'), _) => Action::UntagMedia, + (KeyCode::Char('v'), _) => { + match current_view { + View::Database => Action::Vacuum, + _ => Action::ToggleSelectionMode, + } + }, + (KeyCode::Char('x'), _) => { + match current_view { + View::Tasks => Action::RunNow, + _ => Action::None, + } + }, + (KeyCode::Tab, _) => Action::NextTab, + (KeyCode::BackTab, _) => Action::PrevTab, + (KeyCode::PageUp, _) => Action::PageUp, + (KeyCode::PageDown, _) => Action::PageDown, + // Multi-select keys + (KeyCode::Char(' '), _) => { + match current_view { + View::Library | View::Search => Action::ToggleSelection, + _ => Action::None, + } + }, + (KeyCode::Char('u'), _) => { + match current_view { + View::Library | View::Search => Action::ClearSelection, + _ => Action::None, + } + }, + _ => Action::None, + } + } } diff --git a/crates/pinakes-tui/src/main.rs b/crates/pinakes-tui/src/main.rs index 6fa207c..e6eda62 100644 --- a/crates/pinakes-tui/src/main.rs +++ b/crates/pinakes-tui/src/main.rs @@ -12,44 +12,46 @@ mod ui; #[derive(Parser)] #[command(name = "pinakes-tui", version, about)] struct Cli { - /// Server URL to connect to - #[arg( - short, - long, - env = "PINAKES_SERVER_URL", - default_value = "http://localhost:3000" - )] - server: String, + /// Server URL to connect to + #[arg( + short, + long, + env = "PINAKES_SERVER_URL", + default_value = "http://localhost:3000" + )] + server: String, - /// Set log level (trace, debug, info, warn, error) - #[arg(long, default_value = "warn")] - log_level: String, + /// Set log level (trace, debug, info, warn, error) + #[arg(long, default_value = "warn")] + log_level: String, - /// Log to file instead of stderr (avoids corrupting TUI display) - #[arg(long)] - log_file: Option, + /// Log to file instead of stderr (avoids corrupting TUI display) + #[arg(long)] + log_file: Option, } #[tokio::main] async fn main() -> Result<()> { - let cli = Cli::parse(); + let cli = Cli::parse(); - // Initialize logging - for TUI, must log to file to avoid corrupting the display - let env_filter = EnvFilter::try_new(&cli.log_level).unwrap_or_else(|_| EnvFilter::new("warn")); + // Initialize logging - for TUI, must log to file to avoid corrupting the + // display + let env_filter = EnvFilter::try_new(&cli.log_level) + .unwrap_or_else(|_| EnvFilter::new("warn")); - if let Some(log_path) = &cli.log_file { - let file = std::fs::File::create(log_path)?; - tracing_subscriber::fmt() - .with_env_filter(env_filter) - .with_writer(file) - .with_ansi(false) - .init(); - } else { - // When no log file specified, suppress all output to avoid TUI corruption - tracing_subscriber::fmt() - .with_env_filter(EnvFilter::new("off")) - .init(); - } + if let Some(log_path) = &cli.log_file { + let file = std::fs::File::create(log_path)?; + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .with_writer(file) + .with_ansi(false) + .init(); + } else { + // When no log file specified, suppress all output to avoid TUI corruption + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::new("off")) + .init(); + } - app::run(&cli.server).await + app::run(&cli.server).await } diff --git a/crates/pinakes-tui/src/ui/audit.rs b/crates/pinakes-tui/src/ui/audit.rs index 386ebda..e61dc7b 100644 --- a/crates/pinakes-tui/src/ui/audit.rs +++ b/crates/pinakes-tui/src/ui/audit.rs @@ -1,85 +1,84 @@ -use ratatui::Frame; -use ratatui::layout::Rect; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::Span; -use ratatui::widgets::{Block, Borders, Cell, Row, Table}; +use ratatui::{ + Frame, + layout::Rect, + style::{Color, Modifier, Style}, + text::Span, + widgets::{Block, Borders, Cell, Row, Table}, +}; use super::format_date; use crate::app::AppState; /// Return a color for an audit action string. fn action_color(action: &str) -> Color { - match action { - "imported" | "import" | "created" => Color::Green, - "deleted" | "delete" | "removed" => Color::Red, - "tagged" | "tag_added" => Color::Cyan, - "untagged" | "tag_removed" => Color::Yellow, - "updated" | "modified" | "edited" => Color::Blue, - "scanned" | "scan" => Color::Magenta, - _ => Color::White, - } + match action { + "imported" | "import" | "created" => Color::Green, + "deleted" | "delete" | "removed" => Color::Red, + "tagged" | "tag_added" => Color::Cyan, + "untagged" | "tag_removed" => Color::Yellow, + "updated" | "modified" | "edited" => Color::Blue, + "scanned" | "scan" => Color::Magenta, + _ => Color::White, + } } pub fn render(f: &mut Frame, state: &AppState, area: Rect) { - let header = Row::new(vec!["Action", "Media ID", "Details", "Date"]).style( + let header = Row::new(vec!["Action", "Media ID", "Details", "Date"]).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + + let rows: Vec = state + .audit_log + .iter() + .enumerate() + .map(|(i, entry)| { + let style = if Some(i) == state.audit_selected { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ); + }; - let rows: Vec = state - .audit_log - .iter() - .enumerate() - .map(|(i, entry)| { - let style = if Some(i) == state.audit_selected { - Style::default().fg(Color::Black).bg(Color::Cyan) - } else { - Style::default() - }; + let color = action_color(&entry.action); + let action_cell = Cell::from(Span::styled( + entry.action.clone(), + Style::default().fg(color).add_modifier(Modifier::BOLD), + )); - let color = action_color(&entry.action); - let action_cell = Cell::from(Span::styled( - entry.action.clone(), - Style::default().fg(color).add_modifier(Modifier::BOLD), - )); - - // Truncate media ID for display - let media_display = entry - .media_id - .as_deref() - .map(|id| { - if id.len() > 12 { - format!("{}...", &id[..12]) - } else { - id.to_string() - } - }) - .unwrap_or_else(|| "-".into()); - - Row::new(vec![ - action_cell, - Cell::from(media_display), - Cell::from(entry.details.clone().unwrap_or_else(|| "-".into())), - Cell::from(format_date(&entry.timestamp).to_string()), - ]) - .style(style) + // Truncate media ID for display + let media_display = entry + .media_id + .as_deref() + .map(|id| { + if id.len() > 12 { + format!("{}...", &id[..12]) + } else { + id.to_string() + } }) - .collect(); + .unwrap_or_else(|| "-".into()); - let title = format!(" Audit Log ({}) ", state.audit_log.len()); + Row::new(vec![ + action_cell, + Cell::from(media_display), + Cell::from(entry.details.clone().unwrap_or_else(|| "-".into())), + Cell::from(format_date(&entry.timestamp).to_string()), + ]) + .style(style) + }) + .collect(); - let table = Table::new( - rows, - [ - ratatui::layout::Constraint::Percentage(18), - ratatui::layout::Constraint::Percentage(22), - ratatui::layout::Constraint::Percentage(40), - ratatui::layout::Constraint::Percentage(20), - ], - ) - .header(header) - .block(Block::default().borders(Borders::ALL).title(title)); + let title = format!(" Audit Log ({}) ", state.audit_log.len()); - f.render_widget(table, area); + let table = Table::new(rows, [ + ratatui::layout::Constraint::Percentage(18), + ratatui::layout::Constraint::Percentage(22), + ratatui::layout::Constraint::Percentage(40), + ratatui::layout::Constraint::Percentage(20), + ]) + .header(header) + .block(Block::default().borders(Borders::ALL).title(title)); + + f.render_widget(table, area); } diff --git a/crates/pinakes-tui/src/ui/collections.rs b/crates/pinakes-tui/src/ui/collections.rs index b528c23..cfa6e80 100644 --- a/crates/pinakes-tui/src/ui/collections.rs +++ b/crates/pinakes-tui/src/ui/collections.rs @@ -1,64 +1,66 @@ -use ratatui::Frame; -use ratatui::layout::Rect; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::widgets::{Block, Borders, Row, Table}; +use ratatui::{ + Frame, + layout::Rect, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, Row, Table}, +}; use super::format_date; use crate::app::AppState; pub fn render(f: &mut Frame, state: &AppState, area: Rect) { - let header = Row::new(vec!["Name", "Kind", "Description", "Members", "Created"]).style( - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), + let header = + Row::new(vec!["Name", "Kind", "Description", "Members", "Created"]).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), ); - let rows: Vec = state - .collections - .iter() - .enumerate() - .map(|(i, col)| { - let style = if Some(i) == state.collection_selected { - Style::default().fg(Color::Black).bg(Color::Cyan) - } else { - Style::default() - }; + let rows: Vec = state + .collections + .iter() + .enumerate() + .map(|(i, col)| { + let style = if Some(i) == state.collection_selected { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + Style::default() + }; - // We show the filter_query as a proxy for member info when kind is "smart" - let members_display = if col.kind == "smart" { - col.filter_query - .as_deref() - .map(|q| format!("filter: {q}")) - .unwrap_or_else(|| "-".to_string()) - } else { - "-".to_string() - }; + // We show the filter_query as a proxy for member info when kind is + // "smart" + let members_display = if col.kind == "smart" { + col + .filter_query + .as_deref() + .map(|q| format!("filter: {q}")) + .unwrap_or_else(|| "-".to_string()) + } else { + "-".to_string() + }; - Row::new(vec![ - col.name.clone(), - col.kind.clone(), - col.description.clone().unwrap_or_else(|| "-".into()), - members_display, - format_date(&col.created_at).to_string(), - ]) - .style(style) - }) - .collect(); + Row::new(vec![ + col.name.clone(), + col.kind.clone(), + col.description.clone().unwrap_or_else(|| "-".into()), + members_display, + format_date(&col.created_at).to_string(), + ]) + .style(style) + }) + .collect(); - let title = format!(" Collections ({}) ", state.collections.len()); + let title = format!(" Collections ({}) ", state.collections.len()); - let table = Table::new( - rows, - [ - ratatui::layout::Constraint::Percentage(25), - ratatui::layout::Constraint::Percentage(12), - ratatui::layout::Constraint::Percentage(28), - ratatui::layout::Constraint::Percentage(15), - ratatui::layout::Constraint::Percentage(20), - ], - ) - .header(header) - .block(Block::default().borders(Borders::ALL).title(title)); + let table = Table::new(rows, [ + ratatui::layout::Constraint::Percentage(25), + ratatui::layout::Constraint::Percentage(12), + ratatui::layout::Constraint::Percentage(28), + ratatui::layout::Constraint::Percentage(15), + ratatui::layout::Constraint::Percentage(20), + ]) + .header(header) + .block(Block::default().borders(Borders::ALL).title(title)); - f.render_widget(table, area); + f.render_widget(table, area); } diff --git a/crates/pinakes-tui/src/ui/database.rs b/crates/pinakes-tui/src/ui/database.rs index abfab94..f10bc43 100644 --- a/crates/pinakes-tui/src/ui/database.rs +++ b/crates/pinakes-tui/src/ui/database.rs @@ -1,55 +1,57 @@ -use ratatui::Frame; -use ratatui::layout::Rect; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::{ + Frame, + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, +}; use crate::app::AppState; pub fn render(f: &mut Frame, state: &AppState, area: Rect) { - let label_style = Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD); - let value_style = Style::default().fg(Color::White); - let section_style = Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD); + let label_style = Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD); + let value_style = Style::default().fg(Color::White); + let section_style = Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD); - let pad = " "; + let pad = " "; - let mut lines = vec![ - Line::default(), - Line::from(Span::styled("--- Database Statistics ---", section_style)), - ]; + let mut lines = vec![ + Line::default(), + Line::from(Span::styled("--- Database Statistics ---", section_style)), + ]; - if let Some(ref stats) = state.database_stats { - for (key, value) in stats { - lines.push(Line::from(vec![ - Span::raw(pad), - Span::styled(format!("{key:<20}"), label_style), - Span::styled(value.to_string(), value_style), - ])); - } - } else { - lines.push(Line::from(vec![ - Span::raw(pad), - Span::raw("Press 'r' to load database statistics"), - ])); + if let Some(ref stats) = state.database_stats { + for (key, value) in stats { + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(format!("{key:<20}"), label_style), + Span::styled(value.to_string(), value_style), + ])); } - - lines.push(Line::default()); - lines.push(Line::from(Span::styled("--- Actions ---", section_style))); + } else { lines.push(Line::from(vec![ - Span::raw(pad), - Span::raw("v: Vacuum database"), - ])); - lines.push(Line::from(vec![ - Span::raw(pad), - Span::raw("Esc: Return to library"), + Span::raw(pad), + Span::raw("Press 'r' to load database statistics"), ])); + } - let paragraph = - Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" Database ")); + lines.push(Line::default()); + lines.push(Line::from(Span::styled("--- Actions ---", section_style))); + lines.push(Line::from(vec![ + Span::raw(pad), + Span::raw("v: Vacuum database"), + ])); + lines.push(Line::from(vec![ + Span::raw(pad), + Span::raw("Esc: Return to library"), + ])); - f.render_widget(paragraph, area); + let paragraph = Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL).title(" Database ")); + + f.render_widget(paragraph, area); } diff --git a/crates/pinakes-tui/src/ui/detail.rs b/crates/pinakes-tui/src/ui/detail.rs index f43058a..d965c88 100644 --- a/crates/pinakes-tui/src/ui/detail.rs +++ b/crates/pinakes-tui/src/ui/detail.rs @@ -1,223 +1,229 @@ -use ratatui::Frame; -use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, +}; use super::{format_date, format_duration, format_size, media_type_color}; use crate::app::AppState; pub fn render(f: &mut Frame, state: &AppState, area: Rect) { - let item = match &state.selected_media { - Some(item) => item, - None => { - let msg = Paragraph::new("No item selected") - .block(Block::default().borders(Borders::ALL).title(" Detail ")); - f.render_widget(msg, area); - return; - } - }; + let item = match &state.selected_media { + Some(item) => item, + None => { + let msg = Paragraph::new("No item selected") + .block(Block::default().borders(Borders::ALL).title(" Detail ")); + f.render_widget(msg, area); + return; + }, + }; - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(0)]) - .split(area); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0)]) + .split(area); - let label_style = Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD); - let value_style = Style::default().fg(Color::White); - let dim_style = Style::default().fg(Color::DarkGray); + let label_style = Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD); + let value_style = Style::default().fg(Color::White); + let dim_style = Style::default().fg(Color::DarkGray); - let pad = " "; - let label_width = 14; - let make_label = |name: &str| -> String { format!("{name: String { format!("{name: = Vec::new(); + let mut lines: Vec = Vec::new(); - // Section: File Info - lines.push(Line::from(Span::styled( - "--- File Info ---", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ))); + // Section: File Info + lines.push(Line::from(Span::styled( + "--- File Info ---", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Name"), label_style), + Span::styled(&item.file_name, value_style), + ])); + + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Path"), label_style), + Span::styled(&item.path, dim_style), + ])); + + let type_color = media_type_color(&item.media_type); + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Type"), label_style), + Span::styled(&item.media_type, Style::default().fg(type_color)), + ])); + + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Size"), label_style), + Span::styled(format_size(item.file_size), value_style), + ])); + + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Hash"), label_style), + Span::styled(&item.content_hash, dim_style), + ])); + + if item.has_thumbnail { lines.push(Line::from(vec![ - Span::raw(pad), - Span::styled(make_label("Name"), label_style), - Span::styled(&item.file_name, value_style), + Span::raw(pad), + Span::styled(make_label("Thumbnail"), label_style), + Span::styled("Yes", Style::default().fg(Color::Green)), ])); + } - lines.push(Line::from(vec![ - Span::raw(pad), - Span::styled(make_label("Path"), label_style), - Span::styled(&item.path, dim_style), - ])); + lines.push(Line::default()); // blank line - let type_color = media_type_color(&item.media_type); - lines.push(Line::from(vec![ - Span::raw(pad), - Span::styled(make_label("Type"), label_style), - Span::styled(&item.media_type, Style::default().fg(type_color)), - ])); + // Section: Metadata + lines.push(Line::from(Span::styled( + "--- Metadata ---", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ))); - lines.push(Line::from(vec![ - Span::raw(pad), - Span::styled(make_label("Size"), label_style), - Span::styled(format_size(item.file_size), value_style), - ])); + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Title"), label_style), + Span::styled(item.title.as_deref().unwrap_or("-"), value_style), + ])); - lines.push(Line::from(vec![ - Span::raw(pad), - Span::styled(make_label("Hash"), label_style), - Span::styled(&item.content_hash, dim_style), - ])); + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Artist"), label_style), + Span::styled(item.artist.as_deref().unwrap_or("-"), value_style), + ])); - if item.has_thumbnail { - lines.push(Line::from(vec![ - Span::raw(pad), - Span::styled(make_label("Thumbnail"), label_style), - Span::styled("Yes", Style::default().fg(Color::Green)), - ])); - } + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Album"), label_style), + Span::styled(item.album.as_deref().unwrap_or("-"), value_style), + ])); - lines.push(Line::default()); // blank line + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Genre"), label_style), + Span::styled(item.genre.as_deref().unwrap_or("-"), value_style), + ])); - // Section: Metadata - lines.push(Line::from(Span::styled( - "--- Metadata ---", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ))); + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Year"), label_style), + Span::styled( + item + .year + .map(|y| y.to_string()) + .unwrap_or_else(|| "-".to_string()), + value_style, + ), + ])); - lines.push(Line::from(vec![ - Span::raw(pad), - Span::styled(make_label("Title"), label_style), - Span::styled(item.title.as_deref().unwrap_or("-"), value_style), - ])); - - lines.push(Line::from(vec![ - Span::raw(pad), - Span::styled(make_label("Artist"), label_style), - Span::styled(item.artist.as_deref().unwrap_or("-"), value_style), - ])); - - lines.push(Line::from(vec![ - Span::raw(pad), - Span::styled(make_label("Album"), label_style), - Span::styled(item.album.as_deref().unwrap_or("-"), value_style), - ])); - - lines.push(Line::from(vec![ - Span::raw(pad), - Span::styled(make_label("Genre"), label_style), - Span::styled(item.genre.as_deref().unwrap_or("-"), value_style), - ])); - - lines.push(Line::from(vec![ - Span::raw(pad), - Span::styled(make_label("Year"), label_style), - Span::styled( - item.year - .map(|y| y.to_string()) - .unwrap_or_else(|| "-".to_string()), - value_style, - ), - ])); - - lines.push(Line::from(vec![ - Span::raw(pad), - Span::styled(make_label("Duration"), label_style), - Span::styled( - item.duration_secs - .map(format_duration) - .unwrap_or_else(|| "-".to_string()), - value_style, - ), - ])); - - // Description - if let Some(ref desc) = item.description - && !desc.is_empty() - { - lines.push(Line::default()); - lines.push(Line::from(vec![ - Span::raw(pad), - Span::styled(make_label("Description"), label_style), - Span::styled(desc.as_str(), value_style), - ])); - } - - // Custom fields - if !item.custom_fields.is_empty() { - lines.push(Line::default()); - lines.push(Line::from(Span::styled( - "--- Custom Fields ---", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ))); - let mut fields: Vec<_> = item.custom_fields.iter().collect(); - fields.sort_by_key(|(k, _)| k.as_str()); - for (key, field) in fields { - lines.push(Line::from(vec![ - Span::raw(pad), - Span::styled(format!("{key: = state.tags.iter().map(|t| t.name.as_str()).collect(); - lines.push(Line::from(vec![ - Span::raw(pad), - Span::styled(tag_names.join(", "), Style::default().fg(Color::Green)), - ])); - } + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Duration"), label_style), + Span::styled( + item + .duration_secs + .map(format_duration) + .unwrap_or_else(|| "-".to_string()), + value_style, + ), + ])); + // Description + if let Some(ref desc) = item.description + && !desc.is_empty() + { lines.push(Line::default()); + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Description"), label_style), + Span::styled(desc.as_str(), value_style), + ])); + } - // Section: Timestamps + // Custom fields + if !item.custom_fields.is_empty() { + lines.push(Line::default()); lines.push(Line::from(Span::styled( - "--- Timestamps ---", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), + "--- Custom Fields ---", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), ))); - - lines.push(Line::from(vec![ + let mut fields: Vec<_> = item.custom_fields.iter().collect(); + fields.sort_by_key(|(k, _)| k.as_str()); + for (key, field) in fields { + lines.push(Line::from(vec![ Span::raw(pad), - Span::styled(make_label("Created"), label_style), - Span::styled(format_date(&item.created_at), dim_style), - ])); + Span::styled(format!("{key: = + state.tags.iter().map(|t| t.name.as_str()).collect(); lines.push(Line::from(vec![ - Span::raw(pad), - Span::styled(make_label("Updated"), label_style), - Span::styled(format_date(&item.updated_at), dim_style), + Span::raw(pad), + Span::styled(tag_names.join(", "), Style::default().fg(Color::Green)), ])); + } - let title = if let Some(ref title_str) = item.title { - format!(" Detail: {} ", title_str) - } else { - format!(" Detail: {} ", item.file_name) - }; + lines.push(Line::default()); - let detail = Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(title)); + // Section: Timestamps + lines.push(Line::from(Span::styled( + "--- Timestamps ---", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ))); - f.render_widget(detail, chunks[0]); + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Created"), label_style), + Span::styled(format_date(&item.created_at), dim_style), + ])); + + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Updated"), label_style), + Span::styled(format_date(&item.updated_at), dim_style), + ])); + + let title = if let Some(ref title_str) = item.title { + format!(" Detail: {} ", title_str) + } else { + format!(" Detail: {} ", item.file_name) + }; + + let detail = Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL).title(title)); + + f.render_widget(detail, chunks[0]); } diff --git a/crates/pinakes-tui/src/ui/duplicates.rs b/crates/pinakes-tui/src/ui/duplicates.rs index 8f50142..f1a84f2 100644 --- a/crates/pinakes-tui/src/ui/duplicates.rs +++ b/crates/pinakes-tui/src/ui/duplicates.rs @@ -1,59 +1,62 @@ -use ratatui::Frame; -use ratatui::layout::Rect; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, List, ListItem}; +use ratatui::{ + Frame, + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem}, +}; use crate::app::AppState; pub fn render(f: &mut Frame, state: &AppState, area: Rect) { - let items: Vec = if state.duplicate_groups.is_empty() { - vec![ListItem::new(Line::from(Span::styled( - " No duplicates found. Press 'r' to refresh.", - Style::default().fg(Color::DarkGray), - )))] - } else { - let mut list_items = Vec::new(); - for (i, group) in state.duplicate_groups.iter().enumerate() { - // Show truncated hash (first 16 chars) for identification - let hash_display = if group.content_hash.len() > 16 { - &group.content_hash[..16] - } else { - &group.content_hash - }; - let header = format!( - "Group {} ({} items, hash: {}...)", - i + 1, - group.items.len(), - hash_display - ); - list_items.push(ListItem::new(Line::from(Span::styled( - header, - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - )))); - for item in &group.items { - let line = format!(" {} - {}", item.file_name, item.path); - let is_selected = state - .duplicates_selected - .map(|sel| sel == list_items.len()) - .unwrap_or(false); - let style = if is_selected { - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::White) - }; - list_items.push(ListItem::new(Line::from(Span::styled(line, style)))); - } - list_items.push(ListItem::new(Line::default())); - } - list_items - }; + let items: Vec = if state.duplicate_groups.is_empty() { + vec![ListItem::new(Line::from(Span::styled( + " No duplicates found. Press 'r' to refresh.", + Style::default().fg(Color::DarkGray), + )))] + } else { + let mut list_items = Vec::new(); + for (i, group) in state.duplicate_groups.iter().enumerate() { + // Show truncated hash (first 16 chars) for identification + let hash_display = if group.content_hash.len() > 16 { + &group.content_hash[..16] + } else { + &group.content_hash + }; + let header = format!( + "Group {} ({} items, hash: {}...)", + i + 1, + group.items.len(), + hash_display + ); + list_items.push(ListItem::new(Line::from(Span::styled( + header, + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )))); + for item in &group.items { + let line = format!(" {} - {}", item.file_name, item.path); + let is_selected = state + .duplicates_selected + .map(|sel| sel == list_items.len()) + .unwrap_or(false); + let style = if is_selected { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + list_items.push(ListItem::new(Line::from(Span::styled(line, style)))); + } + list_items.push(ListItem::new(Line::default())); + } + list_items + }; - let list = List::new(items).block(Block::default().borders(Borders::ALL).title(" Duplicates ")); + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title(" Duplicates ")); - f.render_widget(list, area); + f.render_widget(list, area); } diff --git a/crates/pinakes-tui/src/ui/import.rs b/crates/pinakes-tui/src/ui/import.rs index ce6079e..f72030b 100644 --- a/crates/pinakes-tui/src/ui/import.rs +++ b/crates/pinakes-tui/src/ui/import.rs @@ -1,65 +1,77 @@ -use ratatui::Frame; -use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, +}; use crate::app::AppState; pub fn render(f: &mut Frame, state: &AppState, area: Rect) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(3), Constraint::Min(0)]) - .split(area); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(0)]) + .split(area); - let input = Paragraph::new(state.import_input.as_str()) - .block( - Block::default() - .borders(Borders::ALL) - .title(" Import File (enter path and press Enter) "), - ) - .style(if state.input_mode { - Style::default().fg(Color::Cyan) - } else { - Style::default() - }); - f.render_widget(input, chunks[0]); + let input = Paragraph::new(state.import_input.as_str()) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Import File (enter path and press Enter) "), + ) + .style(if state.input_mode { + Style::default().fg(Color::Cyan) + } else { + Style::default() + }); + f.render_widget(input, chunks[0]); - let label_style = Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD); - let key_style = Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD); + let label_style = Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD); + let key_style = Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD); - let help_lines = vec![ - Line::default(), - Line::from(Span::styled( - " Import a file or trigger a library scan", - label_style, - )), - Line::default(), - Line::from(vec![ - Span::styled(" Enter", key_style), - Span::raw(" Import the file at the entered path"), - ]), - Line::from(vec![ - Span::styled(" Esc", key_style), - Span::raw(" Cancel and return to library"), - ]), - Line::from(vec![ - Span::styled(" s", key_style), - Span::raw(" Trigger a full library scan (scans all configured directories)"), - ]), - Line::default(), - Line::from(Span::styled(" Tips:", label_style)), - Line::from(" - Enter an absolute path to a media file (e.g. /home/user/music/song.mp3)"), - Line::from(" - The file will be copied into the managed library"), - Line::from(" - Duplicates are detected by content hash and will be skipped"), - Line::from(" - Press 's' (without typing a path) to scan all library directories"), - ]; + let help_lines = vec![ + Line::default(), + Line::from(Span::styled( + " Import a file or trigger a library scan", + label_style, + )), + Line::default(), + Line::from(vec![ + Span::styled(" Enter", key_style), + Span::raw(" Import the file at the entered path"), + ]), + Line::from(vec![ + Span::styled(" Esc", key_style), + Span::raw(" Cancel and return to library"), + ]), + Line::from(vec![ + Span::styled(" s", key_style), + Span::raw( + " Trigger a full library scan (scans all configured \ + directories)", + ), + ]), + Line::default(), + Line::from(Span::styled(" Tips:", label_style)), + Line::from( + " - Enter an absolute path to a media file (e.g. \ + /home/user/music/song.mp3)", + ), + Line::from(" - The file will be copied into the managed library"), + Line::from( + " - Duplicates are detected by content hash and will be skipped", + ), + Line::from( + " - Press 's' (without typing a path) to scan all library directories", + ), + ]; - let help = - Paragraph::new(help_lines).block(Block::default().borders(Borders::ALL).title(" Help ")); - f.render_widget(help, chunks[1]); + let help = Paragraph::new(help_lines) + .block(Block::default().borders(Borders::ALL).title(" Help ")); + f.render_widget(help, chunks[1]); } diff --git a/crates/pinakes-tui/src/ui/library.rs b/crates/pinakes-tui/src/ui/library.rs index 1d0282b..1a2065c 100644 --- a/crates/pinakes-tui/src/ui/library.rs +++ b/crates/pinakes-tui/src/ui/library.rs @@ -1,97 +1,101 @@ -use ratatui::Frame; -use ratatui::layout::{Constraint, Rect}; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::Span; -use ratatui::widgets::{Block, Borders, Cell, Row, Table}; +use ratatui::{ + Frame, + layout::{Constraint, Rect}, + style::{Color, Modifier, Style}, + text::Span, + widgets::{Block, Borders, Cell, Row, Table}, +}; use super::{format_duration, format_size, media_type_color}; use crate::app::AppState; pub fn render(f: &mut Frame, state: &AppState, area: Rect) { - let header = Row::new(vec!["", "Title / Name", "Type", "Duration", "Year", "Size"]).style( + let header = + Row::new(vec!["", "Title / Name", "Type", "Duration", "Year", "Size"]) + .style( Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ); + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); - let rows: Vec = state - .media_list - .iter() - .enumerate() - .map(|(i, item)| { - let is_cursor = Some(i) == state.selected_index; - let is_selected = state.selected_items.contains(&item.id); + let rows: Vec = state + .media_list + .iter() + .enumerate() + .map(|(i, item)| { + let is_cursor = Some(i) == state.selected_index; + let is_selected = state.selected_items.contains(&item.id); - let style = if is_cursor { - Style::default().fg(Color::Black).bg(Color::Cyan) - } else if is_selected { - Style::default().fg(Color::Black).bg(Color::Green) - } else { - Style::default() - }; + let style = if is_cursor { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else if is_selected { + Style::default().fg(Color::Black).bg(Color::Green) + } else { + Style::default() + }; - // Selection marker - let marker = if is_selected { "[*]" } else { "[ ]" }; - let marker_style = if is_selected { - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::DarkGray) - }; + // Selection marker + let marker = if is_selected { "[*]" } else { "[ ]" }; + let marker_style = if is_selected { + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; - let display_name = item.title.as_deref().unwrap_or(&item.file_name).to_string(); + let display_name = + item.title.as_deref().unwrap_or(&item.file_name).to_string(); - let type_color = media_type_color(&item.media_type); - let type_cell = Cell::from(Span::styled( - item.media_type.clone(), - Style::default().fg(type_color), - )); + let type_color = media_type_color(&item.media_type); + let type_cell = Cell::from(Span::styled( + item.media_type.clone(), + Style::default().fg(type_color), + )); - let duration = item - .duration_secs - .map(format_duration) - .unwrap_or_else(|| "-".to_string()); + let duration = item + .duration_secs + .map(format_duration) + .unwrap_or_else(|| "-".to_string()); - let year = item - .year - .map(|y| y.to_string()) - .unwrap_or_else(|| "-".to_string()); + let year = item + .year + .map(|y| y.to_string()) + .unwrap_or_else(|| "-".to_string()); - Row::new(vec![ - Cell::from(Span::styled(marker, marker_style)), - Cell::from(display_name), - type_cell, - Cell::from(duration), - Cell::from(year), - Cell::from(format_size(item.file_size)), - ]) - .style(style) - }) - .collect(); + Row::new(vec![ + Cell::from(Span::styled(marker, marker_style)), + Cell::from(display_name), + type_cell, + Cell::from(duration), + Cell::from(year), + Cell::from(format_size(item.file_size)), + ]) + .style(style) + }) + .collect(); - let page = (state.page_offset / state.page_size) + 1; - let item_count = state.media_list.len(); - let selected_count = state.selected_items.len(); - let title = if selected_count > 0 { - format!(" Library (page {page}, {item_count} items, {selected_count} selected) ") - } else { - format!(" Library (page {page}, {item_count} items) ") - }; - - let table = Table::new( - rows, - [ - Constraint::Length(3), // Selection marker - Constraint::Percentage(33), // Title - Constraint::Percentage(18), // Type - Constraint::Percentage(13), // Duration - Constraint::Percentage(8), // Year - Constraint::Percentage(18), // Size - ], + let page = (state.page_offset / state.page_size) + 1; + let item_count = state.media_list.len(); + let selected_count = state.selected_items.len(); + let title = if selected_count > 0 { + format!( + " Library (page {page}, {item_count} items, {selected_count} selected) " ) - .header(header) - .block(Block::default().borders(Borders::ALL).title(title)); + } else { + format!(" Library (page {page}, {item_count} items) ") + }; - f.render_widget(table, area); + let table = Table::new(rows, [ + Constraint::Length(3), // Selection marker + Constraint::Percentage(33), // Title + Constraint::Percentage(18), // Type + Constraint::Percentage(13), // Duration + Constraint::Percentage(8), // Year + Constraint::Percentage(18), // Size + ]) + .header(header) + .block(Block::default().borders(Borders::ALL).title(title)); + + f.render_widget(table, area); } diff --git a/crates/pinakes-tui/src/ui/metadata_edit.rs b/crates/pinakes-tui/src/ui/metadata_edit.rs index 3422bd9..e845c14 100644 --- a/crates/pinakes-tui/src/ui/metadata_edit.rs +++ b/crates/pinakes-tui/src/ui/metadata_edit.rs @@ -1,83 +1,85 @@ -use ratatui::Frame; -use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, +}; use crate::app::AppState; pub fn render(f: &mut Frame, state: &AppState, area: Rect) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(3), Constraint::Min(0)]) - .split(area); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(0)]) + .split(area); - // Header - let title = if let Some(ref media) = state.selected_media { - format!(" Edit: {} ", media.file_name) - } else { - " Edit Metadata ".to_string() - }; + // Header + let title = if let Some(ref media) = state.selected_media { + format!(" Edit: {} ", media.file_name) + } else { + " Edit Metadata ".to_string() + }; - let header = Paragraph::new(Line::from(Span::styled( - &title, - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ))) - .block(Block::default().borders(Borders::ALL)); + let header = Paragraph::new(Line::from(Span::styled( + &title, + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ))) + .block(Block::default().borders(Borders::ALL)); - f.render_widget(header, chunks[0]); + f.render_widget(header, chunks[0]); - // Edit fields - let label_style = Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD); - let value_style = Style::default().fg(Color::White); - let active_style = Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD); - let pad = " "; + // Edit fields + let label_style = Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD); + let value_style = Style::default().fg(Color::White); + let active_style = Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD); + let pad = " "; - let fields = [ - ("Title", &state.edit_title), - ("Artist", &state.edit_artist), - ("Album", &state.edit_album), - ("Genre", &state.edit_genre), - ("Year", &state.edit_year), - ("Description", &state.edit_description), - ]; + let fields = [ + ("Title", &state.edit_title), + ("Artist", &state.edit_artist), + ("Album", &state.edit_album), + ("Genre", &state.edit_genre), + ("Year", &state.edit_year), + ("Description", &state.edit_description), + ]; - let mut lines = Vec::new(); - lines.push(Line::default()); + let mut lines = Vec::new(); + lines.push(Line::default()); - for (i, (label, value)) in fields.iter().enumerate() { - let is_active = state.edit_field_index == Some(i); - let style = if is_active { active_style } else { label_style }; - let cursor = if is_active { "> " } else { pad }; - lines.push(Line::from(vec![ - Span::raw(cursor), - Span::styled(format!("{label:<14}"), style), - Span::styled(value.as_str(), value_style), - if is_active { - Span::styled("_", Style::default().fg(Color::Green)) - } else { - Span::raw("") - }, - ])); - } - - lines.push(Line::default()); + for (i, (label, value)) in fields.iter().enumerate() { + let is_active = state.edit_field_index == Some(i); + let style = if is_active { active_style } else { label_style }; + let cursor = if is_active { "> " } else { pad }; lines.push(Line::from(vec![ - Span::raw(pad), - Span::styled( - "Tab: Next field Enter: Save Esc: Cancel", - Style::default().fg(Color::DarkGray), - ), + Span::raw(cursor), + Span::styled(format!("{label:<14}"), style), + Span::styled(value.as_str(), value_style), + if is_active { + Span::styled("_", Style::default().fg(Color::Green)) + } else { + Span::raw("") + }, ])); + } - let editor = - Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" Fields ")); + lines.push(Line::default()); + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled( + "Tab: Next field Enter: Save Esc: Cancel", + Style::default().fg(Color::DarkGray), + ), + ])); - f.render_widget(editor, chunks[1]); + let editor = Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL).title(" Fields ")); + + f.render_widget(editor, chunks[1]); } diff --git a/crates/pinakes-tui/src/ui/mod.rs b/crates/pinakes-tui/src/ui/mod.rs index 6055cae..726de94 100644 --- a/crates/pinakes-tui/src/ui/mod.rs +++ b/crates/pinakes-tui/src/ui/mod.rs @@ -13,178 +13,188 @@ pub mod statistics; pub mod tags; pub mod tasks; -use ratatui::Frame; -use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Paragraph, Tabs}; +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Tabs}, +}; use crate::app::{AppState, View}; /// Format a file size in bytes into a human-readable string. pub fn format_size(bytes: u64) -> String { - if bytes < 1024 { - format!("{bytes} B") - } else if bytes < 1024 * 1024 { - format!("{:.1} KB", bytes as f64 / 1024.0) - } else if bytes < 1024 * 1024 * 1024 { - format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) - } else { - format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) - } + if bytes < 1024 { + format!("{bytes} B") + } else if bytes < 1024 * 1024 { + format!("{:.1} KB", bytes as f64 / 1024.0) + } else if bytes < 1024 * 1024 * 1024 { + format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) + } else { + format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) + } } /// Format duration in seconds into hh:mm:ss format. pub fn format_duration(secs: f64) -> String { - let total = secs as u64; - let h = total / 3600; - let m = (total % 3600) / 60; - let s = total % 60; - if h > 0 { - format!("{h:02}:{m:02}:{s:02}") - } else { - format!("{m:02}:{s:02}") - } + let total = secs as u64; + let h = total / 3600; + let m = (total % 3600) / 60; + let s = total % 60; + if h > 0 { + format!("{h:02}:{m:02}:{s:02}") + } else { + format!("{m:02}:{s:02}") + } } /// Trim a timestamp string to just the date portion (YYYY-MM-DD). pub fn format_date(timestamp: &str) -> &str { - // Timestamps are typically "2024-01-15T10:30:00Z" or similar - if timestamp.len() >= 10 { - ×tamp[..10] - } else { - timestamp - } + // Timestamps are typically "2024-01-15T10:30:00Z" or similar + if timestamp.len() >= 10 { + ×tamp[..10] + } else { + timestamp + } } /// Return a color based on media type string. pub fn media_type_color(media_type: &str) -> Color { - match media_type { - t if t.starts_with("audio") => Color::Green, - t if t.starts_with("video") => Color::Magenta, - t if t.starts_with("image") => Color::Yellow, - t if t.starts_with("application/pdf") => Color::Red, - t if t.starts_with("text") => Color::Cyan, - _ => Color::White, - } + match media_type { + t if t.starts_with("audio") => Color::Green, + t if t.starts_with("video") => Color::Magenta, + t if t.starts_with("image") => Color::Yellow, + t if t.starts_with("application/pdf") => Color::Red, + t if t.starts_with("text") => Color::Cyan, + _ => Color::White, + } } pub fn render(f: &mut Frame, state: &AppState) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Min(0), - Constraint::Length(1), - ]) - .split(f.area()); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(0), + Constraint::Length(1), + ]) + .split(f.area()); - render_tabs(f, state, chunks[0]); + render_tabs(f, state, chunks[0]); - match state.current_view { - View::Library => library::render(f, state, chunks[1]), - View::Search => search::render(f, state, chunks[1]), - View::Detail => detail::render(f, state, chunks[1]), - View::Tags => tags::render(f, state, chunks[1]), - View::Collections => collections::render(f, state, chunks[1]), - View::Audit => audit::render(f, state, chunks[1]), - View::Import => import::render(f, state, chunks[1]), - View::Settings => settings::render(f, state, chunks[1]), - View::Duplicates => duplicates::render(f, state, chunks[1]), - View::Database => database::render(f, state, chunks[1]), - View::MetadataEdit => metadata_edit::render(f, state, chunks[1]), - View::Queue => queue::render(f, state, chunks[1]), - View::Statistics => statistics::render(f, state, chunks[1]), - View::Tasks => tasks::render(f, state, chunks[1]), - } + match state.current_view { + View::Library => library::render(f, state, chunks[1]), + View::Search => search::render(f, state, chunks[1]), + View::Detail => detail::render(f, state, chunks[1]), + View::Tags => tags::render(f, state, chunks[1]), + View::Collections => collections::render(f, state, chunks[1]), + View::Audit => audit::render(f, state, chunks[1]), + View::Import => import::render(f, state, chunks[1]), + View::Settings => settings::render(f, state, chunks[1]), + View::Duplicates => duplicates::render(f, state, chunks[1]), + View::Database => database::render(f, state, chunks[1]), + View::MetadataEdit => metadata_edit::render(f, state, chunks[1]), + View::Queue => queue::render(f, state, chunks[1]), + View::Statistics => statistics::render(f, state, chunks[1]), + View::Tasks => tasks::render(f, state, chunks[1]), + } - render_status_bar(f, state, chunks[2]); + render_status_bar(f, state, chunks[2]); } fn render_tabs(f: &mut Frame, state: &AppState, area: Rect) { - let titles: Vec = vec![ - "Library", - "Search", - "Tags", - "Collections", - "Audit", - "Queue", - "Stats", - "Tasks", - ] - .into_iter() - .map(|t| Line::from(Span::styled(t, Style::default().fg(Color::White)))) - .collect(); + let titles: Vec = vec![ + "Library", + "Search", + "Tags", + "Collections", + "Audit", + "Queue", + "Stats", + "Tasks", + ] + .into_iter() + .map(|t| Line::from(Span::styled(t, Style::default().fg(Color::White)))) + .collect(); - let selected = match state.current_view { - View::Library | View::Detail | View::Import | View::Settings | View::MetadataEdit => 0, - View::Search => 1, - View::Tags => 2, - View::Collections => 3, - View::Audit | View::Duplicates | View::Database => 4, - View::Queue => 5, - View::Statistics => 6, - View::Tasks => 7, - }; + let selected = match state.current_view { + View::Library + | View::Detail + | View::Import + | View::Settings + | View::MetadataEdit => 0, + View::Search => 1, + View::Tags => 2, + View::Collections => 3, + View::Audit | View::Duplicates | View::Database => 4, + View::Queue => 5, + View::Statistics => 6, + View::Tasks => 7, + }; - let tabs = Tabs::new(titles) - .block(Block::default().borders(Borders::ALL).title(" Pinakes ")) - .select(selected) - .style(Style::default().fg(Color::Gray)) - .highlight_style( - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ); + let tabs = Tabs::new(titles) + .block(Block::default().borders(Borders::ALL).title(" Pinakes ")) + .select(selected) + .style(Style::default().fg(Color::Gray)) + .highlight_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ); - f.render_widget(tabs, area); + f.render_widget(tabs, area); } fn render_status_bar(f: &mut Frame, state: &AppState, area: Rect) { - let status = if let Some(ref msg) = state.status_message { - msg.clone() - } else { - match state.current_view { - View::Tags => { - " q:Quit j/k:Nav Home/End:Top/Bot n:New d:Delete r:Refresh Tab:Switch" - .to_string() - } - View::Collections => { - " q:Quit j/k:Nav Home/End:Top/Bot d:Delete r:Refresh Tab:Switch".to_string() - } - View::Audit => { - " q:Quit j/k:Nav Home/End:Top/Bot r:Refresh Tab:Switch".to_string() - } - View::Detail => { - " q:Quit Esc:Back o:Open e:Edit +:Tag -:Untag r:Refresh ?:Help".to_string() - } - View::Import => { - " Enter:Import Esc:Cancel s:Scan libraries ?:Help".to_string() - } - View::Settings => " q:Quit Esc:Back ?:Help".to_string(), - View::Duplicates => " q:Quit j/k:Nav r:Refresh Esc:Back".to_string(), - View::Database => " q:Quit v:Vacuum r:Refresh Esc:Back".to_string(), - View::MetadataEdit => { - " Tab:Next field Enter:Save Esc:Cancel".to_string() - } - View::Queue => { - " q:Quit j/k:Nav Enter:Play d:Remove N:Next P:Prev R:Repeat S:Shuffle C:Clear" - .to_string() - } - View::Statistics => " q:Quit r:Refresh Esc:Back ?:Help".to_string(), - View::Tasks => { - " q:Quit j/k:Nav Enter:Toggle R:Run Now r:Refresh Esc:Back".to_string() - } - _ => { - " q:Quit /:Search i:Import o:Open t:Tags c:Coll a:Audit D:Dupes B:DB Q:Queue X:Stats T:Tasks ?:Help" - .to_string() - } - } - }; + let status = if let Some(ref msg) = state.status_message { + msg.clone() + } else { + match state.current_view { + View::Tags => { + " q:Quit j/k:Nav Home/End:Top/Bot n:New d:Delete r:Refresh \ + Tab:Switch" + .to_string() + }, + View::Collections => { + " q:Quit j/k:Nav Home/End:Top/Bot d:Delete r:Refresh Tab:Switch" + .to_string() + }, + View::Audit => { + " q:Quit j/k:Nav Home/End:Top/Bot r:Refresh Tab:Switch".to_string() + }, + View::Detail => { + " q:Quit Esc:Back o:Open e:Edit +:Tag -:Untag r:Refresh ?:Help" + .to_string() + }, + View::Import => { + " Enter:Import Esc:Cancel s:Scan libraries ?:Help".to_string() + }, + View::Settings => " q:Quit Esc:Back ?:Help".to_string(), + View::Duplicates => " q:Quit j/k:Nav r:Refresh Esc:Back".to_string(), + View::Database => " q:Quit v:Vacuum r:Refresh Esc:Back".to_string(), + View::MetadataEdit => { + " Tab:Next field Enter:Save Esc:Cancel".to_string() + }, + View::Queue => { + " q:Quit j/k:Nav Enter:Play d:Remove N:Next P:Prev R:Repeat \ + S:Shuffle C:Clear" + .to_string() + }, + View::Statistics => " q:Quit r:Refresh Esc:Back ?:Help".to_string(), + View::Tasks => { + " q:Quit j/k:Nav Enter:Toggle R:Run Now r:Refresh Esc:Back" + .to_string() + }, + _ => " q:Quit /:Search i:Import o:Open t:Tags c:Coll a:Audit \ + D:Dupes B:DB Q:Queue X:Stats T:Tasks ?:Help" + .to_string(), + } + }; - let paragraph = Paragraph::new(Line::from(Span::styled( - status, - Style::default().fg(Color::DarkGray), - ))); - f.render_widget(paragraph, area); + let paragraph = Paragraph::new(Line::from(Span::styled( + status, + Style::default().fg(Color::DarkGray), + ))); + f.render_widget(paragraph, area); } diff --git a/crates/pinakes-tui/src/ui/queue.rs b/crates/pinakes-tui/src/ui/queue.rs index fcb4537..d8936d9 100644 --- a/crates/pinakes-tui/src/ui/queue.rs +++ b/crates/pinakes-tui/src/ui/queue.rs @@ -1,69 +1,72 @@ -use ratatui::Frame; -use ratatui::layout::Rect; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, List, ListItem}; +use ratatui::{ + Frame, + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem}, +}; use crate::app::AppState; pub fn render(f: &mut Frame, state: &AppState, area: Rect) { - let items: Vec = if state.play_queue.is_empty() { - vec![ListItem::new(Line::from(Span::styled( - " Queue is empty. Select items in the library and press 'q' to add.", - Style::default().fg(Color::DarkGray), - )))] - } else { - state - .play_queue - .iter() - .enumerate() - .map(|(i, item)| { - let is_current = state.queue_current_index == Some(i); - let is_selected = state.queue_selected == Some(i); - let prefix = if is_current { ">> " } else { " " }; - let type_color = super::media_type_color(&item.media_type); - let id_suffix = if item.media_id.len() > 8 { - &item.media_id[item.media_id.len() - 8..] - } else { - &item.media_id - }; - let text = if let Some(ref artist) = item.artist { - format!("{prefix}{} - {} [{}]", item.title, artist, id_suffix) - } else { - format!("{prefix}{} [{}]", item.title, id_suffix) - }; + let items: Vec = if state.play_queue.is_empty() { + vec![ListItem::new(Line::from(Span::styled( + " Queue is empty. Select items in the library and press 'q' to add.", + Style::default().fg(Color::DarkGray), + )))] + } else { + state + .play_queue + .iter() + .enumerate() + .map(|(i, item)| { + let is_current = state.queue_current_index == Some(i); + let is_selected = state.queue_selected == Some(i); + let prefix = if is_current { ">> " } else { " " }; + let type_color = super::media_type_color(&item.media_type); + let id_suffix = if item.media_id.len() > 8 { + &item.media_id[item.media_id.len() - 8..] + } else { + &item.media_id + }; + let text = if let Some(ref artist) = item.artist { + format!("{prefix}{} - {} [{}]", item.title, artist, id_suffix) + } else { + format!("{prefix}{} [{}]", item.title, id_suffix) + }; - let style = if is_selected { - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD) - } else if is_current { - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(type_color) - }; + let style = if is_selected { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else if is_current { + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(type_color) + }; - ListItem::new(Line::from(Span::styled(text, style))) - }) - .collect() - }; + ListItem::new(Line::from(Span::styled(text, style))) + }) + .collect() + }; - let repeat_str = match state.queue_repeat { - 0 => "Off", - 1 => "One", - _ => "All", - }; - let shuffle_str = if state.queue_shuffle { "On" } else { "Off" }; - let title = format!( - " Queue ({}) | Repeat: {} | Shuffle: {} ", - state.play_queue.len(), - repeat_str, - shuffle_str, - ); + let repeat_str = match state.queue_repeat { + 0 => "Off", + 1 => "One", + _ => "All", + }; + let shuffle_str = if state.queue_shuffle { "On" } else { "Off" }; + let title = format!( + " Queue ({}) | Repeat: {} | Shuffle: {} ", + state.play_queue.len(), + repeat_str, + shuffle_str, + ); - let list = List::new(items).block(Block::default().borders(Borders::ALL).title(title)); + let list = + List::new(items).block(Block::default().borders(Borders::ALL).title(title)); - f.render_widget(list, area); + f.render_widget(list, area); } diff --git a/crates/pinakes-tui/src/ui/search.rs b/crates/pinakes-tui/src/ui/search.rs index ae3d681..7ac996e 100644 --- a/crates/pinakes-tui/src/ui/search.rs +++ b/crates/pinakes-tui/src/ui/search.rs @@ -1,103 +1,104 @@ -use ratatui::Frame; -use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::Span; -use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table}; +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::Span, + widgets::{Block, Borders, Cell, Paragraph, Row, Table}, +}; use super::{format_size, media_type_color}; use crate::app::AppState; pub fn render(f: &mut Frame, state: &AppState, area: Rect) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(3), Constraint::Min(0)]) - .split(area); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(0)]) + .split(area); - // Search input - let input = Paragraph::new(state.search_input.as_str()) - .block( - Block::default() - .borders(Borders::ALL) - .title(" Search (type and press Enter) "), - ) - .style(if state.input_mode { - Style::default().fg(Color::Cyan) - } else { - Style::default() - }); - f.render_widget(input, chunks[0]); - - // Results - let header = Row::new(vec!["", "Name", "Type", "Artist", "Size"]).style( - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ); - - let rows: Vec = state - .search_results - .iter() - .enumerate() - .map(|(i, item)| { - let is_cursor = Some(i) == state.search_selected; - let is_selected = state.selected_items.contains(&item.id); - - let style = if is_cursor { - Style::default().fg(Color::Black).bg(Color::Cyan) - } else if is_selected { - Style::default().fg(Color::Black).bg(Color::Green) - } else { - Style::default() - }; - - // Selection marker - let marker = if is_selected { "[*]" } else { "[ ]" }; - let marker_style = if is_selected { - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::DarkGray) - }; - - let type_color = media_type_color(&item.media_type); - let type_cell = Cell::from(Span::styled( - item.media_type.clone(), - Style::default().fg(type_color), - )); - - Row::new(vec![ - Cell::from(Span::styled(marker, marker_style)), - Cell::from(item.file_name.clone()), - type_cell, - Cell::from(item.artist.clone().unwrap_or_default()), - Cell::from(format_size(item.file_size)), - ]) - .style(style) - }) - .collect(); - - let shown = state.search_results.len(); - let total = state.search_total_count; - let selected_count = state.selected_items.len(); - let results_title = if selected_count > 0 { - format!(" Results: {shown} shown, {total} total, {selected_count} selected ") - } else { - format!(" Results: {shown} shown, {total} total ") - }; - - let table = Table::new( - rows, - [ - Constraint::Length(3), // Selection marker - Constraint::Percentage(33), // Name - Constraint::Percentage(18), // Type - Constraint::Percentage(23), // Artist - Constraint::Percentage(18), // Size - ], + // Search input + let input = Paragraph::new(state.search_input.as_str()) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Search (type and press Enter) "), ) - .header(header) - .block(Block::default().borders(Borders::ALL).title(results_title)); + .style(if state.input_mode { + Style::default().fg(Color::Cyan) + } else { + Style::default() + }); + f.render_widget(input, chunks[0]); - f.render_widget(table, chunks[1]); + // Results + let header = Row::new(vec!["", "Name", "Type", "Artist", "Size"]).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + + let rows: Vec = state + .search_results + .iter() + .enumerate() + .map(|(i, item)| { + let is_cursor = Some(i) == state.search_selected; + let is_selected = state.selected_items.contains(&item.id); + + let style = if is_cursor { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else if is_selected { + Style::default().fg(Color::Black).bg(Color::Green) + } else { + Style::default() + }; + + // Selection marker + let marker = if is_selected { "[*]" } else { "[ ]" }; + let marker_style = if is_selected { + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + + let type_color = media_type_color(&item.media_type); + let type_cell = Cell::from(Span::styled( + item.media_type.clone(), + Style::default().fg(type_color), + )); + + Row::new(vec![ + Cell::from(Span::styled(marker, marker_style)), + Cell::from(item.file_name.clone()), + type_cell, + Cell::from(item.artist.clone().unwrap_or_default()), + Cell::from(format_size(item.file_size)), + ]) + .style(style) + }) + .collect(); + + let shown = state.search_results.len(); + let total = state.search_total_count; + let selected_count = state.selected_items.len(); + let results_title = if selected_count > 0 { + format!( + " Results: {shown} shown, {total} total, {selected_count} selected " + ) + } else { + format!(" Results: {shown} shown, {total} total ") + }; + + let table = Table::new(rows, [ + Constraint::Length(3), // Selection marker + Constraint::Percentage(33), // Name + Constraint::Percentage(18), // Type + Constraint::Percentage(23), // Artist + Constraint::Percentage(18), // Size + ]) + .header(header) + .block(Block::default().borders(Borders::ALL).title(results_title)); + + f.render_widget(table, chunks[1]); } diff --git a/crates/pinakes-tui/src/ui/settings.rs b/crates/pinakes-tui/src/ui/settings.rs index b2394a5..e331343 100644 --- a/crates/pinakes-tui/src/ui/settings.rs +++ b/crates/pinakes-tui/src/ui/settings.rs @@ -1,82 +1,84 @@ -use ratatui::Frame; -use ratatui::layout::Rect; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::{ + Frame, + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, +}; use crate::app::AppState; pub fn render(f: &mut Frame, state: &AppState, area: Rect) { - let label_style = Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD); - let value_style = Style::default().fg(Color::White); - let section_style = Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD); + let label_style = Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD); + let value_style = Style::default().fg(Color::White); + let section_style = Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD); - let pad = " "; + let pad = " "; - let lines = vec![ - Line::default(), - Line::from(Span::styled("--- Connection ---", section_style)), - Line::from(vec![ - Span::raw(pad), - Span::styled("Server URL: ", label_style), - Span::styled(&state.server_url, value_style), - ]), - Line::default(), - Line::from(Span::styled("--- Library ---", section_style)), - Line::from(vec![ - Span::raw(pad), - Span::styled("Total items: ", label_style), - Span::styled(state.total_media_count.to_string(), value_style), - ]), - Line::from(vec![ - Span::raw(pad), - Span::styled("Page size: ", label_style), - Span::styled(state.page_size.to_string(), value_style), - ]), - Line::from(vec![ - Span::raw(pad), - Span::styled("Current page: ", label_style), - Span::styled( - ((state.page_offset / state.page_size) + 1).to_string(), - value_style, - ), - ]), - Line::default(), - Line::from(Span::styled("--- State ---", section_style)), - Line::from(vec![ - Span::raw(pad), - Span::styled("Tags loaded: ", label_style), - Span::styled(state.tags.len().to_string(), value_style), - ]), - Line::from(vec![ - Span::raw(pad), - Span::styled("All tags: ", label_style), - Span::styled(state.all_tags.len().to_string(), value_style), - ]), - Line::from(vec![ - Span::raw(pad), - Span::styled("Collections: ", label_style), - Span::styled(state.collections.len().to_string(), value_style), - ]), - Line::from(vec![ - Span::raw(pad), - Span::styled("Audit entries: ", label_style), - Span::styled(state.audit_log.len().to_string(), value_style), - ]), - Line::default(), - Line::from(Span::styled("--- Shortcuts ---", section_style)), - Line::from(vec![ - Span::raw(pad), - Span::raw("Press Esc to return to the library view"), - ]), - ]; + let lines = vec![ + Line::default(), + Line::from(Span::styled("--- Connection ---", section_style)), + Line::from(vec![ + Span::raw(pad), + Span::styled("Server URL: ", label_style), + Span::styled(&state.server_url, value_style), + ]), + Line::default(), + Line::from(Span::styled("--- Library ---", section_style)), + Line::from(vec![ + Span::raw(pad), + Span::styled("Total items: ", label_style), + Span::styled(state.total_media_count.to_string(), value_style), + ]), + Line::from(vec![ + Span::raw(pad), + Span::styled("Page size: ", label_style), + Span::styled(state.page_size.to_string(), value_style), + ]), + Line::from(vec![ + Span::raw(pad), + Span::styled("Current page: ", label_style), + Span::styled( + ((state.page_offset / state.page_size) + 1).to_string(), + value_style, + ), + ]), + Line::default(), + Line::from(Span::styled("--- State ---", section_style)), + Line::from(vec![ + Span::raw(pad), + Span::styled("Tags loaded: ", label_style), + Span::styled(state.tags.len().to_string(), value_style), + ]), + Line::from(vec![ + Span::raw(pad), + Span::styled("All tags: ", label_style), + Span::styled(state.all_tags.len().to_string(), value_style), + ]), + Line::from(vec![ + Span::raw(pad), + Span::styled("Collections: ", label_style), + Span::styled(state.collections.len().to_string(), value_style), + ]), + Line::from(vec![ + Span::raw(pad), + Span::styled("Audit entries: ", label_style), + Span::styled(state.audit_log.len().to_string(), value_style), + ]), + Line::default(), + Line::from(Span::styled("--- Shortcuts ---", section_style)), + Line::from(vec![ + Span::raw(pad), + Span::raw("Press Esc to return to the library view"), + ]), + ]; - let settings = - Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" Settings ")); + let settings = Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL).title(" Settings ")); - f.render_widget(settings, area); + f.render_widget(settings, area); } diff --git a/crates/pinakes-tui/src/ui/statistics.rs b/crates/pinakes-tui/src/ui/statistics.rs index 542d89d..3a1d346 100644 --- a/crates/pinakes-tui/src/ui/statistics.rs +++ b/crates/pinakes-tui/src/ui/statistics.rs @@ -1,183 +1,189 @@ -use ratatui::Frame; -use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Paragraph, Row, Table}; +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Row, Table}, +}; use crate::app::AppState; pub fn render(f: &mut Frame, state: &AppState, area: Rect) { - let Some(ref stats) = state.library_stats else { - let msg = Paragraph::new("Loading statistics... (press X to refresh)") - .block(Block::default().borders(Borders::ALL).title(" Statistics ")); - f.render_widget(msg, area); - return; - }; + let Some(ref stats) = state.library_stats else { + let msg = Paragraph::new("Loading statistics... (press X to refresh)") + .block(Block::default().borders(Borders::ALL).title(" Statistics ")); + f.render_widget(msg, area); + return; + }; - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(8), // Overview - Constraint::Length(10), // Media by type - Constraint::Min(6), // Top tags & collections - ]) - .split(area); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(8), // Overview + Constraint::Length(10), // Media by type + Constraint::Min(6), // Top tags & collections + ]) + .split(area); - // Overview section - let overview_lines = vec![ - Line::from(vec![ - Span::styled(" Total Media: ", Style::default().fg(Color::Gray)), - Span::styled( - stats.total_media.to_string(), - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled("Total Size: ", Style::default().fg(Color::Gray)), - Span::styled( - super::format_size(stats.total_size_bytes), - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ), - ]), - Line::from(vec![ - Span::styled(" Avg Size: ", Style::default().fg(Color::Gray)), - Span::styled( - super::format_size(stats.avg_file_size_bytes), - Style::default().fg(Color::White), - ), - ]), - Line::from(vec![ - Span::styled(" Tags: ", Style::default().fg(Color::Gray)), - Span::styled( - stats.total_tags.to_string(), - Style::default().fg(Color::Green), - ), - Span::raw(" "), - Span::styled("Collections: ", Style::default().fg(Color::Gray)), - Span::styled( - stats.total_collections.to_string(), - Style::default().fg(Color::Green), - ), - Span::raw(" "), - Span::styled("Duplicates: ", Style::default().fg(Color::Gray)), - Span::styled( - stats.total_duplicates.to_string(), - Style::default().fg(Color::Yellow), - ), - ]), - Line::from(vec![ - Span::styled(" Newest: ", Style::default().fg(Color::Gray)), - Span::styled( - stats - .newest_item - .as_deref() - .map(super::format_date) - .unwrap_or("-"), - Style::default().fg(Color::White), - ), - Span::raw(" "), - Span::styled("Oldest: ", Style::default().fg(Color::Gray)), - Span::styled( - stats - .oldest_item - .as_deref() - .map(super::format_date) - .unwrap_or("-"), - Style::default().fg(Color::White), - ), - ]), - ]; + // Overview section + let overview_lines = vec![ + Line::from(vec![ + Span::styled(" Total Media: ", Style::default().fg(Color::Gray)), + Span::styled( + stats.total_media.to_string(), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled("Total Size: ", Style::default().fg(Color::Gray)), + Span::styled( + super::format_size(stats.total_size_bytes), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(vec![ + Span::styled(" Avg Size: ", Style::default().fg(Color::Gray)), + Span::styled( + super::format_size(stats.avg_file_size_bytes), + Style::default().fg(Color::White), + ), + ]), + Line::from(vec![ + Span::styled(" Tags: ", Style::default().fg(Color::Gray)), + Span::styled( + stats.total_tags.to_string(), + Style::default().fg(Color::Green), + ), + Span::raw(" "), + Span::styled("Collections: ", Style::default().fg(Color::Gray)), + Span::styled( + stats.total_collections.to_string(), + Style::default().fg(Color::Green), + ), + Span::raw(" "), + Span::styled("Duplicates: ", Style::default().fg(Color::Gray)), + Span::styled( + stats.total_duplicates.to_string(), + Style::default().fg(Color::Yellow), + ), + ]), + Line::from(vec![ + Span::styled(" Newest: ", Style::default().fg(Color::Gray)), + Span::styled( + stats + .newest_item + .as_deref() + .map(super::format_date) + .unwrap_or("-"), + Style::default().fg(Color::White), + ), + Span::raw(" "), + Span::styled("Oldest: ", Style::default().fg(Color::Gray)), + Span::styled( + stats + .oldest_item + .as_deref() + .map(super::format_date) + .unwrap_or("-"), + Style::default().fg(Color::White), + ), + ]), + ]; - let overview = Paragraph::new(overview_lines) - .block(Block::default().borders(Borders::ALL).title(" Overview ")); - f.render_widget(overview, chunks[0]); + let overview = Paragraph::new(overview_lines) + .block(Block::default().borders(Borders::ALL).title(" Overview ")); + f.render_widget(overview, chunks[0]); - // Media by Type table - let type_rows: Vec = stats - .media_by_type - .iter() - .map(|tc| { - let color = super::media_type_color(&tc.name); - Row::new(vec![ - Span::styled(tc.name.clone(), Style::default().fg(color)), - Span::styled(tc.count.to_string(), Style::default().fg(Color::White)), - ]) - }) - .collect(); + // Media by Type table + let type_rows: Vec = stats + .media_by_type + .iter() + .map(|tc| { + let color = super::media_type_color(&tc.name); + Row::new(vec![ + Span::styled(tc.name.clone(), Style::default().fg(color)), + Span::styled(tc.count.to_string(), Style::default().fg(Color::White)), + ]) + }) + .collect(); - let storage_rows: Vec = stats - .storage_by_type - .iter() - .map(|tc| { - Row::new(vec![ - Span::styled(tc.name.clone(), Style::default().fg(Color::Gray)), - Span::styled( - super::format_size(tc.count), - Style::default().fg(Color::White), - ), - ]) - }) - .collect(); + let storage_rows: Vec = stats + .storage_by_type + .iter() + .map(|tc| { + Row::new(vec![ + Span::styled(tc.name.clone(), Style::default().fg(Color::Gray)), + Span::styled( + super::format_size(tc.count), + Style::default().fg(Color::White), + ), + ]) + }) + .collect(); - let type_cols = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(chunks[1]); + let type_cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(chunks[1]); - let type_table = Table::new(type_rows, [Constraint::Min(20), Constraint::Length(10)]).block( - Block::default() - .borders(Borders::ALL) - .title(" Media by Type "), + let type_table = + Table::new(type_rows, [Constraint::Min(20), Constraint::Length(10)]).block( + Block::default() + .borders(Borders::ALL) + .title(" Media by Type "), ); - f.render_widget(type_table, type_cols[0]); + f.render_widget(type_table, type_cols[0]); - let storage_table = Table::new(storage_rows, [Constraint::Min(20), Constraint::Length(12)]) - .block( - Block::default() - .borders(Borders::ALL) - .title(" Storage by Type "), - ); - f.render_widget(storage_table, type_cols[1]); - - // Top tags and collections - let bottom_cols = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(chunks[2]); - - let tag_rows: Vec = stats - .top_tags - .iter() - .map(|tc| { - Row::new(vec![ - Span::styled(tc.name.clone(), Style::default().fg(Color::Green)), - Span::styled(tc.count.to_string(), Style::default().fg(Color::White)), - ]) - }) - .collect(); - - let tags_table = Table::new(tag_rows, [Constraint::Min(20), Constraint::Length(10)]) - .block(Block::default().borders(Borders::ALL).title(" Top Tags ")); - f.render_widget(tags_table, bottom_cols[0]); - - let col_rows: Vec = stats - .top_collections - .iter() - .map(|tc| { - Row::new(vec![ - Span::styled(tc.name.clone(), Style::default().fg(Color::Magenta)), - Span::styled(tc.count.to_string(), Style::default().fg(Color::White)), - ]) - }) - .collect(); - - let cols_table = Table::new(col_rows, [Constraint::Min(20), Constraint::Length(10)]).block( + let storage_table = + Table::new(storage_rows, [Constraint::Min(20), Constraint::Length(12)]) + .block( Block::default() - .borders(Borders::ALL) - .title(" Top Collections "), + .borders(Borders::ALL) + .title(" Storage by Type "), + ); + f.render_widget(storage_table, type_cols[1]); + + // Top tags and collections + let bottom_cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(chunks[2]); + + let tag_rows: Vec = stats + .top_tags + .iter() + .map(|tc| { + Row::new(vec![ + Span::styled(tc.name.clone(), Style::default().fg(Color::Green)), + Span::styled(tc.count.to_string(), Style::default().fg(Color::White)), + ]) + }) + .collect(); + + let tags_table = + Table::new(tag_rows, [Constraint::Min(20), Constraint::Length(10)]) + .block(Block::default().borders(Borders::ALL).title(" Top Tags ")); + f.render_widget(tags_table, bottom_cols[0]); + + let col_rows: Vec = stats + .top_collections + .iter() + .map(|tc| { + Row::new(vec![ + Span::styled(tc.name.clone(), Style::default().fg(Color::Magenta)), + Span::styled(tc.count.to_string(), Style::default().fg(Color::White)), + ]) + }) + .collect(); + + let cols_table = + Table::new(col_rows, [Constraint::Min(20), Constraint::Length(10)]).block( + Block::default() + .borders(Borders::ALL) + .title(" Top Collections "), ); - f.render_widget(cols_table, bottom_cols[1]); + f.render_widget(cols_table, bottom_cols[1]); } diff --git a/crates/pinakes-tui/src/ui/tags.rs b/crates/pinakes-tui/src/ui/tags.rs index 4b092ae..33347c7 100644 --- a/crates/pinakes-tui/src/ui/tags.rs +++ b/crates/pinakes-tui/src/ui/tags.rs @@ -1,61 +1,62 @@ -use ratatui::Frame; -use ratatui::layout::Rect; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::widgets::{Block, Borders, Row, Table}; +use ratatui::{ + Frame, + layout::Rect, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, Row, Table}, +}; use super::format_date; use crate::app::AppState; pub fn render(f: &mut Frame, state: &AppState, area: Rect) { - let header = Row::new(vec!["Name", "Parent", "Created"]).style( + let header = Row::new(vec!["Name", "Parent", "Created"]).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + + let rows: Vec = state + .tags + .iter() + .enumerate() + .map(|(i, tag)| { + let style = if Some(i) == state.tag_selected { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ); + }; - let rows: Vec = state - .tags - .iter() - .enumerate() - .map(|(i, tag)| { - let style = if Some(i) == state.tag_selected { - Style::default().fg(Color::Black).bg(Color::Cyan) - } else { - Style::default() - }; + // Resolve parent tag name from the tags list itself + let parent_display = match &tag.parent_id { + Some(pid) => { + state + .tags + .iter() + .find(|t| t.id == *pid) + .map(|t| t.name.clone()) + .unwrap_or_else(|| pid.chars().take(8).collect::() + "...") + }, + None => "-".to_string(), + }; - // Resolve parent tag name from the tags list itself - let parent_display = match &tag.parent_id { - Some(pid) => state - .tags - .iter() - .find(|t| t.id == *pid) - .map(|t| t.name.clone()) - .unwrap_or_else(|| pid.chars().take(8).collect::() + "..."), - None => "-".to_string(), - }; + Row::new(vec![ + tag.name.clone(), + parent_display, + format_date(&tag.created_at).to_string(), + ]) + .style(style) + }) + .collect(); - Row::new(vec![ - tag.name.clone(), - parent_display, - format_date(&tag.created_at).to_string(), - ]) - .style(style) - }) - .collect(); + let title = format!(" Tags ({}) ", state.tags.len()); - let title = format!(" Tags ({}) ", state.tags.len()); + let table = Table::new(rows, [ + ratatui::layout::Constraint::Percentage(40), + ratatui::layout::Constraint::Percentage(30), + ratatui::layout::Constraint::Percentage(30), + ]) + .header(header) + .block(Block::default().borders(Borders::ALL).title(title)); - let table = Table::new( - rows, - [ - ratatui::layout::Constraint::Percentage(40), - ratatui::layout::Constraint::Percentage(30), - ratatui::layout::Constraint::Percentage(30), - ], - ) - .header(header) - .block(Block::default().borders(Borders::ALL).title(title)); - - f.render_widget(table, area); + f.render_widget(table, area); } diff --git a/crates/pinakes-tui/src/ui/tasks.rs b/crates/pinakes-tui/src/ui/tasks.rs index 01149e1..712c67f 100644 --- a/crates/pinakes-tui/src/ui/tasks.rs +++ b/crates/pinakes-tui/src/ui/tasks.rs @@ -1,69 +1,73 @@ -use ratatui::Frame; -use ratatui::layout::Rect; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, List, ListItem}; +use ratatui::{ + Frame, + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem}, +}; use crate::app::AppState; pub fn render(f: &mut Frame, state: &AppState, area: Rect) { - let items: Vec = if state.scheduled_tasks.is_empty() { - vec![ListItem::new(Line::from(Span::styled( - " No scheduled tasks. Press T to refresh.", - Style::default().fg(Color::DarkGray), - )))] - } else { - state - .scheduled_tasks - .iter() - .enumerate() - .map(|(i, task)| { - let is_selected = state.scheduled_tasks_selected == Some(i); - let enabled_marker = if task.enabled { "[ON] " } else { "[OFF]" }; - let enabled_color = if task.enabled { - Color::Green - } else { - Color::DarkGray - }; + let items: Vec = if state.scheduled_tasks.is_empty() { + vec![ListItem::new(Line::from(Span::styled( + " No scheduled tasks. Press T to refresh.", + Style::default().fg(Color::DarkGray), + )))] + } else { + state + .scheduled_tasks + .iter() + .enumerate() + .map(|(i, task)| { + let is_selected = state.scheduled_tasks_selected == Some(i); + let enabled_marker = if task.enabled { "[ON] " } else { "[OFF]" }; + let enabled_color = if task.enabled { + Color::Green + } else { + Color::DarkGray + }; - let last_run = task - .last_run - .as_deref() - .map(super::format_date) - .unwrap_or("-"); - let next_run = task - .next_run - .as_deref() - .map(super::format_date) - .unwrap_or("-"); - let status = task.last_status.as_deref().unwrap_or("-"); - // Show abbreviated task ID (first 8 chars) - let task_id_short = if task.id.len() > 8 { - &task.id[..8] - } else { - &task.id - }; + let last_run = task + .last_run + .as_deref() + .map(super::format_date) + .unwrap_or("-"); + let next_run = task + .next_run + .as_deref() + .map(super::format_date) + .unwrap_or("-"); + let status = task.last_status.as_deref().unwrap_or("-"); + // Show abbreviated task ID (first 8 chars) + let task_id_short = if task.id.len() > 8 { + &task.id[..8] + } else { + &task.id + }; - let text = format!( - " {enabled_marker} [{task_id_short}] {:<20} {:<16} Last: {:<12} Next: {:<12} Status: {}", - task.name, task.schedule, last_run, next_run, status - ); + let text = format!( + " {enabled_marker} [{task_id_short}] {:<20} {:<16} Last: {:<12} \ + Next: {:<12} Status: {}", + task.name, task.schedule, last_run, next_run, status + ); - let style = if is_selected { - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(enabled_color) - }; + let style = if is_selected { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(enabled_color) + }; - ListItem::new(Line::from(Span::styled(text, style))) - }) - .collect() - }; + ListItem::new(Line::from(Span::styled(text, style))) + }) + .collect() + }; - let title = format!(" Scheduled Tasks ({}) ", state.scheduled_tasks.len()); - let list = List::new(items).block(Block::default().borders(Borders::ALL).title(title)); + let title = format!(" Scheduled Tasks ({}) ", state.scheduled_tasks.len()); + let list = + List::new(items).block(Block::default().borders(Borders::ALL).title(title)); - f.render_widget(list, area); + f.render_widget(list, area); } diff --git a/crates/pinakes-ui/Cargo.toml b/crates/pinakes-ui/Cargo.toml index 7c09848..57eed63 100644 --- a/crates/pinakes-ui/Cargo.toml +++ b/crates/pinakes-ui/Cargo.toml @@ -32,4 +32,5 @@ web = ["dioxus/web"] desktop = ["dioxus/desktop"] mobile = ["dioxus/mobile"] - +[build-dependencies] +grass = "0.13" diff --git a/crates/pinakes-ui/Dioxus.toml b/crates/pinakes-ui/Dioxus.toml new file mode 100644 index 0000000..14fd6c3 --- /dev/null +++ b/crates/pinakes-ui/Dioxus.toml @@ -0,0 +1,21 @@ +[application] + +[web.app] + +# HTML title tag content +title = "pinakes" + +# include `assets` in web platform +[web.resource] + +# Additional CSS style files +style = [] + +# Additional JavaScript files +script = [] + +[web.resource.dev] + +# Javascript code file +# serve: [dev-server] only +script = [] diff --git a/crates/pinakes-ui/assets/css/main.css b/crates/pinakes-ui/assets/css/main.css new file mode 100644 index 0000000..3bddc22 --- /dev/null +++ b/crates/pinakes-ui/assets/css/main.css @@ -0,0 +1 @@ +@media (prefers-reduced-motion: reduce){*,*::before,*::after{animation-duration:.01ms !important;animation-iteration-count:1 !important;transition-duration:.01ms !important}}*{margin:0;padding:0;box-sizing:border-box;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.06) rgba(0,0,0,0)}*::-webkit-scrollbar{width:5px;height:5px}*::-webkit-scrollbar-track{background:rgba(0,0,0,0)}*::-webkit-scrollbar-thumb{background:rgba(255,255,255,.06);border-radius:3px}*::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.14)}:root{--bg-0: #111118;--bg-1: #18181f;--bg-2: #1f1f28;--bg-3: #26263a;--border-subtle: rgba(255,255,255,.06);--border: rgba(255,255,255,.09);--border-strong: rgba(255,255,255,.14);--text-0: #dcdce4;--text-1: #a0a0b8;--text-2: #6c6c84;--accent: #7c7ef5;--accent-dim: rgba(124,126,245,.15);--accent-text: #9698f7;--success: #3ec97a;--error: #e45858;--warning: #d4a037;--radius-sm: 3px;--radius: 5px;--radius-md: 7px;--shadow-sm: 0 1px 3px rgba(0,0,0,.3);--shadow: 0 2px 8px rgba(0,0,0,.35);--shadow-lg: 0 4px 20px rgba(0,0,0,.45)}body{font-family:"Inter",-apple-system,"Segoe UI",system-ui,sans-serif;background:var(--bg-0);color:var(--text-0);font-size:13px;line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden}:focus-visible:focus-visible{outline:2px solid #7c7ef5;outline-offset:2px}::selection{background:rgba(124,126,245,.15);color:#9698f7}a{color:#9698f7;text-decoration:none}a:hover{text-decoration:underline}code{padding:1px 5px;border-radius:3px;background:#111118;color:#9698f7;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px}ul{list-style:none;padding:0}ul li{padding:3px 0;font-size:12px;color:#a0a0b8}.text-muted{color:#a0a0b8}.text-sm{font-size:11px}.mono{font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px}.flex-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px}.flex-between{display:flex;justify-content:space-between;align-items:center}.mb-16{margin-bottom:16px}.mb-8{margin-bottom:12px}@keyframes fade-in{from{opacity:0}to{opacity:1}}@keyframes slide-up{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}@keyframes pulse{0%, 100%{opacity:1}50%{opacity:.3}}@keyframes spin{to{transform:rotate(360deg)}}@keyframes skeleton-pulse{0%{opacity:.6}50%{opacity:.3}100%{opacity:.6}}@keyframes indeterminate{0%{transform:translateX(-100%)}100%{transform:translateX(400%)}}.app{display:flex;flex-direction:row;justify-content:flex-start;align-items:stretch;height:100vh;overflow:hidden}.sidebar{width:220px;min-width:220px;max-width:220px;background:#18181f;border-right:1px solid rgba(255,255,255,.09);display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;flex-shrink:0;user-select:none;overflow-y:auto;overflow-x:hidden;z-index:10;transition:width .15s,min-width .15s,max-width .15s}.sidebar.collapsed{width:48px;min-width:48px;max-width:48px}.sidebar.collapsed .nav-label,.sidebar.collapsed .sidebar-header .logo,.sidebar.collapsed .sidebar-header .version,.sidebar.collapsed .nav-badge,.sidebar.collapsed .nav-item-text,.sidebar.collapsed .sidebar-footer .status-text,.sidebar.collapsed .user-name,.sidebar.collapsed .role-badge,.sidebar.collapsed .user-info .btn,.sidebar.collapsed .sidebar-import-header span,.sidebar.collapsed .sidebar-import-file{display:none}.sidebar.collapsed .nav-item{justify-content:center;padding:8px;border-left:none;border-radius:3px}.sidebar.collapsed .nav-item.active{border-left:none}.sidebar.collapsed .nav-icon{width:auto;margin:0}.sidebar.collapsed .sidebar-header{padding:12px 8px;justify-content:center}.sidebar.collapsed .nav-section{padding:0 4px}.sidebar.collapsed .sidebar-footer{padding:8px}.sidebar.collapsed .sidebar-footer .user-info{justify-content:center;padding:4px}.sidebar.collapsed .sidebar-import-progress{padding:6px}.sidebar-header{padding:16px 16px 20px;display:flex;flex-direction:row;justify-content:flex-start;align-items:baseline;gap:8px}.sidebar-header .logo{font-size:15px;font-weight:700;letter-spacing:-.4px;color:#dcdce4}.sidebar-header .version{font-size:10px;color:#6c6c84}.sidebar-toggle{background:rgba(0,0,0,0);border:none;color:#6c6c84;padding:8px;font-size:18px;width:100%;text-align:center}.sidebar-toggle:hover{color:#dcdce4}.sidebar-spacer{flex:1}.sidebar-footer{padding:12px;border-top:1px solid rgba(255,255,255,.06);overflow:visible;min-width:0}.nav-section{padding:0 8px;margin-bottom:2px}.nav-label{padding:8px 8px 4px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#6c6c84}.nav-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:6px 8px;border-radius:3px;cursor:pointer;color:#a0a0b8;font-size:13px;font-weight:450;transition:color .1s,background .1s;border:none;background:none;width:100%;text-align:left;border-left:2px solid rgba(0,0,0,0);margin-left:0}.nav-item:hover{color:#dcdce4;background:rgba(255,255,255,.03)}.nav-item.active{color:#9698f7;border-left-color:#7c7ef5;background:rgba(124,126,245,.15)}.nav-item-text{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.sidebar:not(.collapsed) .nav-item-text{overflow:visible}.nav-icon{width:18px;text-align:center;font-size:14px;opacity:.7}.nav-badge{margin-left:auto;font-size:10px;font-weight:600;color:#6c6c84;background:#26263a;padding:1px 6px;border-radius:12px;min-width:20px;text-align:center;font-variant-numeric:tabular-nums}.status-indicator{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:6px;font-size:11px;font-weight:500;min-width:0;overflow:visible}.sidebar:not(.collapsed) .status-indicator{justify-content:flex-start}.status-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}.status-dot.connected{background:#3ec97a}.status-dot.disconnected{background:#e45858}.status-dot.checking{background:#d4a037;animation:pulse 1.5s infinite}.status-text{color:#6c6c84;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.sidebar:not(.collapsed) .status-text{overflow:visible}.main{flex:1;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;overflow:hidden;min-width:0}.header{height:48px;min-height:48px;border-bottom:1px solid rgba(255,255,255,.06);display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:0 20px;background:#18181f}.page-title{font-size:14px;font-weight:600;color:#dcdce4}.header-spacer{flex:1}.content{flex:1;overflow-y:auto;padding:20px;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.06) rgba(0,0,0,0)}.sidebar-import-progress{padding:10px 12px;background:#1f1f28;border-top:1px solid rgba(255,255,255,.06);font-size:11px}.sidebar-import-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;margin-bottom:4px;color:#a0a0b8}.sidebar-import-file{color:#6c6c84;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px}.sidebar-import-progress .progress-bar{height:3px}.user-info{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:12px;overflow:hidden;min-width:0}.user-name{font-weight:500;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:90px;flex-shrink:1}.role-badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase}.role-badge.role-admin{background:rgba(139,92,246,.1);color:#9d8be0}.role-badge.role-editor{background:rgba(34,160,80,.1);color:#5cb97a}.role-badge.role-viewer{background:rgba(59,120,200,.1);color:#6ca0d4}.btn{padding:5px 12px;border-radius:3px;border:none;cursor:pointer;font-size:12px;font-weight:500;transition:all .1s;display:inline-flex;align-items:center;gap:5px;white-space:nowrap;line-height:1.5}.btn-primary{background:#7c7ef5;color:#fff}.btn-primary:hover{background:#8b8df7}.btn-secondary{background:#26263a;color:#dcdce4;border:1px solid rgba(255,255,255,.09)}.btn-secondary:hover{border-color:rgba(255,255,255,.14);background:rgba(255,255,255,.06)}.btn-danger{background:rgba(0,0,0,0);color:#e45858;border:1px solid rgba(228,88,88,.25)}.btn-danger:hover{background:rgba(228,88,88,.08)}.btn-ghost{background:rgba(0,0,0,0);border:none;color:#a0a0b8;padding:5px 8px}.btn-ghost:hover{color:#dcdce4;background:rgba(255,255,255,.04)}.btn-sm{padding:3px 8px;font-size:11px}.btn-icon{padding:4px;border-radius:3px;background:rgba(0,0,0,0);border:none;color:#6c6c84;cursor:pointer;transition:color .1s;font-size:13px}.btn-icon:hover{color:#dcdce4}.btn:disabled,.btn[disabled]{opacity:.4;cursor:not-allowed;pointer-events:none}.btn.btn-disabled-hint:disabled{opacity:.6;border-style:dashed;pointer-events:auto;cursor:help}.card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px}.card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.card-title{font-size:14px;font-weight:600}.data-table{width:100%;border-collapse:collapse;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden}.data-table thead th{padding:8px 14px;text-align:left;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#6c6c84;border-bottom:1px solid rgba(255,255,255,.09);background:#26263a}.data-table tbody td{padding:8px 14px;font-size:13px;border-bottom:1px solid rgba(255,255,255,.06);max-width:300px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.data-table tbody tr{cursor:pointer;transition:background .08s}.data-table tbody tr:hover{background:rgba(255,255,255,.02)}.data-table tbody tr.row-selected{background:rgba(99,102,241,.12)}.data-table tbody tr:last-child td{border-bottom:none}.sortable-header{cursor:pointer;user-select:none;transition:color .1s}.sortable-header:hover{color:#9698f7}input[type=text],textarea,select{padding:6px 10px;border-radius:3px;border:1px solid rgba(255,255,255,.09);background:#111118;color:#dcdce4;font-size:13px;outline:none;transition:border-color .15s;font-family:inherit}input[type=text]::placeholder,textarea::placeholder,select::placeholder{color:#6c6c84}input[type=text]:focus,textarea:focus,select:focus{border-color:#7c7ef5}input[type=text][type=number],textarea[type=number],select[type=number]{width:80px;padding:6px 8px;-moz-appearance:textfield}input[type=text][type=number]::-webkit-outer-spin-button,input[type=text][type=number]::-webkit-inner-spin-button,textarea[type=number]::-webkit-outer-spin-button,textarea[type=number]::-webkit-inner-spin-button,select[type=number]::-webkit-outer-spin-button,select[type=number]::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}textarea{min-height:64px;resize:vertical}select{appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%236c6c84' d='M5 7L1 3h8z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 8px center;padding-right:26px;min-width:100px}.form-group{margin-bottom:12px}.form-label{display:block;font-size:11px;font-weight:600;color:#a0a0b8;margin-bottom:4px;text-transform:uppercase;letter-spacing:.03em}.form-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:flex-end;gap:8px}.form-row input[type=text]{flex:1}.form-label-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:4px}.form-label-row .form-label{margin-bottom:0}input[type=checkbox]{appearance:none;-webkit-appearance:none;width:16px;height:16px;border:1px solid rgba(255,255,255,.14);border-radius:3px;background:#1f1f28;cursor:pointer;position:relative;flex-shrink:0;transition:all .15s ease}input[type=checkbox]:hover{border-color:#7c7ef5;background:#26263a}input[type=checkbox]:checked{background:#7c7ef5;border-color:#7c7ef5}input[type=checkbox]:checked::after{content:"";position:absolute;left:5px;top:2px;width:4px;height:8px;border:solid #111118;border-width:0 2px 2px 0;transform:rotate(45deg)}input[type=checkbox]:focus-visible:focus-visible{outline:2px solid #7c7ef5;outline-offset:2px}.checkbox-label{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:#a0a0b8;user-select:none}.checkbox-label:hover{color:#dcdce4}.checkbox-label input[type=checkbox]{margin:0}.toggle{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:#dcdce4}.toggle.disabled{opacity:.4;cursor:not-allowed}.toggle-track{width:32px;height:18px;border-radius:9px;background:#26263a;border:1px solid rgba(255,255,255,.09);position:relative;transition:background .15s;flex-shrink:0}.toggle-track.active{background:#7c7ef5;border-color:#7c7ef5}.toggle-track.active .toggle-thumb{transform:translateX(14px)}.toggle-thumb{width:14px;height:14px;border-radius:50%;background:#dcdce4;position:absolute;top:1px;left:1px;transition:transform .15s}.filter-bar{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:12px;padding:12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:5px;margin-bottom:12px}.filter-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px}.filter-label{font-size:11px;font-weight:500;color:#6c6c84;text-transform:uppercase;letter-spacing:.5px;margin-right:4px}.filter-chip{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:14px;cursor:pointer;font-size:11px;color:#a0a0b8;transition:all .15s ease;user-select:none}.filter-chip:hover{background:#26263a;border-color:rgba(255,255,255,.14);color:#dcdce4}.filter-chip.active{background:rgba(124,126,245,.15);border-color:#7c7ef5;color:#9698f7}.filter-chip input[type=checkbox]{width:12px;height:12px;margin:0}.filter-chip input[type=checkbox]:checked::after{left:3px;top:1px;width:3px;height:6px}.filter-group{display:flex;align-items:center;gap:6px}.filter-group label{display:flex;align-items:center;gap:3px;cursor:pointer;color:#a0a0b8;font-size:11px;white-space:nowrap}.filter-group label:hover{color:#dcdce4}.filter-separator{width:1px;height:20px;background:rgba(255,255,255,.09);flex-shrink:0}.view-toggle{display:flex;border:1px solid rgba(255,255,255,.09);border-radius:3px;overflow:hidden}.view-btn{padding:4px 10px;background:#1f1f28;border:none;color:#6c6c84;cursor:pointer;font-size:18px;line-height:1;transition:background .1s,color .1s}.view-btn:first-child{border-right:1px solid rgba(255,255,255,.09)}.view-btn:hover{color:#dcdce4;background:#26263a}.view-btn.active{background:rgba(124,126,245,.15);color:#9698f7}.breadcrumb{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px;padding:10px 16px;font-size:.85rem;color:#6c6c84}.breadcrumb-sep{color:#6c6c84;opacity:.5}.breadcrumb-link{color:#9698f7;text-decoration:none;cursor:pointer}.breadcrumb-link:hover{text-decoration:underline}.breadcrumb-current{color:#dcdce4;font-weight:500}.progress-bar{width:100%;height:8px;background:#26263a;border-radius:4px;overflow:hidden;margin-bottom:6px}.progress-fill{height:100%;background:#7c7ef5;border-radius:4px;transition:width .3s ease}.progress-fill.indeterminate{width:30%;animation:indeterminate 1.5s ease-in-out infinite}.loading-overlay{display:flex;align-items:center;justify-content:center;padding:48px 16px;color:#6c6c84;font-size:13px;gap:10px}.spinner{width:18px;height:18px;border:2px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.spinner-small{width:14px;height:14px;border:2px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.spinner-tiny{width:10px;height:10px;border:1.5px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:100;animation:fade-in .1s ease-out}.modal{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:20px;min-width:360px;max-width:480px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.modal.wide{max-width:600px;max-height:70vh;overflow-y:auto}.modal-title{font-size:15px;font-weight:600;margin-bottom:6px}.modal-body{font-size:12px;color:#a0a0b8;margin-bottom:16px;line-height:1.5}.modal-actions{display:flex;flex-direction:row;justify-content:flex-end;align-items:center;gap:6px}.tooltip-trigger{display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border-radius:50%;background:#26263a;color:#6c6c84;font-size:9px;font-weight:700;cursor:help;position:relative;flex-shrink:0;margin-left:4px}.tooltip-trigger:hover{background:rgba(124,126,245,.15);color:#9698f7}.tooltip-trigger:hover .tooltip-text{display:block}.tooltip-text{display:none;position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);padding:6px 10px;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#dcdce4;font-size:11px;font-weight:400;line-height:1.4;white-space:normal;width:220px;text-transform:none;letter-spacing:normal;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:100;pointer-events:none}.media-player{position:relative;background:#111118;border-radius:5px;overflow:hidden}.media-player:focus{outline:none}.media-player-audio .player-artwork{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:8px;padding:24px 16px 8px}.player-artwork img{max-width:200px;max-height:200px;border-radius:5px;object-fit:cover}.player-artwork-placeholder{width:120px;height:120px;display:flex;align-items:center;justify-content:center;background:#1f1f28;border-radius:5px;font-size:48px;opacity:.3}.player-title{font-size:13px;font-weight:500;color:#dcdce4;text-align:center}.player-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 14px;background:#1f1f28}.media-player-video .player-controls{position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,.7);opacity:0;transition:opacity .2s}.media-player-video:hover .player-controls{opacity:1}.play-btn,.mute-btn,.fullscreen-btn{background:none;border:none;color:#dcdce4;cursor:pointer;font-size:18px;padding:4px;line-height:1;transition:color .1s}.play-btn:hover,.mute-btn:hover,.fullscreen-btn:hover{color:#9698f7}.player-time{font-size:11px;color:#6c6c84;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;min-width:36px;text-align:center;user-select:none}.seek-bar{flex:1;-webkit-appearance:none;appearance:none;height:4px;border-radius:4px;background:#26263a;outline:none;cursor:pointer}.seek-bar::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:12px;height:12px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none}.seek-bar::-moz-range-thumb{width:12px;height:12px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none}.volume-slider{width:70px;-webkit-appearance:none;appearance:none;height:4px;border-radius:4px;background:#26263a;outline:none;cursor:pointer}.volume-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:10px;height:10px;border-radius:50%;background:#a0a0b8;cursor:pointer;border:none}.volume-slider::-moz-range-thumb{width:10px;height:10px;border-radius:50%;background:#a0a0b8;cursor:pointer;border:none}.image-viewer-overlay{position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:150;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;animation:fade-in .15s ease-out}.image-viewer-overlay:focus{outline:none}.image-viewer-toolbar{display:flex;justify-content:space-between;align-items:center;padding:10px 16px;background:rgba(0,0,0,.5);border-bottom:1px solid rgba(255,255,255,.08);z-index:2;user-select:none}.image-viewer-toolbar-left,.image-viewer-toolbar-center,.image-viewer-toolbar-right{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px}.iv-btn{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);color:#dcdce4;border-radius:3px;padding:4px 10px;font-size:12px;cursor:pointer;transition:background .1s}.iv-btn:hover{background:rgba(255,255,255,.12)}.iv-btn.iv-close{color:#e45858;font-weight:600}.iv-zoom-label{font-size:11px;color:#a0a0b8;min-width:40px;text-align:center;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace}.image-viewer-canvas{flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative}.image-viewer-canvas img{max-width:100%;max-height:100%;object-fit:contain;user-select:none;-webkit-user-drag:none}.pdf-viewer{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;height:100%;min-height:500px;background:#111118;border-radius:5px;overflow:hidden}.pdf-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:10px 12px;background:#18181f;border-bottom:1px solid rgba(255,255,255,.09)}.pdf-toolbar-group{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px}.pdf-toolbar-btn{display:flex;align-items:center;justify-content:center;width:28px;height:28px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#a0a0b8;font-size:14px;cursor:pointer;transition:all .15s}.pdf-toolbar-btn:hover:not(:disabled){background:#26263a;color:#dcdce4}.pdf-toolbar-btn:disabled{opacity:.4;cursor:not-allowed}.pdf-zoom-label{min-width:45px;text-align:center;font-size:12px;color:#a0a0b8}.pdf-container{flex:1;position:relative;overflow:hidden;background:#1f1f28}.pdf-object{width:100%;height:100%;border:none}.pdf-loading,.pdf-error{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:12px;background:#18181f;color:#a0a0b8}.pdf-error{padding:12px;text-align:center}.pdf-fallback{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:16px;padding:48px 12px;text-align:center;color:#6c6c84}.markdown-viewer{padding:16px;text-align:left;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:12px}.markdown-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:8px;background:#1f1f28;border-radius:5px;border:1px solid rgba(255,255,255,.09)}.toolbar-btn{padding:6px 12px;border:1px solid rgba(255,255,255,.09);border-radius:3px;background:#18181f;color:#a0a0b8;font-size:13px;font-weight:500;cursor:pointer;transition:all .15s}.toolbar-btn:hover{background:#26263a;border-color:rgba(255,255,255,.14)}.toolbar-btn.active{background:#7c7ef5;color:#fff;border-color:#7c7ef5}.markdown-source{max-width:100%;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px;overflow-x:auto;font-family:"Menlo","Monaco","Courier New",monospace;font-size:13px;line-height:1.7;color:#dcdce4;white-space:pre-wrap;word-wrap:break-word}.markdown-source code{font-family:inherit;background:none;padding:0;border:none}.markdown-content{max-width:800px;color:#dcdce4;line-height:1.7;font-size:14px;text-align:left}.markdown-content h1{font-size:1.8em;font-weight:700;margin:1em 0 .5em;border-bottom:1px solid rgba(255,255,255,.06);padding-bottom:.3em}.markdown-content h2{font-size:1.5em;font-weight:600;margin:.8em 0 .4em;border-bottom:1px solid rgba(255,255,255,.06);padding-bottom:.2em}.markdown-content h3{font-size:1.25em;font-weight:600;margin:.6em 0 .3em}.markdown-content h4{font-size:1.1em;font-weight:600;margin:.5em 0 .25em}.markdown-content h5,.markdown-content h6{font-size:1em;font-weight:600;margin:.4em 0 .2em;color:#a0a0b8}.markdown-content p{margin:0 0 1em}.markdown-content a{color:#7c7ef5;text-decoration:none}.markdown-content a:hover{text-decoration:underline}.markdown-content pre{background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;padding:12px 16px;overflow-x:auto;margin:0 0 1em;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px;line-height:1.5}.markdown-content code{background:#26263a;padding:1px 5px;border-radius:3px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:.9em}.markdown-content pre code{background:none;padding:0}.markdown-content blockquote{border-left:3px solid #7c7ef5;padding:4px 16px;margin:0 0 1em;color:#a0a0b8;background:rgba(124,126,245,.04)}.markdown-content table{width:100%;border-collapse:collapse;margin:0 0 1em}.markdown-content th,.markdown-content td{padding:6px 12px;border:1px solid rgba(255,255,255,.09);font-size:13px}.markdown-content th{background:#26263a;font-weight:600;text-align:left}.markdown-content tr:nth-child(even){background:#1f1f28}.markdown-content ul,.markdown-content ol{margin:0 0 1em;padding-left:16px}.markdown-content ul{list-style:disc}.markdown-content ol{list-style:decimal}.markdown-content li{padding:2px 0;font-size:14px;color:#dcdce4}.markdown-content hr{border:none;border-top:1px solid rgba(255,255,255,.09);margin:1.5em 0}.markdown-content img{max-width:100%;border-radius:5px}.markdown-content .footnote-definition{font-size:.85em;color:#a0a0b8;margin-top:.5em;padding-left:1.5em}.markdown-content .footnote-definition sup{color:#7c7ef5;margin-right:4px}.markdown-content sup a{color:#7c7ef5;text-decoration:none;font-size:.8em}.wikilink{color:#9698f7;text-decoration:none;border-bottom:1px dashed #7c7ef5;cursor:pointer;transition:border-color .1s,color .1s}.wikilink:hover{color:#7c7ef5;border-bottom-style:solid}.wikilink-embed{display:inline-block;padding:2px 8px;background:rgba(139,92,246,.08);border:1px dashed rgba(139,92,246,.3);border-radius:3px;color:#9d8be0;font-size:12px;cursor:default}.media-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr));gap:12px}.media-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden;cursor:pointer;transition:border-color .12s,box-shadow .12s;position:relative}.media-card:hover{border-color:rgba(255,255,255,.14);box-shadow:0 1px 3px rgba(0,0,0,.3)}.media-card.selected{border-color:#7c7ef5;box-shadow:0 0 0 1px #7c7ef5}.card-checkbox{position:absolute;top:6px;left:6px;z-index:2;opacity:0;transition:opacity .1s}.card-checkbox input[type=checkbox]{width:16px;height:16px;cursor:pointer;filter:drop-shadow(0 1px 2px rgba(0,0,0,.5))}.media-card:hover .card-checkbox,.media-card.selected .card-checkbox{opacity:1}.card-thumbnail{width:100%;aspect-ratio:1;background:#111118;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative}.card-thumbnail img,.card-thumbnail .card-thumb-img{width:100%;height:100%;object-fit:cover;position:absolute;top:0;left:0;z-index:1}.card-type-icon{font-size:32px;opacity:.4;display:flex;align-items:center;justify-content:center;width:100%;height:100%;position:absolute;top:0;left:0;z-index:0}.card-info{padding:8px 10px}.card-name{font-size:12px;font-weight:500;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px}.card-title,.card-artist{font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.3}.card-meta{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:10px}.card-size{color:#6c6c84;font-size:10px}.table-thumb-cell{width:36px;padding:4px 6px !important;position:relative}.table-thumb{width:28px;height:28px;object-fit:cover;border-radius:3px;display:block}.table-thumb-overlay{position:absolute;top:4px;left:6px;z-index:1}.table-type-icon{display:flex;align-items:center;justify-content:center;width:28px;height:28px;font-size:14px;opacity:.5;border-radius:3px;background:#111118;z-index:0}.type-badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em}.type-badge.type-audio{background:rgba(139,92,246,.1);color:#9d8be0}.type-badge.type-video{background:rgba(200,72,130,.1);color:#d07eaa}.type-badge.type-image{background:rgba(34,160,80,.1);color:#5cb97a}.type-badge.type-document{background:rgba(59,120,200,.1);color:#6ca0d4}.type-badge.type-text{background:rgba(200,160,36,.1);color:#c4a840}.type-badge.type-other{background:rgba(128,128,160,.08);color:#6c6c84}.tag-list{display:flex;flex-wrap:wrap;gap:4px}.tag-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 10px;background:rgba(124,126,245,.15);color:#9698f7;border-radius:12px;font-size:11px;font-weight:500}.tag-badge.selected{background:#7c7ef5;color:#fff;cursor:pointer}.tag-badge:not(.selected){cursor:pointer}.tag-badge .tag-remove{cursor:pointer;opacity:.4;font-size:13px;line-height:1;transition:opacity .1s}.tag-badge .tag-remove:hover{opacity:1}.tag-group{margin-bottom:6px}.tag-children{margin-left:16px;margin-top:4px;display:flex;flex-wrap:wrap;gap:4px}.tag-confirm-delete{display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#a0a0b8}.tag-confirm-yes{cursor:pointer;color:#e45858;font-weight:600}.tag-confirm-yes:hover{text-decoration:underline}.tag-confirm-no{cursor:pointer;color:#6c6c84;font-weight:500}.tag-confirm-no:hover{text-decoration:underline}.detail-actions{display:flex;gap:6px;margin-bottom:16px}.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.detail-field{padding:10px 12px;background:#111118;border-radius:3px;border:1px solid rgba(255,255,255,.06)}.detail-field.full-width{grid-column:1/-1}.detail-field input[type=text],.detail-field textarea,.detail-field select{width:100%;margin-top:4px}.detail-field textarea{min-height:64px;resize:vertical}.detail-label{font-size:10px;font-weight:600;color:#6c6c84;text-transform:uppercase;letter-spacing:.04em;margin-bottom:2px}.detail-value{font-size:13px;color:#dcdce4;word-break:break-all}.detail-value.mono{font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px;color:#a0a0b8}.detail-preview{margin-bottom:16px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:5px;overflow:hidden;text-align:center}.detail-preview:has(.markdown-viewer){max-height:none;overflow-y:auto;text-align:left}.detail-preview:not(:has(.markdown-viewer)){max-height:450px}.detail-preview img{max-width:100%;max-height:400px;object-fit:contain;display:block;margin:0 auto}.detail-preview audio{width:100%;padding:16px}.detail-preview video{max-width:100%;max-height:400px;display:block;margin:0 auto}.detail-no-preview{padding:16px 16px;text-align:center;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px}.frontmatter-card{max-width:800px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:12px 16px;margin-bottom:16px}.frontmatter-fields{display:grid;grid-template-columns:auto 1fr;gap:4px 12px;margin:0}.frontmatter-fields dt{font-weight:600;font-size:12px;color:#a0a0b8;text-transform:capitalize}.frontmatter-fields dd{font-size:13px;color:#dcdce4;margin:0}.empty-state{text-align:center;padding:48px 12px;color:#6c6c84}.empty-state .empty-icon{font-size:32px;margin-bottom:12px;opacity:.3}.empty-title{font-size:15px;font-weight:600;color:#a0a0b8;margin-bottom:4px}.empty-subtitle{font-size:12px;max-width:320px;margin:0 auto;line-height:1.5}.toast-container{position:fixed;bottom:16px;right:16px;z-index:300;display:flex;flex-direction:column-reverse;gap:6px;align-items:flex-end}.toast-container .toast{position:static;transform:none}.toast{position:fixed;bottom:16px;right:16px;padding:10px 16px;border-radius:5px;background:#26263a;border:1px solid rgba(255,255,255,.09);color:#dcdce4;font-size:12px;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:300;animation:slide-up .15s ease-out;max-width:420px}.toast.success{border-left:3px solid #3ec97a}.toast.error{border-left:3px solid #e45858}.offline-banner,.error-banner{background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);border-radius:3px;padding:10px 12px;margin-bottom:12px;font-size:12px;color:#d47070;display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px}.offline-banner .offline-icon,.offline-banner .error-icon,.error-banner .offline-icon,.error-banner .error-icon{font-size:14px;flex-shrink:0}.error-banner{padding:10px 14px}.readonly-banner{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 12px;background:rgba(212,160,55,.06);border:1px solid rgba(212,160,55,.15);border-radius:3px;margin-bottom:16px;font-size:12px;color:#d4a037}.batch-actions{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:8px 10px;background:rgba(124,126,245,.15);border:1px solid rgba(124,126,245,.2);border-radius:3px;margin-bottom:12px;font-size:12px;font-weight:500;color:#9698f7}.select-all-banner{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:8px;padding:10px 16px;background:rgba(99,102,241,.08);border-radius:6px;margin-bottom:8px;font-size:.85rem;color:#a0a0b8}.select-all-banner button{background:none;border:none;color:#7c7ef5;cursor:pointer;font-weight:600;text-decoration:underline;font-size:.85rem;padding:0}.select-all-banner button:hover{color:#dcdce4}.import-status-panel{background:#1f1f28;border:1px solid #7c7ef5;border-radius:5px;padding:12px 16px;margin-bottom:16px}.import-status-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:8px;font-size:13px;color:#dcdce4}.import-current-file{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:6px;font-size:12px;overflow:hidden}.import-file-label{color:#6c6c84;flex-shrink:0}.import-file-name{color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:monospace;font-size:11px}.import-queue-indicator{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:8px;font-size:11px}.import-queue-badge{display:flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 6px;background:rgba(124,126,245,.15);color:#9698f7;border-radius:9px;font-weight:600;font-size:10px}.import-queue-text{color:#6c6c84}.import-tabs{display:flex;gap:0;margin-bottom:16px;border-bottom:1px solid rgba(255,255,255,.09)}.import-tab{padding:10px 16px;background:none;border:none;border-bottom:2px solid rgba(0,0,0,0);color:#6c6c84;font-size:12px;font-weight:500;cursor:pointer;transition:color .1s,border-color .1s}.import-tab:hover{color:#dcdce4}.import-tab.active{color:#9698f7;border-bottom-color:#7c7ef5}.queue-panel{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;border-left:1px solid rgba(255,255,255,.09);background:#18181f;min-width:280px;max-width:320px}.queue-header{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid rgba(255,255,255,.06)}.queue-header h3{margin:0;font-size:.9rem;color:#dcdce4}.queue-controls{display:flex;gap:2px}.queue-list{overflow-y:auto;flex:1}.queue-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;padding:8px 16px;cursor:pointer;border-bottom:1px solid rgba(255,255,255,.06);transition:background .15s}.queue-item:hover{background:#1f1f28}.queue-item:hover .queue-item-remove{opacity:1}.queue-item-active{background:rgba(124,126,245,.15);border-left:3px solid #7c7ef5}.queue-item-info{flex:1;min-width:0}.queue-item-title{display:block;font-size:.85rem;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.queue-item-artist{display:block;font-size:.75rem;color:#6c6c84}.queue-item-remove{opacity:0;transition:opacity .15s}.queue-empty{padding:16px 16px;text-align:center;color:#6c6c84;font-size:.85rem}.statistics-page{padding:20px}.stats-overview,.stats-grid{display:grid;grid-template-columns:repeat(3, 1fr);gap:16px;margin-bottom:24px}@media (max-width: 768px){.stats-overview,.stats-grid{grid-template-columns:1fr}}.stat-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:20px;display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:16px}.stat-card.stat-primary{border-left:3px solid #7c7ef5}.stat-card.stat-success{border-left:3px solid #3ec97a}.stat-card.stat-info{border-left:3px solid #6ca0d4}.stat-card.stat-warning{border-left:3px solid #d4a037}.stat-card.stat-purple{border-left:3px solid #9d8be0}.stat-card.stat-danger{border-left:3px solid #e45858}.stat-icon{flex-shrink:0;color:#6c6c84}.stat-content{flex:1}.stat-value{font-size:28px;font-weight:700;color:#dcdce4;line-height:1.2;font-variant-numeric:tabular-nums}.stat-label{font-size:12px;color:#6c6c84;margin-top:4px;font-weight:500}.stats-section{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px;margin-bottom:20px}.section-title{font-size:16px;font-weight:600;color:#dcdce4;margin-bottom:20px}.section-title.small{font-size:14px;margin-bottom:12px;padding-bottom:6px;border-bottom:1px solid rgba(255,255,255,.06)}.chart-bars{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:16px}.bar-item{display:grid;grid-template-columns:120px 1fr 80px;align-items:center;gap:16px}.bar-label{font-size:13px;font-weight:500;color:#a0a0b8;text-align:right}.bar-track{height:28px;background:#26263a;border-radius:3px;overflow:hidden;position:relative}.bar-fill{height:100%;transition:width .6s cubic-bezier(.4, 0, .2, 1);border-radius:3px}.bar-fill.bar-primary{background:linear-gradient(90deg, #7c7ef5 0%, #7c7ef3 100%)}.bar-fill.bar-success{background:linear-gradient(90deg, #3ec97a 0%, #66bb6a 100%)}.bar-value{font-size:13px;font-weight:600;color:#a0a0b8;text-align:right;font-variant-numeric:tabular-nums}.settings-section{margin-bottom:16px}.settings-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:20px;margin-bottom:16px}.settings-card.danger-card{border:1px solid rgba(228,88,88,.25)}.settings-card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid rgba(255,255,255,.06)}.settings-card-title{font-size:14px;font-weight:600}.settings-card-body{padding-top:2px}.settings-field{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid rgba(255,255,255,.06)}.settings-field:last-child{border-bottom:none}.settings-field select{min-width:120px}.config-path{font-size:11px;color:#6c6c84;margin-bottom:12px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;padding:6px 10px;background:#111118;border-radius:3px;border:1px solid rgba(255,255,255,.06)}.config-status{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600}.config-status.writable{background:rgba(62,201,122,.1);color:#3ec97a}.config-status.readonly{background:rgba(228,88,88,.1);color:#e45858}.root-list{list-style:none}.root-item{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:3px;margin-bottom:4px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px;color:#a0a0b8}.info-row{display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid rgba(255,255,255,.06);font-size:13px}.info-row:last-child{border-bottom:none}.info-label{color:#a0a0b8;font-weight:500}.info-value{color:#dcdce4}.tasks-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(400px, 1fr));gap:16px;padding:12px}.task-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden;transition:all .2s}.task-card:hover{border-color:rgba(255,255,255,.14);box-shadow:0 4px 12px rgba(0,0,0,.08);transform:translateY(-2px)}.task-card-enabled{border-left:3px solid #3ec97a}.task-card-disabled{border-left:3px solid #4a4a5e;opacity:.7}.task-card-header{display:flex;justify-content:space-between;align-items:center;align-items:flex-start;padding:16px;border-bottom:1px solid rgba(255,255,255,.06)}.task-header-left{flex:1;min-width:0}.task-name{font-size:16px;font-weight:600;color:#dcdce4;margin-bottom:2px}.task-schedule{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:12px;color:#6c6c84;font-family:"Menlo","Monaco","Courier New",monospace}.schedule-icon{font-size:14px}.task-status-badge{flex-shrink:0}.status-badge{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:2px 10px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.03em}.status-badge.status-enabled{background:rgba(76,175,80,.12);color:#3ec97a}.status-badge.status-enabled .status-dot{animation:pulse 1.5s infinite}.status-badge.status-disabled{background:#26263a;color:#6c6c84}.status-badge .status-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;background:currentColor}.task-info-grid{display:grid;grid-template-columns:repeat(auto-fit, minmax(120px, 1fr));gap:12px;padding:16px}.task-info-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:flex-start;gap:10px}.task-info-icon{font-size:18px;color:#6c6c84;flex-shrink:0}.task-info-content{flex:1;min-width:0}.task-info-label{font-size:10px;color:#6c6c84;font-weight:600;text-transform:uppercase;letter-spacing:.03em;margin-bottom:2px}.task-info-value{font-size:12px;color:#a0a0b8;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.task-card-actions{display:flex;gap:8px;padding:10px 16px;background:#18181f;border-top:1px solid rgba(255,255,255,.06)}.task-card-actions button{flex:1}.db-actions{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:16px;padding:10px}.db-action-row{display:flex;flex-direction:row;justify-content:space-between;align-items:center;gap:16px;padding:10px;border-radius:6px;background:rgba(0,0,0,.06)}.db-action-info{flex:1}.db-action-info h4{font-size:.95rem;font-weight:600;color:#dcdce4;margin-bottom:2px}.db-action-confirm{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;flex-shrink:0}.library-toolbar{display:flex;justify-content:space-between;align-items:center;padding:8px 0;margin-bottom:12px;gap:12px;flex-wrap:wrap}.toolbar-left{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:10px}.toolbar-right{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:10px}.sort-control select,.page-size-control select{padding:4px 24px 4px 8px;font-size:11px;background:#1f1f28}.page-size-control{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px}.library-stats{display:flex;justify-content:space-between;align-items:center;padding:2px 0 6px 0;font-size:11px}.type-filter-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:4px 0;margin-bottom:6px;flex-wrap:wrap}.pagination{display:flex;align-items:center;justify-content:center;gap:4px;margin-top:16px;padding:8px 0}.page-btn{min-width:28px;text-align:center;font-variant-numeric:tabular-nums}.page-ellipsis{color:#6c6c84;padding:0 4px;font-size:12px;user-select:none}.audit-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:12px}.filter-select{padding:4px 24px 4px 8px;font-size:11px;background:#1f1f28}.action-danger{background:rgba(228,88,88,.1);color:#d47070}.action-updated{background:rgba(59,120,200,.1);color:#6ca0d4}.action-collection{background:rgba(34,160,80,.1);color:#5cb97a}.action-collection-remove{background:rgba(212,160,55,.1);color:#c4a840}.action-opened{background:rgba(139,92,246,.1);color:#9d8be0}.action-scanned{background:rgba(128,128,160,.08);color:#6c6c84}.clickable{cursor:pointer;color:#9698f7}.clickable:hover{text-decoration:underline}.clickable-row{cursor:pointer}.clickable-row:hover{background:rgba(255,255,255,.03)}.duplicates-view{padding:0}.duplicates-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.duplicates-header h3{margin:0}.duplicates-summary{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px}.duplicate-group{border:1px solid rgba(255,255,255,.09);border-radius:5px;margin-bottom:8px;overflow:hidden}.duplicate-group-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;width:100%;padding:10px 14px;background:#1f1f28;border:none;cursor:pointer;text-align:left;color:#dcdce4;font-size:13px}.duplicate-group-header:hover{background:#26263a}.expand-icon{font-size:10px;width:14px;flex-shrink:0}.group-name{font-weight:600;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.group-badge{background:#7c7ef5;color:#fff;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;flex-shrink:0}.group-size{flex-shrink:0;font-size:12px}.group-hash{font-size:11px;flex-shrink:0}.duplicate-items{border-top:1px solid rgba(255,255,255,.09)}.duplicate-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.06)}.duplicate-item:last-child{border-bottom:none}.duplicate-item-keep{background:rgba(76,175,80,.06)}.dup-thumb{width:48px;height:48px;flex-shrink:0;border-radius:3px;overflow:hidden}.dup-thumb-img{width:100%;height:100%;object-fit:cover}.dup-thumb-placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#26263a;font-size:20px;color:#6c6c84}.dup-info{flex:1;min-width:0}.dup-filename{font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dup-path{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dup-meta{font-size:12px;margin-top:2px}.dup-actions{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;flex-shrink:0}.keep-badge{background:rgba(76,175,80,.12);color:#4caf50;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:600}.saved-searches-list{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:4px;max-height:300px;overflow-y:auto}.saved-search-item{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:#18181f;border-radius:3px;cursor:pointer;transition:background .15s ease}.saved-search-item:hover{background:#1f1f28}.saved-search-info{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:2px;flex:1;min-width:0}.saved-search-name{font-weight:500;color:#dcdce4}.saved-search-query{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.backlinks-panel,.outgoing-links-panel{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;margin-top:16px;overflow:hidden}.backlinks-header,.outgoing-links-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 14px;background:#26263a;cursor:pointer;user-select:none;transition:background .1s}.backlinks-header:hover,.outgoing-links-header:hover{background:rgba(255,255,255,.04)}.backlinks-toggle,.outgoing-links-toggle{font-size:10px;color:#6c6c84;width:12px;text-align:center}.backlinks-title,.outgoing-links-title{font-size:12px;font-weight:600;color:#dcdce4;flex:1}.backlinks-count,.outgoing-links-count{font-size:11px;color:#6c6c84}.backlinks-reindex-btn{display:flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0;margin-left:auto;background:rgba(0,0,0,0);border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#6c6c84;font-size:12px;cursor:pointer;transition:background .1s,color .1s,border-color .1s}.backlinks-reindex-btn:hover:not(:disabled){background:#1f1f28;color:#dcdce4;border-color:rgba(255,255,255,.14)}.backlinks-reindex-btn:disabled{opacity:.5;cursor:not-allowed}.backlinks-content,.outgoing-links-content{padding:12px;border-top:1px solid rgba(255,255,255,.06)}.backlinks-loading,.outgoing-links-loading{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:12px;color:#6c6c84;font-size:12px}.backlinks-error,.outgoing-links-error{padding:8px 12px;background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);border-radius:3px;font-size:12px;color:#e45858}.backlinks-empty,.outgoing-links-empty{padding:16px;text-align:center;color:#6c6c84;font-size:12px;font-style:italic}.backlinks-list,.outgoing-links-list{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:6px}.backlink-item,.outgoing-link-item{padding:10px 12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:3px;cursor:pointer;transition:background .1s,border-color .1s}.backlink-item:hover,.outgoing-link-item:hover{background:#18181f;border-color:rgba(255,255,255,.09)}.backlink-item.unresolved,.outgoing-link-item.unresolved{opacity:.7;border-style:dashed}.backlink-source,.outgoing-link-target{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:2px}.backlink-title,.outgoing-link-text{font-size:13px;font-weight:500;color:#dcdce4;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.backlink-type-badge,.outgoing-link-type-badge{display:inline-block;padding:1px 6px;border-radius:12px;font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.03em}.backlink-type-badge.backlink-type-wikilink,.backlink-type-badge.link-type-wikilink,.outgoing-link-type-badge.backlink-type-wikilink,.outgoing-link-type-badge.link-type-wikilink{background:rgba(124,126,245,.15);color:#9698f7}.backlink-type-badge.backlink-type-embed,.backlink-type-badge.link-type-embed,.outgoing-link-type-badge.backlink-type-embed,.outgoing-link-type-badge.link-type-embed{background:rgba(139,92,246,.1);color:#9d8be0}.backlink-type-badge.backlink-type-markdown_link,.backlink-type-badge.link-type-markdown_link,.outgoing-link-type-badge.backlink-type-markdown_link,.outgoing-link-type-badge.link-type-markdown_link{background:rgba(59,120,200,.1);color:#6ca0d4}.backlink-context{font-size:11px;color:#6c6c84;line-height:1.4;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.backlink-line{color:#a0a0b8;font-weight:500}.unresolved-badge{padding:1px 6px;border-radius:12px;font-size:9px;font-weight:600;background:rgba(212,160,55,.1);color:#d4a037}.outgoing-links-unresolved-badge{margin-left:8px;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:500;background:rgba(212,160,55,.12);color:#d4a037}.outgoing-links-global-unresolved{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;margin-top:12px;padding:10px 12px;background:rgba(212,160,55,.06);border:1px solid rgba(212,160,55,.15);border-radius:3px;font-size:11px;color:#6c6c84}.outgoing-links-global-unresolved .unresolved-icon{color:#d4a037}.backlinks-message{padding:8px 10px;margin-bottom:10px;border-radius:3px;font-size:11px}.backlinks-message.success{background:rgba(62,201,122,.08);border:1px solid rgba(62,201,122,.2);color:#3ec97a}.backlinks-message.error{background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);color:#e45858}.graph-view{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;height:100%;background:#18181f;border-radius:5px;overflow:hidden}.graph-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:16px;padding:12px 16px;background:#1f1f28;border-bottom:1px solid rgba(255,255,255,.09)}.graph-title{font-size:14px;font-weight:600;color:#dcdce4}.graph-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;font-size:12px;color:#a0a0b8}.graph-controls select{padding:4px 20px 4px 8px;font-size:11px;background:#26263a}.graph-stats{margin-left:auto;font-size:11px;color:#6c6c84}.graph-container{flex:1;position:relative;display:flex;align-items:center;justify-content:center;overflow:hidden;background:#111118}.graph-loading,.graph-error,.graph-empty{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px;padding:48px;color:#6c6c84;font-size:13px;text-align:center}.graph-svg{max-width:100%;max-height:100%;cursor:grab}.graph-svg-container{position:relative;width:100%;height:100%}.graph-zoom-controls{position:absolute;top:16px;left:16px;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;z-index:5}.zoom-btn{width:36px;height:36px;border-radius:6px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);color:#dcdce4;font-size:18px;font-weight:bold;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:all .15s;box-shadow:0 1px 3px rgba(0,0,0,.3)}.zoom-btn:hover{background:#26263a;border-color:rgba(255,255,255,.14);transform:scale(1.05)}.zoom-btn:active{transform:scale(.95)}.graph-edges line{stroke:rgba(255,255,255,.14);stroke-width:1;opacity:.6}.graph-edges line.edge-type-wikilink{stroke:#7c7ef5}.graph-edges line.edge-type-embed{stroke:#9d8be0;stroke-dasharray:4 2}.graph-nodes .graph-node{cursor:pointer}.graph-nodes .graph-node circle{fill:#4caf50;stroke:#388e3c;stroke-width:2;transition:fill .15s,stroke .15s}.graph-nodes .graph-node:hover circle{fill:#66bb6a}.graph-nodes .graph-node.selected circle{fill:#7c7ef5;stroke:#5456d6}.graph-nodes .graph-node text{fill:#a0a0b8;font-size:11px;pointer-events:none;text-anchor:middle;dominant-baseline:central;transform:translateY(16px)}.node-details-panel{position:absolute;top:16px;right:16px;width:280px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:10}.node-details-header{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.06)}.node-details-header h3{font-size:13px;font-weight:600;color:#dcdce4;margin:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.node-details-header .close-btn{background:none;border:none;color:#6c6c84;cursor:pointer;font-size:14px;padding:2px 6px;line-height:1}.node-details-header .close-btn:hover{color:#dcdce4}.node-details-content{padding:14px}.node-details-content .node-title{font-size:12px;color:#a0a0b8;margin-bottom:12px}.node-stats{display:flex;gap:16px;margin-bottom:12px}.node-stats .stat{font-size:12px;color:#6c6c84}.node-stats .stat strong{color:#dcdce4}.physics-controls-panel{position:absolute;top:16px;right:16px;width:300px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;box-shadow:0 2px 8px rgba(0,0,0,.35);padding:16px;z-index:10}.physics-controls-panel h4{font-size:13px;font-weight:600;color:#dcdce4;margin:0 0 16px 0;padding-bottom:8px;border-bottom:1px solid rgba(255,255,255,.06)}.physics-controls-panel .btn{width:100%;margin-top:8px}.control-group{margin-bottom:14px}.control-group label{display:block;font-size:11px;font-weight:500;color:#a0a0b8;margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px}.control-group input[type=range]{width:100%;height:4px;border-radius:4px;background:#26263a;outline:none;-webkit-appearance:none}.control-group input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;border-radius:50%;background:#7c7ef5;cursor:pointer;transition:transform .1s}.control-group input[type=range]::-webkit-slider-thumb:hover{transform:scale(1.15)}.control-group input[type=range]::-moz-range-thumb{width:14px;height:14px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none;transition:transform .1s}.control-group input[type=range]::-moz-range-thumb:hover{transform:scale(1.15)}.control-value{display:inline-block;margin-top:2px;font-size:11px;color:#6c6c84;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace}.theme-light{--bg-0: #f5f5f7;--bg-1: #eeeef0;--bg-2: #fff;--bg-3: #e8e8ec;--border-subtle: rgba(0,0,0,.06);--border: rgba(0,0,0,.1);--border-strong: rgba(0,0,0,.16);--text-0: #1a1a2e;--text-1: #555570;--text-2: #8888a0;--accent: #6366f1;--accent-dim: rgba(99,102,241,.1);--accent-text: #4f52e8;--shadow-sm: 0 1px 3px rgba(0,0,0,.08);--shadow: 0 2px 8px rgba(0,0,0,.1);--shadow-lg: 0 4px 20px rgba(0,0,0,.12)}.theme-light ::-webkit-scrollbar-thumb{background:rgba(0,0,0,.12)}.theme-light ::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.08)}.theme-light ::-webkit-scrollbar-track{background:rgba(0,0,0,.06)}.theme-light .graph-nodes .graph-node text{fill:#1a1a2e}.theme-light .graph-edges line{stroke:rgba(0,0,0,.12)}.theme-light .pdf-container{background:#e8e8ec}.skeleton-pulse{animation:skeleton-pulse 1.5s ease-in-out infinite;background:#26263a;border-radius:4px}.skeleton-card{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;padding:8px}.skeleton-thumb{width:100%;aspect-ratio:1;border-radius:6px}.skeleton-text{height:14px;width:80%}.skeleton-text-short{width:50%}.skeleton-row{display:flex;gap:12px;padding:10px 16px;align-items:center}.skeleton-cell{height:14px;flex:1;border-radius:4px}.skeleton-cell-icon{width:32px;height:32px;flex:none;border-radius:4px}.skeleton-cell-wide{flex:3}.loading-overlay{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px;background:rgba(0,0,0,.3);z-index:100;border-radius:8px}.loading-spinner{width:32px;height:32px;border:3px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .8s linear infinite}.loading-message{color:#a0a0b8;font-size:.9rem}.login-container{display:flex;align-items:center;justify-content:center;height:100vh;background:#111118}.login-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:24px;width:360px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.login-title{font-size:20px;font-weight:700;color:#dcdce4;text-align:center;margin-bottom:2px}.login-subtitle{font-size:13px;color:#6c6c84;text-align:center;margin-bottom:20px}.login-error{background:rgba(228,88,88,.08);border:1px solid rgba(228,88,88,.2);border-radius:3px;padding:8px 12px;margin-bottom:12px;font-size:12px;color:#e45858}.login-form input[type=text],.login-form input[type=password]{width:100%}.login-btn{width:100%;padding:8px 16px;font-size:13px;margin-top:2px}.pagination{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:2px;margin-top:16px;padding:8px 0}.page-btn{min-width:28px;text-align:center;font-variant-numeric:tabular-nums}.page-ellipsis{color:#6c6c84;padding:0 4px;font-size:12px;user-select:none}.help-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:200;animation:fade-in .1s ease-out}.help-dialog{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:16px;min-width:300px;max-width:400px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.help-dialog h3{font-size:16px;font-weight:600;margin-bottom:16px}.help-shortcuts{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;margin-bottom:16px}.shortcut-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px}.shortcut-row kbd{display:inline-block;padding:2px 8px;background:#111118;border:1px solid rgba(255,255,255,.09);border-radius:3px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px;color:#dcdce4;min-width:32px;text-align:center}.shortcut-row span{font-size:13px;color:#a0a0b8}.help-close{display:block;width:100%;padding:6px 12px;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#dcdce4;font-size:12px;cursor:pointer;text-align:center}.help-close:hover{background:rgba(255,255,255,.06)} \ No newline at end of file diff --git a/crates/pinakes-ui/assets/styles/_audit.scss b/crates/pinakes-ui/assets/styles/_audit.scss new file mode 100644 index 0000000..3688f01 --- /dev/null +++ b/crates/pinakes-ui/assets/styles/_audit.scss @@ -0,0 +1,493 @@ +@use 'variables' as *; +@use 'mixins' as *; + +// Audit log +.audit-controls { + @include flex(row, flex-start, center, $space-4); + margin-bottom: $space-6; +} + +.filter-select { + padding: $space-2 24px $space-2 $space-4; + font-size: $font-size-base; + background: $bg-2; +} + +.action-danger { + background: $error-medium; + color: $error-text; +} + +.action-updated { + background: $action-updated-bg; + color: $action-updated-text; +} + +.action-collection { + background: $action-collection-bg; + color: $action-collection-text; +} + +.action-collection-remove { + background: $action-collection-remove-bg; + color: $action-collection-remove-text; +} + +.action-opened { + background: $action-opened-bg; + color: $action-opened-text; +} + +.action-scanned { + background: $action-scanned-bg; + color: $text-2; +} + +.clickable { + cursor: pointer; + color: $accent-text; + + &:hover { + text-decoration: underline; + } +} + +.clickable-row { + cursor: pointer; + + &:hover { + background: $overlay-light; + } +} + +// Duplicates +.duplicates-view { + padding: 0; +} + +.duplicates-header { + @include flex-between; + margin-bottom: $space-8; + + h3 { + margin: 0; + } +} + +.duplicates-summary { + @include flex(row, flex-start, center, $space-6); +} + +.duplicate-group { + border: 1px solid $border; + border-radius: $radius; + margin-bottom: $space-4; + overflow: hidden; +} + +.duplicate-group-header { + @include flex(row, flex-start, center, $space-6); + width: 100%; + padding: 10px 14px; + background: $bg-2; + border: none; + cursor: pointer; + text-align: left; + color: $text-0; + font-size: $font-size-lg; + + &:hover { + background: $bg-3; + } +} + +.expand-icon { + font-size: $font-size-sm; + width: 14px; + flex-shrink: 0; +} + +.group-name { + font-weight: $font-weight-semibold; + flex: 1; + @include text-truncate; +} + +.group-badge { + background: $accent; + color: white; + padding: $space-1 $space-4; + border-radius: 10px; + font-size: $font-size-base; + font-weight: $font-weight-semibold; + flex-shrink: 0; +} + +.group-size { + flex-shrink: 0; + font-size: $font-size-md; +} + +.group-hash { + font-size: $font-size-base; + flex-shrink: 0; +} + +.duplicate-items { + border-top: 1px solid $border; +} + +.duplicate-item { + @include flex(row, flex-start, center, $space-6); + padding: 10px 14px; + border-bottom: 1px solid $border-subtle; + + &:last-child { + border-bottom: none; + } + + &-keep { + background: $green-light; + } +} + +.dup-thumb { + width: 48px; + height: 48px; + flex-shrink: 0; + border-radius: $radius-sm; + overflow: hidden; +} + +.dup-thumb-img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.dup-thumb-placeholder { + width: 100%; + height: 100%; + @include flex-center; + background: $bg-3; + font-size: 20px; + color: $text-2; +} + +.dup-info { + flex: 1; + min-width: 0; +} + +.dup-filename { + font-weight: $font-weight-semibold; + font-size: $font-size-lg; + @include text-truncate; +} + +.dup-path { + font-size: $font-size-base; + @include text-truncate; +} + +.dup-meta { + font-size: $font-size-md; + margin-top: $space-1; +} + +.dup-actions { + @include flex(row, flex-start, center, 6px); + flex-shrink: 0; +} + +.keep-badge { + background: $green-medium; + color: $green-text; + padding: $space-1 $space-5; + border-radius: 10px; + font-size: $font-size-base; + font-weight: $font-weight-semibold; +} + +// Saved searches +.saved-searches-list { + @include flex(column, flex-start, stretch, $space-2); + max-height: 300px; + overflow-y: auto; +} + +.saved-search-item { + @include flex-between; + padding: $space-4 $space-6; + background: $bg-1; + border-radius: $radius-sm; + cursor: pointer; + transition: background $transition-slow ease; + + &:hover { + background: $bg-2; + } +} + +.saved-search-info { + @include flex(column, flex-start, stretch, $space-1); + flex: 1; + min-width: 0; +} + +.saved-search-name { + font-weight: $font-weight-medium; + color: $text-0; +} + +.saved-search-query { + font-size: $font-size-base; + @include text-truncate; +} + +// Backlinks +.backlinks-panel, +.outgoing-links-panel { + background: $bg-2; + border: 1px solid $border; + border-radius: $radius; + margin-top: $space-8; + overflow: hidden; +} + +.backlinks-header, +.outgoing-links-header { + @include flex(row, flex-start, center, $space-4); + padding: 10px 14px; + background: $bg-3; + cursor: pointer; + user-select: none; + transition: background $transition-base; + + &:hover { + background: $overlay-medium; + } +} + +.backlinks-toggle, +.outgoing-links-toggle { + font-size: $font-size-sm; + color: $text-2; + width: 12px; + text-align: center; +} + +.backlinks-title, +.outgoing-links-title { + font-size: $font-size-md; + font-weight: $font-weight-semibold; + color: $text-0; + flex: 1; +} + +.backlinks-count, +.outgoing-links-count { + font-size: $font-size-base; + color: $text-2; +} + +.backlinks-reindex-btn { + @include flex-center; + width: 22px; + height: 22px; + padding: 0; + margin-left: auto; + background: transparent; + border: 1px solid $border; + border-radius: $radius-sm; + color: $text-2; + font-size: $font-size-md; + cursor: pointer; + transition: background $transition-base, color $transition-base, + border-color $transition-base; + + &:hover:not(:disabled) { + background: $bg-2; + color: $text-0; + border-color: $border-strong; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.backlinks-content, +.outgoing-links-content { + padding: $space-6; + border-top: 1px solid $border-subtle; +} + +.backlinks-loading, +.outgoing-links-loading { + @include flex(row, flex-start, center, $space-4); + padding: $space-6; + color: $text-2; + font-size: $font-size-md; +} + +.backlinks-error, +.outgoing-links-error { + padding: $space-4 $space-6; + background: $error-bg; + border: 1px solid $error-border; + border-radius: $radius-sm; + font-size: $font-size-md; + color: $error; +} + +.backlinks-empty, +.outgoing-links-empty { + padding: $space-8; + text-align: center; + color: $text-2; + font-size: $font-size-md; + font-style: italic; +} + +.backlinks-list, +.outgoing-links-list { + list-style: none; + padding: 0; + margin: 0; + @include flex(column, flex-start, stretch, 6px); +} + +.backlink-item, +.outgoing-link-item { + padding: $space-5 $space-6; + background: $bg-0; + border: 1px solid $border-subtle; + border-radius: $radius-sm; + cursor: pointer; + transition: background $transition-base, border-color $transition-base; + + &:hover { + background: $bg-1; + border-color: $border; + } + + &.unresolved { + opacity: 0.7; + border-style: dashed; + } +} + +.backlink-source, +.outgoing-link-target { + @include flex(row, flex-start, center, $space-4); + margin-bottom: $space-1; +} + +.backlink-title, +.outgoing-link-text { + font-size: $font-size-lg; + font-weight: $font-weight-medium; + color: $text-0; + flex: 1; + @include text-truncate; +} + +.backlink-type-badge, +.outgoing-link-type-badge { + display: inline-block; + padding: 1px $space-3; + border-radius: $radius-xl; + font-size: 9px; + font-weight: $font-weight-semibold; + text-transform: uppercase; + letter-spacing: $letter-spacing-wide; + + &.backlink-type-wikilink, + &.link-type-wikilink { + background: $accent-dim; + color: $accent-text; + } + + &.backlink-type-embed, + &.link-type-embed { + background: $type-audio-bg; + color: $type-audio-text; + } + + &.backlink-type-markdown_link, + &.link-type-markdown_link { + background: $type-document-bg; + color: $type-document-text; + } +} + +.backlink-context { + font-size: $font-size-base; + color: $text-2; + line-height: $line-height-normal; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.backlink-line { + color: $text-1; + font-weight: $font-weight-medium; +} + +.unresolved-badge { + padding: 1px $space-3; + border-radius: $radius-xl; + font-size: 9px; + font-weight: $font-weight-semibold; + background: $warning-light; + color: $warning; +} + +.outgoing-links-unresolved-badge { + margin-left: $space-4; + padding: $space-1 $space-4; + border-radius: 10px; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + background: $warning-medium; + color: $warning; +} + +.outgoing-links-global-unresolved { + @include flex(row, flex-start, center, 6px); + margin-top: $space-6; + padding: $space-5 $space-6; + background: $warning-bg; + border: 1px solid $warning-border; + border-radius: $radius-sm; + font-size: $font-size-base; + color: $text-2; + + .unresolved-icon { + color: $warning; + } +} + +.backlinks-message { + padding: $space-4 $space-5; + margin-bottom: 10px; + border-radius: $radius-sm; + font-size: $font-size-base; + + &.success { + background: $success-bg; + border: 1px solid $success-border; + color: $success; + } + + &.error { + background: $error-bg; + border: 1px solid $error-border; + color: $error; + } +} diff --git a/crates/pinakes-ui/assets/styles/_base.scss b/crates/pinakes-ui/assets/styles/_base.scss new file mode 100644 index 0000000..c8c2cbe --- /dev/null +++ b/crates/pinakes-ui/assets/styles/_base.scss @@ -0,0 +1,210 @@ +@use 'variables' as *; +@use 'mixins' as *; + +// Reset & base +@media (prefers-reduced-motion: reduce) { + + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; + @include scrollbar; +} + +// CSS custom properties +:root { + // Background + --bg-0: #{$bg-0}; + --bg-1: #{$bg-1}; + --bg-2: #{$bg-2}; + --bg-3: #{$bg-3}; + + // Border + --border-subtle: #{$border-subtle}; + --border: #{$border}; + --border-strong: #{$border-strong}; + + // Text + --text-0: #{$text-0}; + --text-1: #{$text-1}; + --text-2: #{$text-2}; + + // Accent + --accent: #{$accent}; + --accent-dim: #{$accent-dim}; + --accent-text: #{$accent-text}; + + // Semantic + --success: #{$success}; + --error: #{$error}; + --warning: #{$warning}; + + // Border radius + --radius-sm: #{$radius-sm}; + --radius: #{$radius}; + --radius-md: #{$radius-md}; + + // Shadows + --shadow-sm: #{$shadow-sm}; + --shadow: #{$shadow}; + --shadow-lg: #{$shadow-lg}; +} + +// Body +body { + font-family: $font-family-base; + background: var(--bg-0); + color: var(--text-0); + font-size: $font-size-lg; + line-height: $line-height-relaxed; + -webkit-font-smoothing: antialiased; + overflow: hidden; +} + +// Focus styles +:focus-visible { + @include focus-outline; +} + +// Selection +::selection { + background: $accent-dim; + color: $accent-text; +} + +// Links +a { + color: $accent-text; + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +// Code +code { + padding: 1px 5px; + border-radius: $radius-sm; + background: $bg-0; + color: $accent-text; + font-family: $font-family-mono; + font-size: $font-size-base; +} + +// Lists +ul { + list-style: none; + padding: 0; + + li { + padding: 3px 0; + font-size: $font-size-md; + color: $text-1; + } +} + +// Utility text +.text-muted { + color: $text-1; +} + +.text-sm { + font-size: $font-size-base; +} + +.mono { + font-family: $font-family-mono; + font-size: $font-size-md; +} + +// Utility layout +.flex-row { + @include flex(row, flex-start, center, $space-4); +} + +.flex-between { + @include flex-between; +} + +.mb-16 { + margin-bottom: $space-8; +} + +.mb-8 { + margin-bottom: $space-6; +} + +// Animations +@keyframes fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes slide-up { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes pulse { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.3; + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes skeleton-pulse { + 0% { + opacity: 0.6; + } + + 50% { + opacity: 0.3; + } + + 100% { + opacity: 0.6; + } +} + +@keyframes indeterminate { + 0% { + transform: translateX(-100%); + } + + 100% { + transform: translateX(400%); + } +} diff --git a/crates/pinakes-ui/assets/styles/_components.scss b/crates/pinakes-ui/assets/styles/_components.scss new file mode 100644 index 0000000..4d1bedb --- /dev/null +++ b/crates/pinakes-ui/assets/styles/_components.scss @@ -0,0 +1,1132 @@ +@use 'variables' as *; +@use 'mixins' as *; + +// Buttons +.btn { + @include button-base; + + &-primary { + @include button-variant($accent, white, null, $accent-hover); + } + + &-secondary { + @include button-variant($bg-3, $text-0, $border); + + &:hover { + border-color: $border-strong; + background: $btn-secondary-hover; + } + } + + &-danger { + @include button-variant(transparent, $error, $error-border-light); + + &:hover { + background: $btn-danger-hover; + } + } + + &-ghost { + @include button-ghost; + color: $text-1; + padding: 5px $space-4; + + &:hover { + color: $text-0; + background: $btn-ghost-hover; + } + } + + &-sm { + padding: 3px $space-4; + font-size: $font-size-base; + } + + &-icon { + padding: $space-2; + border-radius: $radius-sm; + background: transparent; + border: none; + color: $text-2; + cursor: pointer; + transition: color $transition-base; + font-size: $font-size-lg; + + &:hover { + color: $text-0; + } + } + + &:disabled, + &[disabled] { + @include disabled-state(0.4); + } + + &.btn-disabled-hint:disabled { + opacity: 0.6; + border-style: dashed; + pointer-events: auto; + cursor: help; + } +} + +// Cards +.card { + @include card; + + &-header { + @include card-header; + } + + &-title { + font-size: $font-size-xl; + font-weight: $font-weight-semibold; + } +} + +// Tables +.data-table { + width: 100%; + border-collapse: collapse; + background: $bg-2; + border: 1px solid $border; + border-radius: $radius; + overflow: hidden; + + thead th { + padding: $space-4 14px; + text-align: left; + font-size: $font-size-sm; + font-weight: $font-weight-semibold; + @include text-uppercase($letter-spacing-wider); + color: $text-2; + border-bottom: 1px solid $border; + background: $bg-3; + } + + tbody { + td { + padding: $space-4 14px; + font-size: $font-size-lg; + border-bottom: 1px solid $border-subtle; + max-width: 300px; + @include text-truncate; + } + + tr { + cursor: pointer; + transition: background $transition-fast; + + &:hover { + background: $overlay-subtle; + } + + &.row-selected { + background: $info-bg-light; + } + + &:last-child td { + border-bottom: none; + } + } + } +} + +.sortable-header { + cursor: pointer; + user-select: none; + transition: color $transition-base; + + &:hover { + color: $accent-text; + } +} + +// Forms +input[type='text'], +textarea, +select { + @include input-base; + + &[type='number'] { + width: 80px; + padding: 6px $space-4; + -moz-appearance: textfield; + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + } +} + +textarea { + min-height: 64px; + resize: vertical; +} + +select { + appearance: none; + -webkit-appearance: none; + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%236c6c84' d='M5 7L1 3h8z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right $space-4 center; + padding-right: 26px; + min-width: 100px; +} + +.form-group { + margin-bottom: $space-6; +} + +.form-label { + @include form-label; +} + +.form-row { + @include flex(row, flex-start, flex-end, $space-4); + + input[type='text'] { + flex: 1; + } +} + +.form-label-row { + @include flex(row, flex-start, center, 2px); + margin-bottom: $space-2; + + .form-label { + margin-bottom: 0; + } +} + +// Checkbox + +input[type='checkbox'] { + appearance: none; + -webkit-appearance: none; + width: 16px; + height: 16px; + border: 1px solid $border-strong; + border-radius: 3px; + background: $bg-2; + cursor: pointer; + position: relative; + flex-shrink: 0; + transition: all $transition-slow ease; + + &:hover { + border-color: $accent; + background: $bg-3; + } + + &:checked { + background: $accent; + border-color: $accent; + + &::after { + content: ''; + position: absolute; + left: 5px; + top: 2px; + width: 4px; + height: 8px; + border: solid $bg-0; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + } + } + + &:focus-visible { + @include focus-outline; + } +} + +.checkbox-label { + display: inline-flex; + align-items: center; + gap: $space-4; + cursor: pointer; + font-size: $font-size-lg; + color: $text-1; + user-select: none; + + &:hover { + color: $text-0; + } + + input[type='checkbox'] { + margin: 0; + } +} + +// Toggle switch +.toggle { + @include flex(row, flex-start, center, $space-4); + cursor: pointer; + font-size: $font-size-lg; + color: $text-0; + + &.disabled { + opacity: 0.4; + cursor: not-allowed; + } +} + +.toggle-track { + width: 32px; + height: 18px; + border-radius: 9px; + background: $bg-3; + border: 1px solid $border; + position: relative; + transition: background $transition-slow; + flex-shrink: 0; + + &.active { + background: $accent; + border-color: $accent; + + .toggle-thumb { + transform: translateX(14px); + } + } +} + +.toggle-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + background: $text-0; + position: absolute; + top: 1px; + left: 1px; + transition: transform $transition-slow; +} + +// Filter chips +.filter-bar { + @include flex(column, flex-start, stretch, 12px); + padding: $space-6; + background: $bg-0; + border: 1px solid $border-subtle; + border-radius: $radius; + margin-bottom: $space-6; +} + +.filter-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: $space-4; +} + +.filter-label { + font-size: $font-size-base; + font-weight: $font-weight-medium; + color: $text-2; + @include text-uppercase($letter-spacing-uppercase); + margin-right: $space-2; +} + +.filter-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px $space-5; + background: $bg-2; + border: 1px solid $border; + border-radius: 14px; + cursor: pointer; + font-size: $font-size-base; + color: $text-1; + transition: all $transition-slow ease; + user-select: none; + + &:hover { + background: $bg-3; + border-color: $border-strong; + color: $text-0; + } + + &.active { + background: $accent-dim; + border-color: $accent; + color: $accent-text; + } + + input[type='checkbox'] { + width: 12px; + height: 12px; + margin: 0; + + &:checked::after { + left: 3px; + top: 1px; + width: 3px; + height: 6px; + } + } +} + +.filter-group { + display: flex; + align-items: center; + gap: 6px; + + label { + display: flex; + align-items: center; + gap: 3px; + cursor: pointer; + color: $text-1; + font-size: $font-size-base; + white-space: nowrap; + + &:hover { + color: $text-0; + } + } +} + +.filter-separator { + width: 1px; + height: 20px; + background: $border; + flex-shrink: 0; +} + +// View toggle +.view-toggle { + display: flex; + border: 1px solid $border; + border-radius: $radius-sm; + overflow: hidden; +} + +.view-btn { + padding: $space-2 $space-5; + background: $bg-2; + border: none; + color: $text-2; + cursor: pointer; + font-size: $font-size-4xl; + line-height: 1; + transition: background $transition-base, color $transition-base; + + &:first-child { + border-right: 1px solid $border; + } + + &:hover { + color: $text-0; + background: $bg-3; + } + + &.active { + background: $accent-dim; + color: $accent-text; + } +} + +// Breadcrumb +.breadcrumb { + @include flex(row, flex-start, center, $space-2); + padding: $space-5 16px; + font-size: 0.85rem; + color: $text-2; +} + +.breadcrumb-sep { + color: $text-2; + opacity: 0.5; +} + +.breadcrumb-link { + color: $accent-text; + text-decoration: none; + cursor: pointer; + + &:hover { + text-decoration: underline; + } +} + +.breadcrumb-current { + color: $text-0; + font-weight: $font-weight-medium; +} + +// Progress bar +.progress-bar { + width: 100%; + height: 8px; + background: $bg-3; + border-radius: 4px; + overflow: hidden; + margin-bottom: 6px; +} + +.progress-fill { + height: 100%; + background: $accent; + border-radius: 4px; + transition: width 0.3s ease; + + &.indeterminate { + width: 30%; + animation: indeterminate 1.5s ease-in-out infinite; + } +} + +// Loading states +.loading-overlay { + @include flex-center; + padding: 48px 16px; + color: $text-2; + font-size: $font-size-lg; + gap: $space-5; +} + +.spinner { + width: 18px; + height: 18px; + border: 2px solid $border; + border-top-color: $accent; + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +.spinner-small { + width: 14px; + height: 14px; + border: 2px solid $border; + border-top-color: $accent; + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +.spinner-tiny { + width: 10px; + height: 10px; + border: 1.5px solid $border; + border-top-color: $accent; + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +// Modal +.modal-overlay { + position: fixed; + inset: 0; + background: $media-overlay-medium; + @include flex-center; + z-index: $z-modal-backdrop; + @include fade-in; +} + +.modal { + background: $bg-2; + border: 1px solid $border; + border-radius: $radius-md; + padding: 20px; + min-width: 360px; + max-width: 480px; + box-shadow: $shadow-lg; + + &.wide { + max-width: 600px; + max-height: 70vh; + overflow-y: auto; + } + + &-title { + font-size: $font-size-2xl; + font-weight: $font-weight-semibold; + margin-bottom: 6px; + } + + &-body { + font-size: $font-size-md; + color: $text-1; + margin-bottom: $space-8; + line-height: $line-height-relaxed; + } + + &-actions { + @include flex(row, flex-end, center, 6px); + } +} + +// Tooltips + +.tooltip-trigger { + @include tooltip-trigger; + + &:hover .tooltip-text { + display: block; + } +} + +.tooltip-text { + @include tooltip-content; +} + +// Media player + +.media-player { + position: relative; + background: $bg-0; + border-radius: $radius; + overflow: hidden; + + &:focus { + outline: none; + } + + &-audio .player-artwork { + @include flex(column, center, center, $space-4); + padding: $space-12 $space-8 8px; + } +} + +.player-artwork { + img { + max-width: 200px; + max-height: 200px; + border-radius: $radius; + object-fit: cover; + } + + &-placeholder { + width: 120px; + height: 120px; + @include flex-center; + background: $bg-2; + border-radius: $radius; + font-size: 48px; + opacity: 0.3; + } +} + +.player-title { + font-size: $font-size-lg; + font-weight: $font-weight-medium; + color: $text-0; + text-align: center; +} + +.player-controls { + @include flex(row, flex-start, center, $space-4); + padding: $space-5 14px; + background: $bg-2; +} + +.media-player-video .player-controls { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: $media-controls-bg; + opacity: 0; + transition: opacity $transition-slower; +} + +.media-player-video:hover .player-controls { + opacity: 1; +} + +.play-btn, +.mute-btn, +.fullscreen-btn { + background: none; + border: none; + color: $text-0; + cursor: pointer; + font-size: $font-size-4xl; + padding: $space-2; + line-height: 1; + transition: color $transition-base; + + &:hover { + color: $accent-text; + } +} + +.player-time { + font-size: $font-size-base; + color: $text-2; + font-family: $font-family-mono; + min-width: 36px; + text-align: center; + user-select: none; +} + +.seek-bar { + flex: 1; + -webkit-appearance: none; + appearance: none; + height: 4px; + border-radius: $space-2; + background: $bg-3; + outline: none; + cursor: pointer; + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 12px; + height: 12px; + border-radius: 50%; + background: $accent; + cursor: pointer; + border: none; + } + + &::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + background: $accent; + cursor: pointer; + border: none; + } +} + +.volume-slider { + width: 70px; + -webkit-appearance: none; + appearance: none; + height: 4px; + border-radius: $space-2; + background: $bg-3; + outline: none; + cursor: pointer; + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 10px; + height: 10px; + border-radius: 50%; + background: $text-1; + cursor: pointer; + border: none; + } + + &::-moz-range-thumb { + width: 10px; + height: 10px; + border-radius: 50%; + background: $text-1; + cursor: pointer; + border: none; + } +} + +// Image viewer + +.image-viewer-overlay { + position: fixed; + inset: 0; + background: $media-overlay-bg; + z-index: 150; + @include flex(column); + animation: fade-in 0.15s ease-out; + + &:focus { + outline: none; + } +} + +.image-viewer-toolbar { + @include flex-between; + padding: $space-5 16px; + background: $media-overlay-medium; + border-bottom: 1px solid $image-viewer-border; + z-index: 2; + user-select: none; + + &-left, + &-center, + &-right { + @include flex(row, flex-start, center, 6px); + } +} + +.iv-btn { + background: $image-viewer-btn-bg; + border: 1px solid $image-viewer-btn-border; + color: $text-0; + border-radius: $radius-sm; + padding: $space-2 $space-5; + font-size: $font-size-md; + cursor: pointer; + transition: background $transition-base; + + &:hover { + background: $image-viewer-btn-hover; + } + + &.iv-close { + color: $error; + font-weight: $font-weight-semibold; + } +} + +.iv-zoom-label { + font-size: $font-size-base; + color: $text-1; + min-width: 40px; + text-align: center; + font-family: $font-family-mono; +} + +.image-viewer-canvas { + flex: 1; + @include flex-center; + overflow: hidden; + position: relative; + + img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + user-select: none; + -webkit-user-drag: none; + } +} + +// PDF viewer + +.pdf-viewer { + @include flex(column); + height: 100%; + min-height: 500px; + background: $bg-0; + border-radius: $radius; + overflow: hidden; +} + +.pdf-toolbar { + @include flex(row, flex-start, center, $space-6); + padding: $space-5 $space-6; + background: $bg-1; + border-bottom: 1px solid $border; + + &-group { + @include flex(row, flex-start, center, $space-2); + } +} + +.pdf-toolbar-btn { + @include flex-center; + width: 28px; + height: 28px; + background: $bg-2; + border: 1px solid $border; + border-radius: $radius-sm; + color: $text-1; + font-size: 14px; + cursor: pointer; + transition: all $transition-slow; + + &:hover:not(:disabled) { + background: $bg-3; + color: $text-0; + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } +} + +.pdf-zoom-label { + min-width: 45px; + text-align: center; + font-size: $font-size-md; + color: $text-1; +} + +.pdf-container { + flex: 1; + position: relative; + overflow: hidden; + background: $bg-2; +} + +.pdf-object { + width: 100%; + height: 100%; + border: none; +} + +.pdf-loading, +.pdf-error { + position: absolute; + inset: 0; + @include flex(column, center, center, 12px); + background: $bg-1; + color: $text-1; +} + +.pdf-error { + padding: $space-6; + text-align: center; +} + +.pdf-fallback { + @include flex(column, center, center, $space-8); + padding: 48px $space-6; + text-align: center; + color: $text-2; +} + +// Markdown viewer + +.markdown-viewer { + padding: $space-8; + text-align: left; + @include flex(column, flex-start, stretch, 12px); +} + +.markdown-toolbar { + @include flex(row, flex-start, center, $space-4); + padding: $space-4; + background: $bg-2; + border-radius: $radius; + border: 1px solid $border; +} + +.toolbar-btn { + padding: 6px $space-6; + border: 1px solid $border; + border-radius: $radius-sm; + background: $bg-1; + color: $text-1; + font-size: $font-size-lg; + font-weight: $font-weight-medium; + cursor: pointer; + transition: all $transition-slow; + + &:hover { + background: $bg-3; + border-color: $border-strong; + } + + &.active { + background: $accent; + color: white; + border-color: $accent; + } +} + +.markdown-source { + max-width: 100%; + background: $bg-3; + border: 1px solid $border; + border-radius: $radius; + padding: $space-8; + overflow-x: auto; + font-family: $font-family-mono-alt; + font-size: $font-size-lg; + line-height: $line-height-loose; + color: $text-0; + white-space: pre-wrap; + word-wrap: break-word; + + code { + font-family: inherit; + background: none; + padding: 0; + border: none; + } +} + +.markdown-content { + max-width: 800px; + color: $text-0; + line-height: $line-height-loose; + font-size: 14px; + text-align: left; + + h1 { + font-size: 1.8em; + font-weight: $font-weight-bold; + margin: 1em 0 0.5em; + border-bottom: 1px solid $border-subtle; + padding-bottom: 0.3em; + } + + h2 { + font-size: 1.5em; + font-weight: $font-weight-semibold; + margin: 0.8em 0 0.4em; + border-bottom: 1px solid $border-subtle; + padding-bottom: 0.2em; + } + + h3 { + font-size: 1.25em; + font-weight: $font-weight-semibold; + margin: 0.6em 0 0.3em; + } + + h4 { + font-size: 1.1em; + font-weight: $font-weight-semibold; + margin: 0.5em 0 0.25em; + } + + h5, + h6 { + font-size: 1em; + font-weight: $font-weight-semibold; + margin: 0.4em 0 0.2em; + color: $text-1; + } + + p { + margin: 0 0 1em; + } + + a { + color: $accent; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + pre { + background: $bg-3; + border: 1px solid $border; + border-radius: $radius-sm; + padding: $space-6 $space-8; + overflow-x: auto; + margin: 0 0 1em; + font-family: $font-family-mono; + font-size: $font-size-md; + line-height: $line-height-relaxed; + } + + code { + background: $bg-3; + padding: 1px 5px; + border-radius: $radius-sm; + font-family: $font-family-mono; + font-size: 0.9em; + } + + pre code { + background: none; + padding: 0; + } + + blockquote { + border-left: 3px solid $accent; + padding: $space-2 $space-8; + margin: 0 0 1em; + color: $text-1; + background: $purple-bg; + } + + table { + width: 100%; + border-collapse: collapse; + margin: 0 0 1em; + } + + th, + td { + padding: 6px $space-6; + border: 1px solid $border; + font-size: $font-size-lg; + } + + th { + background: $bg-3; + font-weight: $font-weight-semibold; + text-align: left; + } + + tr:nth-child(even) { + background: $bg-2; + } + + ul, + ol { + margin: 0 0 1em; + padding-left: $space-8; + } + + ul { + list-style: disc; + } + + ol { + list-style: decimal; + } + + li { + padding: $space-1 0; + font-size: 14px; + color: $text-0; + } + + hr { + border: none; + border-top: 1px solid $border; + margin: 1.5em 0; + } + + img { + max-width: 100%; + border-radius: $radius; + } + + .footnote-definition { + font-size: 0.85em; + color: $text-1; + margin-top: 0.5em; + padding-left: 1.5em; + + sup { + color: $accent; + margin-right: $space-2; + } + } + + sup a { + color: $accent; + text-decoration: none; + font-size: 0.8em; + } +} + +.wikilink { + color: $accent-text; + text-decoration: none; + border-bottom: 1px dashed $accent; + cursor: pointer; + transition: border-color $transition-base, color $transition-base; + + &:hover { + color: $accent; + border-bottom-style: solid; + } +} + +.wikilink-embed { + display: inline-block; + padding: $space-1 $space-4; + background: $purple-light; + border: 1px dashed $purple-border; + border-radius: $radius-sm; + color: $type-audio-text; + font-size: $font-size-md; + cursor: default; +} diff --git a/crates/pinakes-ui/assets/styles/_graph.scss b/crates/pinakes-ui/assets/styles/_graph.scss new file mode 100644 index 0000000..3dd14ba --- /dev/null +++ b/crates/pinakes-ui/assets/styles/_graph.scss @@ -0,0 +1,310 @@ +@use 'variables' as *; +@use 'mixins' as *; + +// Graph view + +.graph-view { + @include flex(column); + height: 100%; + background: $bg-1; + border-radius: $radius; + overflow: hidden; +} + +.graph-toolbar { + @include flex(row, flex-start, center, $space-8); + padding: $space-6 $space-8; + background: $bg-2; + border-bottom: 1px solid $border; +} + +.graph-title { + font-size: $font-size-xl; + font-weight: $font-weight-semibold; + color: $text-0; +} + +.graph-controls { + @include flex(row, flex-start, center, $space-4); + font-size: $font-size-md; + color: $text-1; + + select { + padding: $space-2 20px $space-2 $space-4; + font-size: $font-size-base; + background: $bg-3; + } +} + +.graph-stats { + margin-left: auto; + font-size: $font-size-base; + color: $text-2; +} + +.graph-container { + flex: 1; + position: relative; + @include flex-center; + overflow: hidden; + background: $bg-0; +} + +.graph-loading, +.graph-error, +.graph-empty { + @include flex(column, center, center, $space-5); + padding: 48px; + color: $text-2; + font-size: $font-size-lg; + text-align: center; +} + +.graph-svg { + max-width: 100%; + max-height: 100%; + cursor: grab; +} + +.graph-svg-container { + position: relative; + width: 100%; + height: 100%; +} + +.graph-zoom-controls { + position: absolute; + top: $space-8; + left: $space-8; + @include flex(column, flex-start, stretch, $space-4); + z-index: 5; +} + +.zoom-btn { + width: 36px; + height: 36px; + border-radius: 6px; + background: $bg-2; + border: 1px solid $border; + color: $text-0; + font-size: 18px; + font-weight: bold; + @include flex-center; + cursor: pointer; + transition: all $transition-slow; + box-shadow: $shadow-sm; + + &:hover { + background: $bg-3; + border-color: $border-strong; + transform: scale(1.05); + } + + &:active { + transform: scale(0.95); + } +} + +// Graph elements + +.graph-edges line { + stroke: $border-strong; + stroke-width: 1; + opacity: 0.6; + + &.edge-type-wikilink { + stroke: $accent; + } + + &.edge-type-embed { + stroke: $graph-edge-embed; + stroke-dasharray: 4 2; + } +} + +.graph-nodes { + .graph-node { + cursor: pointer; + + circle { + fill: $graph-node-fill; + stroke: $graph-node-stroke; + stroke-width: 2; + transition: fill $transition-slow, stroke $transition-slow; + } + + &:hover circle { + fill: $graph-node-hover; + } + + &.selected circle { + fill: $accent; + stroke: $graph-node-selected; + } + + text { + fill: $text-1; + font-size: 11px; + pointer-events: none; + text-anchor: middle; + dominant-baseline: central; + transform: translateY(16px); + } + } +} + +// Node details panel + +.node-details-panel { + position: absolute; + top: $space-8; + right: $space-8; + width: 280px; + background: $bg-2; + border: 1px solid $border; + border-radius: $radius; + box-shadow: $shadow; + z-index: 10; +} + +.node-details-header { + @include flex-between; + padding: $space-5 14px; + border-bottom: 1px solid $border-subtle; + + h3 { + font-size: $font-size-lg; + font-weight: $font-weight-semibold; + color: $text-0; + margin: 0; + @include text-truncate; + } + + .close-btn { + background: none; + border: none; + color: $text-2; + cursor: pointer; + font-size: 14px; + padding: $space-1 $space-3; + line-height: 1; + + &:hover { + color: $text-0; + } + } +} + +.node-details-content { + padding: 14px; + + .node-title { + font-size: $font-size-md; + color: $text-1; + margin-bottom: $space-6; + } +} + +.node-stats { + display: flex; + gap: $space-8; + margin-bottom: $space-6; + + .stat { + font-size: $font-size-md; + color: $text-2; + + strong { + color: $text-0; + } + } +} + +// Physics controls + +.physics-controls-panel { + position: absolute; + top: $space-8; + right: $space-8; + width: 300px; + background: $bg-2; + border: 1px solid $border; + border-radius: $radius; + box-shadow: $shadow; + padding: $space-8; + z-index: 10; + + h4 { + font-size: $font-size-lg; + font-weight: $font-weight-semibold; + color: $text-0; + margin: 0 0 $space-8 0; + padding-bottom: $space-4; + border-bottom: 1px solid $border-subtle; + } + + .btn { + width: 100%; + margin-top: $space-4; + } +} + +.control-group { + margin-bottom: 14px; + + label { + display: block; + font-size: $font-size-base; + font-weight: $font-weight-medium; + color: $text-1; + margin-bottom: 6px; + text-transform: uppercase; + letter-spacing: $letter-spacing-uppercase; + } + + input[type='range'] { + width: 100%; + height: 4px; + border-radius: $space-2; + background: $bg-3; + outline: none; + -webkit-appearance: none; + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: $accent; + cursor: pointer; + transition: transform $transition-base; + + &:hover { + transform: scale(1.15); + } + } + + &::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + background: $accent; + cursor: pointer; + border: none; + transition: transform $transition-base; + + &:hover { + transform: scale(1.15); + } + } + } +} + +.control-value { + display: inline-block; + margin-top: $space-1; + font-size: $font-size-base; + color: $text-2; + font-family: $font-family-mono; +} diff --git a/crates/pinakes-ui/assets/styles/_layout.scss b/crates/pinakes-ui/assets/styles/_layout.scss new file mode 100644 index 0000000..969b04f --- /dev/null +++ b/crates/pinakes-ui/assets/styles/_layout.scss @@ -0,0 +1,344 @@ +@use 'variables' as *; +@use 'mixins' as *; + +// App layout + +.app { + @include flex(row, flex-start, stretch); + height: 100vh; + overflow: hidden; +} + +// Sidebar + +.sidebar { + width: $sidebar-width; + min-width: $sidebar-width; + max-width: $sidebar-width; + background: $bg-1; + border-right: 1px solid $border; + @include flex(column); + flex-shrink: 0; + user-select: none; + overflow-y: auto; + overflow-x: hidden; + z-index: $z-dropdown; + transition: width $transition-slow, min-width $transition-slow, + max-width $transition-slow; + + &.collapsed { + width: $sidebar-collapsed-width; + min-width: $sidebar-collapsed-width; + max-width: $sidebar-collapsed-width; + + .nav-label, + .sidebar-header .logo, + .sidebar-header .version, + .nav-badge, + .nav-item-text, + .sidebar-footer .status-text, + .user-name, + .role-badge, + .user-info .btn, + .sidebar-import-header span, + .sidebar-import-file { + display: none; + } + + .nav-item { + justify-content: center; + padding: $space-4; + border-left: none; + border-radius: $radius-sm; + + &.active { + border-left: none; + } + } + + .nav-icon { + width: auto; + margin: 0; + } + + .sidebar-header { + padding: 12px $space-4; + justify-content: center; + } + + .nav-section { + padding: 0 $space-2; + } + + .sidebar-footer { + padding: $space-4; + + .user-info { + justify-content: center; + padding: $space-2; + } + } + + .sidebar-import-progress { + padding: 6px; + } + } +} + +.sidebar-header { + padding: $space-8 $space-8 20px; + @include flex(row, flex-start, baseline, $space-4); + + .logo { + font-size: $font-size-2xl; + font-weight: $font-weight-bold; + letter-spacing: $letter-spacing-tight; + color: $text-0; + } + + .version { + font-size: $font-size-sm; + color: $text-2; + } +} + +.sidebar-toggle { + @include button-ghost; + color: $text-2; + padding: $space-4; + font-size: $font-size-4xl; + width: 100%; + text-align: center; + + &:hover { + color: $text-0; + } +} + +.sidebar-spacer { + flex: 1; +} + +.sidebar-footer { + padding: $space-6; + border-top: 1px solid $border-subtle; + overflow: visible; + min-width: 0; +} + +// Navigation + +.nav-section { + padding: 0 $space-4; + margin-bottom: $space-1; +} + +.nav-label { + padding: $space-4 $space-4 $space-2; + font-size: $font-size-sm; + font-weight: $font-weight-semibold; + @include text-uppercase; + color: $text-2; +} + +.nav-item { + @include flex(row, flex-start, center, $space-4); + padding: 6px $space-4; + border-radius: $radius-sm; + cursor: pointer; + color: $text-1; + font-size: $font-size-lg; + font-weight: 450; + transition: color $transition-base, background $transition-base; + border: none; + background: none; + width: 100%; + text-align: left; + border-left: 2px solid transparent; + margin-left: 0; + + &:hover { + color: $text-0; + background: $overlay-light; + } + + &.active { + color: $accent-text; + border-left-color: $accent; + background: $accent-dim; + } +} + +.nav-item-text { + flex: 1; + @include text-truncate; + min-width: 0; +} + +.sidebar:not(.collapsed) .nav-item-text { + overflow: visible; +} + +.nav-icon { + width: 18px; + text-align: center; + font-size: 14px; + opacity: 0.7; +} + +.nav-badge { + margin-left: auto; + font-size: $font-size-sm; + font-weight: $font-weight-semibold; + color: $text-2; + background: $bg-3; + padding: 1px $space-3; + border-radius: $radius-xl; + min-width: 20px; + text-align: center; + font-variant-numeric: tabular-nums; +} + +// Status indicator + +.status-indicator { + @include flex(row, center, center, 6px); + font-size: $font-size-base; + font-weight: $font-weight-medium; + min-width: 0; + overflow: visible; +} + +.sidebar:not(.collapsed) .status-indicator { + justify-content: flex-start; +} + +.status-dot { + @include status-dot; + + &.connected { + background: $success; + } + + &.disconnected { + background: $error; + } + + &.checking { + background: $warning; + @include pulse; + } +} + +.status-text { + color: $text-2; + @include text-truncate; + min-width: 0; +} + +.sidebar:not(.collapsed) .status-text { + overflow: visible; +} + +// Main content + +.main { + flex: 1; + @include flex(column); + overflow: hidden; + min-width: 0; +} + +.header { + height: $header-height; + min-height: $header-height; + border-bottom: 1px solid $border-subtle; + @include flex(row, flex-start, center, 12px); + padding: 0 20px; + background: $bg-1; +} + +.page-title { + font-size: $font-size-xl; + font-weight: $font-weight-semibold; + color: $text-0; +} + +.header-spacer { + flex: 1; +} + +.content { + flex: 1; + overflow-y: auto; + padding: 20px; + scrollbar-width: thin; + scrollbar-color: $overlay-strong transparent; +} + +// Import progress + +.sidebar-import-progress { + padding: $space-5 $space-6; + background: $bg-2; + border-top: 1px solid $border-subtle; + font-size: $font-size-base; +} + +.sidebar-import-header { + @include flex(row, flex-start, center, 6px); + margin-bottom: $space-2; + color: $text-1; +} + +.sidebar-import-file { + color: $text-2; + font-size: $font-size-sm; + @include text-truncate; + margin-bottom: $space-2; +} + +.sidebar-import-progress .progress-bar { + height: 3px; +} + +// User info + +.user-info { + @include flex(row, flex-start, center, 6px); + font-size: $font-size-md; + overflow: hidden; + min-width: 0; +} + +.user-name { + font-weight: $font-weight-medium; + color: $text-0; + @include text-truncate; + max-width: 90px; + flex-shrink: 1; +} + +.role-badge { + display: inline-block; + padding: 1px $space-3; + border-radius: $radius-sm; + font-size: $font-size-sm; + font-weight: $font-weight-semibold; + text-transform: uppercase; + + &.role-admin { + background: $role-admin-bg; + color: $role-admin-text; + } + + &.role-editor { + background: $role-editor-bg; + color: $role-editor-text; + } + + &.role-viewer { + background: $role-viewer-bg; + color: $role-viewer-text; + } +} diff --git a/crates/pinakes-ui/assets/styles/_media.scss b/crates/pinakes-ui/assets/styles/_media.scss new file mode 100644 index 0000000..8d7e6ff --- /dev/null +++ b/crates/pinakes-ui/assets/styles/_media.scss @@ -0,0 +1,715 @@ +@use 'variables' as *; +@use 'mixins' as *; + +// Media cards & grid + +.media-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: $space-6; +} + +.media-card { + background: $bg-2; + border: 1px solid $border; + border-radius: $radius; + overflow: hidden; + cursor: pointer; + transition: border-color 0.12s, box-shadow 0.12s; + position: relative; + + &:hover { + border-color: $border-strong; + box-shadow: $shadow-sm; + } + + &.selected { + border-color: $accent; + box-shadow: 0 0 0 1px $accent; + } +} + +.card-checkbox { + position: absolute; + top: 6px; + left: 6px; + z-index: 2; + opacity: 0; + transition: opacity $transition-base; + + input[type='checkbox'] { + width: 16px; + height: 16px; + cursor: pointer; + filter: drop-shadow(0 1px 2px $drop-shadow); + } +} + +.media-card:hover .card-checkbox, +.media-card.selected .card-checkbox { + opacity: 1; +} + +.card-thumbnail { + width: 100%; + aspect-ratio: 1; + background: $bg-0; + @include flex-center; + overflow: hidden; + position: relative; + + img, + .card-thumb-img { + width: 100%; + height: 100%; + object-fit: cover; + position: absolute; + top: 0; + left: 0; + z-index: 1; + } +} + +.card-type-icon { + font-size: 32px; + opacity: 0.4; + @include flex-center; + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + z-index: 0; +} + +.card-info { + padding: $space-4 $space-5; +} + +.card-name { + font-size: $font-size-md; + font-weight: $font-weight-medium; + color: $text-0; + @include text-truncate; + margin-bottom: $space-2; +} + +.card-title, +.card-artist { + font-size: $font-size-sm; + @include text-truncate; + line-height: $line-height-base; +} + +.card-meta { + @include flex(row, flex-start, center, 6px); + font-size: $font-size-sm; +} + +.card-size { + color: $text-2; + font-size: $font-size-sm; +} + +// Table thumbnails + +.table-thumb-cell { + width: 36px; + padding: $space-2 6px !important; + position: relative; +} + +.table-thumb { + width: 28px; + height: 28px; + object-fit: cover; + border-radius: 3px; + display: block; +} + +.table-thumb-overlay { + position: absolute; + top: $space-2; + left: 6px; + z-index: 1; +} + +.table-type-icon { + @include flex-center; + width: 28px; + height: 28px; + font-size: 14px; + opacity: 0.5; + border-radius: 3px; + background: $bg-0; + z-index: 0; +} + +// Type badges + +.type-badge { + display: inline-block; + padding: 1px $space-3; + border-radius: $radius-sm; + font-size: $font-size-sm; + font-weight: $font-weight-semibold; + @include text-uppercase; + + &.type-audio { + background: $type-audio-bg; + color: $type-audio-text; + } + + &.type-video { + background: $type-video-bg; + color: $type-video-text; + } + + &.type-image { + background: $type-image-bg; + color: $type-image-text; + } + + &.type-document { + background: $type-document-bg; + color: $type-document-text; + } + + &.type-text { + background: $type-text-bg; + color: $type-text-text; + } + + &.type-other { + background: $type-other-bg; + color: $text-2; + } +} + +// Tag badges + +.tag-list { + display: flex; + flex-wrap: wrap; + gap: $space-2; +} + +.tag-badge { + display: inline-flex; + align-items: center; + gap: $space-2; + padding: $space-1 $space-5; + background: $accent-dim; + color: $accent-text; + border-radius: 12px; + font-size: $font-size-base; + font-weight: $font-weight-medium; + + &.selected { + background: $accent; + color: white; + cursor: pointer; + } + + &:not(.selected) { + cursor: pointer; + } + + .tag-remove { + cursor: pointer; + opacity: 0.4; + font-size: $font-size-lg; + line-height: 1; + transition: opacity $transition-base; + + &:hover { + opacity: 1; + } + } +} + +// Tag hierarchy + +.tag-group { + margin-bottom: 6px; +} + +.tag-children { + margin-left: $space-8; + margin-top: $space-2; + display: flex; + flex-wrap: wrap; + gap: $space-2; +} + +.tag-confirm-delete { + display: inline-flex; + align-items: center; + gap: $space-2; + font-size: $font-size-sm; + color: $text-1; +} + +.tag-confirm-yes { + cursor: pointer; + color: $error; + font-weight: $font-weight-semibold; + + &:hover { + text-decoration: underline; + } +} + +.tag-confirm-no { + cursor: pointer; + color: $text-2; + font-weight: $font-weight-medium; + + &:hover { + text-decoration: underline; + } +} + +// Detail view + +.detail-actions { + display: flex; + gap: 6px; + margin-bottom: $space-8; +} + +.detail-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: $space-4; +} + +.detail-field { + padding: $space-5 $space-6; + background: $bg-0; + border-radius: $radius-sm; + border: 1px solid $border-subtle; + + &.full-width { + grid-column: 1 / -1; + } + + input[type='text'], + textarea, + select { + width: 100%; + margin-top: $space-2; + } + + textarea { + min-height: 64px; + resize: vertical; + } +} + +.detail-label { + font-size: $font-size-sm; + font-weight: $font-weight-semibold; + color: $text-2; + @include text-uppercase($letter-spacing-wider); + margin-bottom: $space-1; +} + +.detail-value { + font-size: $font-size-lg; + color: $text-0; + word-break: break-all; + + &.mono { + font-family: $font-family-mono; + font-size: $font-size-base; + color: $text-1; + } +} + +.detail-preview { + margin-bottom: $space-8; + background: $bg-0; + border: 1px solid $border-subtle; + border-radius: $radius; + overflow: hidden; + text-align: center; + + &:has(.markdown-viewer) { + max-height: none; + overflow-y: auto; + text-align: left; + } + + &:not(:has(.markdown-viewer)) { + max-height: 450px; + } + + img { + max-width: 100%; + max-height: 400px; + object-fit: contain; + display: block; + margin: 0 auto; + } + + audio { + width: 100%; + padding: $space-8; + } + + video { + max-width: 100%; + max-height: 400px; + display: block; + margin: 0 auto; + } +} + +.detail-no-preview { + padding: $space-8 $space-8; + text-align: center; + @include flex(column, center, center, $space-5); +} + +// Frontmatter + +.frontmatter-card { + max-width: 800px; + background: $bg-2; + border: 1px solid $border; + border-radius: $radius; + padding: $space-6 $space-8; + margin-bottom: $space-8; +} + +.frontmatter-fields { + display: grid; + grid-template-columns: auto 1fr; + gap: $space-2 $space-6; + margin: 0; + + dt { + font-weight: $font-weight-semibold; + font-size: $font-size-md; + color: $text-1; + text-transform: capitalize; + } + + dd { + font-size: $font-size-lg; + color: $text-0; + margin: 0; + } +} + +// Empty state + +.empty-state { + text-align: center; + padding: 48px $space-6; + color: $text-2; + + .empty-icon { + font-size: $font-size-7xl; + margin-bottom: $space-6; + opacity: 0.3; + } +} + +.empty-title { + font-size: $font-size-2xl; + font-weight: $font-weight-semibold; + color: $text-1; + margin-bottom: $space-2; +} + +.empty-subtitle { + font-size: $font-size-md; + max-width: 320px; + margin: 0 auto; + line-height: $line-height-relaxed; +} + +// Toast + +.toast-container { + position: fixed; + bottom: $space-8; + right: $space-8; + z-index: $z-toast; + display: flex; + flex-direction: column-reverse; + gap: 6px; + align-items: flex-end; + + .toast { + position: static; + transform: none; + } +} + +.toast { + position: fixed; + bottom: $space-8; + right: $space-8; + padding: $space-5 $space-8; + border-radius: $radius; + background: $bg-3; + border: 1px solid $border; + color: $text-0; + font-size: $font-size-md; + box-shadow: $shadow; + z-index: $z-toast; + animation: slide-up 0.15s ease-out; + max-width: 420px; + + &.success { + border-left: 3px solid $success; + } + + &.error { + border-left: 3px solid $error; + } +} + +// Banners + +.offline-banner, +.error-banner { + background: $error-bg; + border: 1px solid $error-border; + border-radius: $radius-sm; + padding: $space-5 $space-6; + margin-bottom: $space-6; + font-size: $font-size-md; + color: $error-text; + @include flex(row, flex-start, center, $space-4); + + .offline-icon, + .error-icon { + font-size: 14px; + flex-shrink: 0; + } +} + +.error-banner { + padding: 10px 14px; +} + +.readonly-banner { + @include flex(row, flex-start, center, $space-4); + padding: $space-5 $space-6; + background: $warning-bg; + border: 1px solid $warning-border; + border-radius: $radius-sm; + margin-bottom: $space-8; + font-size: $font-size-md; + color: $warning; +} + +.batch-actions { + @include flex(row, flex-start, center, $space-4); + padding: $space-4 $space-5; + background: $accent-dim; + border: 1px solid $accent-border; + border-radius: $radius-sm; + margin-bottom: $space-6; + font-size: $font-size-md; + font-weight: $font-weight-medium; + color: $accent-text; +} + +.select-all-banner { + @include flex(row, center, center, $space-4); + padding: $space-5 $space-8; + background: $info-bg; + border-radius: 6px; + margin-bottom: $space-4; + font-size: 0.85rem; + color: $text-1; + + button { + background: none; + border: none; + color: $accent; + cursor: pointer; + font-weight: $font-weight-semibold; + text-decoration: underline; + font-size: 0.85rem; + padding: 0; + + &:hover { + color: $text-0; + } + } +} + +// Import status + +.import-status-panel { + background: $bg-2; + border: 1px solid $accent; + border-radius: $radius; + padding: $space-6 $space-8; + margin-bottom: $space-8; +} + +.import-status-header { + @include flex(row, flex-start, center, $space-4); + margin-bottom: $space-4; + font-size: $font-size-lg; + color: $text-0; +} + +.import-current-file { + @include flex(row, flex-start, center, $space-1); + margin-bottom: 6px; + font-size: $font-size-md; + overflow: hidden; +} + +.import-file-label { + color: $text-2; + flex-shrink: 0; +} + +.import-file-name { + color: $text-0; + @include text-truncate; + font-family: monospace; + font-size: $font-size-base; +} + +.import-queue-indicator { + @include flex(row, flex-start, center, $space-1); + margin-bottom: $space-4; + font-size: $font-size-base; +} + +.import-queue-badge { + @include flex-center; + min-width: 18px; + height: 18px; + padding: 0 6px; + background: $accent-dim; + color: $accent-text; + border-radius: 9px; + font-weight: $font-weight-semibold; + font-size: $font-size-sm; +} + +.import-queue-text { + color: $text-2; +} + +.import-tabs { + display: flex; + gap: 0; + margin-bottom: $space-8; + border-bottom: 1px solid $border; +} + +.import-tab { + padding: $space-5 $space-8; + background: none; + border: none; + border-bottom: 2px solid transparent; + color: $text-2; + font-size: $font-size-md; + font-weight: $font-weight-medium; + cursor: pointer; + transition: color $transition-base, border-color $transition-base; + + &:hover { + color: $text-0; + } + + &.active { + color: $accent-text; + border-bottom-color: $accent; + } +} + +// Queue panel + +.queue-panel { + @include flex(column); + border-left: 1px solid $border; + background: $bg-1; + min-width: 280px; + max-width: 320px; +} + +.queue-header { + @include flex-between; + padding: $space-6 $space-8; + border-bottom: 1px solid $border-subtle; + + h3 { + margin: 0; + font-size: 0.9rem; + color: $text-0; + } +} + +.queue-controls { + display: flex; + gap: $space-1; +} + +.queue-list { + overflow-y: auto; + flex: 1; +} + +.queue-item { + @include flex(row, flex-start, center); + padding: $space-4 $space-8; + cursor: pointer; + border-bottom: 1px solid $border-subtle; + transition: background $transition-slow; + + &:hover { + background: $bg-2; + + .queue-item-remove { + opacity: 1; + } + } + + &-active { + background: $accent-dim; + border-left: 3px solid $accent; + } +} + +.queue-item-info { + flex: 1; + min-width: 0; +} + +.queue-item-title { + display: block; + font-size: 0.85rem; + color: $text-0; + @include text-truncate; +} + +.queue-item-artist { + display: block; + font-size: 0.75rem; + color: $text-2; +} + +.queue-item-remove { + opacity: 0; + transition: opacity $transition-slow; +} + +.queue-empty { + padding: $space-8 $space-8; + text-align: center; + color: $text-2; + font-size: 0.85rem; +} diff --git a/crates/pinakes-ui/assets/styles/_mixins.scss b/crates/pinakes-ui/assets/styles/_mixins.scss new file mode 100644 index 0000000..4caba8f --- /dev/null +++ b/crates/pinakes-ui/assets/styles/_mixins.scss @@ -0,0 +1,300 @@ +@use 'variables' as *; + +// Utility mixins + +@mixin flex($direction: row, $justify: flex-start, $align: stretch, $gap: 0) { + display: flex; + flex-direction: $direction; + justify-content: $justify; + align-items: $align; + @if $gap != 0 { + gap: $gap; + } +} + +@mixin flex-center { + display: flex; + align-items: center; + justify-content: center; +} + +@mixin flex-between { + display: flex; + justify-content: space-between; + align-items: center; +} + +// Text mixins + +@mixin text-truncate { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +@mixin text-uppercase($letter-spacing: $letter-spacing-wider) { + text-transform: uppercase; + letter-spacing: $letter-spacing; +} + +@mixin font-mono($size: $font-size-md) { + font-family: $font-family-mono; + font-size: $size; +} + +// Button mixins + +@mixin button-base { + padding: 5px 12px; + border-radius: $radius-sm; + border: none; + cursor: pointer; + font-size: $font-size-md; + font-weight: $font-weight-medium; + transition: all $transition-base; + display: inline-flex; + align-items: center; + gap: 5px; + white-space: nowrap; + line-height: 1.5; +} + +@mixin button-variant($bg, $color, $border: null, $hover-bg: null) { + background: $bg; + color: $color; + @if $border { + border: 1px solid $border; + } + @if $hover-bg { + &:hover { + background: $hover-bg; + } + } +} + +@mixin button-ghost { + background: transparent; + border: none; +} + +// Card mixins + +@mixin card($padding: $space-8) { + background: $bg-2; + border: 1px solid $border; + border-radius: $radius; + padding: $padding; +} + +@mixin card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: $space-6; +} + +// Form mixins + +@mixin input-base { + padding: 6px 10px; + border-radius: $radius-sm; + border: 1px solid $border; + background: $bg-0; + color: $text-0; + font-size: $font-size-lg; + outline: none; + transition: border-color $transition-slow; + font-family: inherit; + + &::placeholder { + color: $text-2; + } + + &:focus { + border-color: $accent; + } +} + +@mixin form-label { + display: block; + font-size: $font-size-base; + font-weight: $font-weight-semibold; + color: $text-1; + margin-bottom: $space-2; + @include text-uppercase($letter-spacing-wide); +} + +// Scrollbar mixins + +@mixin scrollbar($width: 5px, $thumb-color: $overlay-strong, $track-color: transparent) { + &::-webkit-scrollbar { + width: $width; + height: $width; + } + + &::-webkit-scrollbar-track { + background: $track-color; + } + + &::-webkit-scrollbar-thumb { + background: $thumb-color; + border-radius: 3px; + + &:hover { + background: $border-strong; + } + } + + scrollbar-width: thin; + scrollbar-color: $thumb-color $track-color; +} + +// Status/state mixins + +@mixin status-dot($size: 6px) { + width: $size; + height: $size; + border-radius: 50%; + flex-shrink: 0; +} + +@mixin disabled-state($opacity: 0.4) { + opacity: $opacity; + cursor: not-allowed; + pointer-events: none; +} + +@mixin focus-outline($color: $accent, $width: 2px, $offset: 2px) { + &:focus-visible { + outline: $width solid $color; + outline-offset: $offset; + } +} + +// Animation mixins + +@mixin animation($name, $duration: 0.7s, $timing: linear, $iteration: infinite) { + animation: $name $duration $timing $iteration; +} + +@mixin fade-in($duration: 0.1s) { + animation: fade-in $duration ease-out; +} + +@mixin slide-up($duration: 0.15s, $distance: 8px) { + animation: slide-up $duration ease-out; + + @keyframes slide-up { + from { + opacity: 0; + transform: translateY($distance); + } + to { + opacity: 1; + transform: translateY(0); + } + } +} + +@mixin pulse($duration: 1.5s) { + animation: pulse $duration infinite; +} + +@mixin skeleton-pulse { + animation: skeleton-pulse 1.5s ease-in-out infinite; + background: $bg-3; +} + +// Badge mixins + +@mixin badge-base { + display: inline-flex; + align-items: center; + gap: $space-2; + padding: $space-1 $space-3; + border-radius: $radius-xl; + font-size: $font-size-sm; + font-weight: $font-weight-medium; +} + +@mixin badge-variant($bg, $color) { + background: $bg; + color: $color; +} + +// Tooltip mixins + +@mixin tooltip-trigger { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + border-radius: 50%; + background: $bg-3; + color: $text-2; + font-size: $font-size-xs; + font-weight: $font-weight-bold; + cursor: help; + position: relative; + flex-shrink: 0; + margin-left: $space-2; + + &:hover { + background: $accent-dim; + color: $accent-text; + } +} + +@mixin tooltip-content { + display: none; + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + padding: 6px 10px; + background: $bg-3; + border: 1px solid $border; + border-radius: $radius-sm; + color: $text-0; + font-size: $font-size-base; + font-weight: $font-weight-normal; + line-height: $line-height-normal; + white-space: normal; + width: 220px; + text-transform: none; + letter-spacing: normal; + box-shadow: $shadow; + z-index: 100; + pointer-events: none; +} + +// Media queries + +@mixin mobile { + @media (max-width: 768px) { + @content; + } +} + +@mixin tablet { + @media (min-width: 769px) and (max-width: 1024px) { + @content; + } +} + +@mixin desktop { + @media (min-width: 1025px) { + @content; + } +} + +// Reduced motion + +@mixin respect-reduced-motion { + @media (prefers-reduced-motion: reduce) { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/crates/pinakes-ui/assets/styles/_sections.scss b/crates/pinakes-ui/assets/styles/_sections.scss new file mode 100644 index 0000000..022618b --- /dev/null +++ b/crates/pinakes-ui/assets/styles/_sections.scss @@ -0,0 +1,508 @@ +@use 'variables' as *; +@use 'mixins' as *; + +// Statistics + +.statistics-page { + padding: 20px; +} + +.stats-overview, +.stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: $space-8; + margin-bottom: $space-12; + + @media (max-width: 768px) { + grid-template-columns: 1fr; + } +} + +.stat-card { + background: $bg-2; + border: 1px solid $border; + border-radius: $radius; + padding: 20px; + @include flex(row, flex-start, center, $space-8); + + &.stat-primary { + border-left: 3px solid $accent; + } + + &.stat-success { + border-left: 3px solid $success; + } + + &.stat-info { + border-left: 3px solid $type-document-text; + } + + &.stat-warning { + border-left: 3px solid $warning; + } + + &.stat-purple { + border-left: 3px solid $type-audio-text; + } + + &.stat-danger { + border-left: 3px solid $error; + } +} + +.stat-icon { + flex-shrink: 0; + color: $text-2; +} + +.stat-content { + flex: 1; +} + +.stat-value { + font-size: $font-size-6xl; + font-weight: $font-weight-bold; + color: $text-0; + line-height: $line-height-tight; + font-variant-numeric: tabular-nums; +} + +.stat-label { + font-size: $font-size-md; + color: $text-2; + margin-top: $space-2; + font-weight: $font-weight-medium; +} + +.stats-section { + background: $bg-2; + border: 1px solid $border; + border-radius: $radius; + padding: $space-8; + margin-bottom: 20px; +} + +.section-title { + font-size: $font-size-3xl; + font-weight: $font-weight-semibold; + color: $text-0; + margin-bottom: 20px; + + // Alternative smaller size used in some places + &.small { + font-size: $font-size-xl; + margin-bottom: $space-6; + padding-bottom: 6px; + border-bottom: 1px solid $border-subtle; + } +} + +// Chart bars + +.chart-bars { + @include flex(column, flex-start, stretch, $space-8); +} + +.bar-item { + display: grid; + grid-template-columns: 120px 1fr 80px; + align-items: center; + gap: $space-8; +} + +.bar-label { + font-size: $font-size-lg; + font-weight: $font-weight-medium; + color: $text-1; + text-align: right; +} + +.bar-track { + height: 28px; + background: $bg-3; + border-radius: $radius-sm; + overflow: hidden; + position: relative; +} + +.bar-fill { + height: 100%; + transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1); + border-radius: $radius-sm; + + &.bar-primary { + background: linear-gradient(90deg, $accent 0%, $gradient-accent-end 100%); + } + + &.bar-success { + background: linear-gradient(90deg, $success 0%, $gradient-success-end 100%); + } +} + +.bar-value { + font-size: $font-size-lg; + font-weight: $font-weight-semibold; + color: $text-1; + text-align: right; + font-variant-numeric: tabular-nums; +} + +// Settings + +.settings-section { + margin-bottom: $space-8; +} + +.settings-card { + background: $bg-2; + border: 1px solid $border; + border-radius: $radius-md; + padding: 20px; + margin-bottom: $space-8; + + &.danger-card { + border: 1px solid $error-border-light; + } +} + +.settings-card-header { + @include flex-between; + margin-bottom: $space-8; + padding-bottom: $space-6; + border-bottom: 1px solid $border-subtle; +} + +.settings-card-title { + font-size: $font-size-xl; + font-weight: $font-weight-semibold; +} + +.settings-card-body { + padding-top: $space-1; +} + +.settings-field { + @include flex-between; + padding: $space-4 0; + border-bottom: 1px solid $border-subtle; + + &:last-child { + border-bottom: none; + } + + select { + min-width: 120px; + } +} + +// Config status + +.config-path { + font-size: $font-size-base; + color: $text-2; + margin-bottom: $space-6; + font-family: $font-family-mono; + padding: 6px $space-5; + background: $bg-0; + border-radius: $radius-sm; + border: 1px solid $border-subtle; +} + +.config-status { + @include flex(row, flex-start, center, 6px); + padding: 3px $space-5; + border-radius: 12px; + font-size: $font-size-base; + font-weight: $font-weight-semibold; + + &.writable { + background: $success-light; + color: $success; + } + + &.readonly { + background: $error-medium; + color: $error; + } +} + +// Root list + +.root-list { + list-style: none; +} + +.root-item { + @include flex-between; + padding: $space-4 $space-6; + background: $bg-0; + border: 1px solid $border-subtle; + border-radius: $radius-sm; + margin-bottom: $space-2; + font-family: $font-family-mono; + font-size: $font-size-md; + color: $text-1; +} + +// Info row + +.info-row { + @include flex-between; + padding: 6px 0; + border-bottom: 1px solid $border-subtle; + font-size: $font-size-lg; + + &:last-child { + border-bottom: none; + } +} + +.info-label { + color: $text-1; + font-weight: $font-weight-medium; +} + +.info-value { + color: $text-0; +} + +// Tasks + +.tasks-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); + gap: $space-8; + padding: $space-6; +} + +.task-card { + background: $bg-2; + border: 1px solid $border; + border-radius: $radius; + overflow: hidden; + transition: all $transition-slower; + + &:hover { + border-color: $border-strong; + box-shadow: $shadow-card-hover; + transform: translateY(-2px); + } + + &-enabled { + border-left: 3px solid $success; + } + + &-disabled { + border-left: 3px solid $text-3; + opacity: 0.7; + } +} + +.task-card-header { + @include flex-between; + align-items: flex-start; + padding: $space-8; + border-bottom: 1px solid $border-subtle; +} + +.task-header-left { + flex: 1; + min-width: 0; +} + +.task-name { + font-size: $font-size-3xl; + font-weight: $font-weight-semibold; + color: $text-0; + margin-bottom: $space-1; +} + +.task-schedule { + @include flex(row, flex-start, center, 6px); + font-size: $font-size-md; + color: $text-2; + font-family: $font-family-mono-alt; +} + +.schedule-icon { + font-size: 14px; +} + +.task-status-badge { + flex-shrink: 0; +} + +.status-badge { + @include flex(row, flex-start, center, 6px); + padding: $space-1 10px; + border-radius: $radius-sm; + font-size: $font-size-sm; + font-weight: $font-weight-semibold; + text-transform: uppercase; + letter-spacing: $letter-spacing-wide; + + &.status-enabled { + background: $green-medium; + color: $success; + + .status-dot { + animation: pulse 1.5s infinite; + } + } + + &.status-disabled { + background: $bg-3; + color: $text-2; + } + + .status-dot { + @include status-dot; + background: currentColor; + } +} + +.task-info-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: $space-6; + padding: $space-8; +} + +.task-info-item { + @include flex(row, flex-start, flex-start, 10px); +} + +.task-info-icon { + font-size: 18px; + color: $text-2; + flex-shrink: 0; +} + +.task-info-content { + flex: 1; + min-width: 0; +} + +.task-info-label { + font-size: $font-size-sm; + color: $text-2; + font-weight: $font-weight-semibold; + @include text-uppercase($letter-spacing-wide); + margin-bottom: $space-1; +} + +.task-info-value { + font-size: $font-size-md; + color: $text-1; + font-weight: $font-weight-medium; + @include text-truncate; +} + +.task-card-actions { + display: flex; + gap: $space-4; + padding: $space-5 $space-8; + background: $bg-1; + border-top: 1px solid $border-subtle; + + button { + flex: 1; + } +} + +// Database actions + +.db-actions { + @include flex(column, flex-start, stretch, $space-8); + padding: $space-5; +} + +.db-action-row { + @include flex(row, space-between, center, $space-8); + padding: $space-5; + border-radius: 6px; + background: $overlay-inverse-light; +} + +.db-action-info { + flex: 1; + + h4 { + font-size: 0.95rem; + font-weight: $font-weight-semibold; + color: $text-0; + margin-bottom: $space-1; + } +} + +.db-action-confirm { + @include flex(row, flex-start, center, $space-4); + flex-shrink: 0; +} + +// Library page + +.library-toolbar { + @include flex-between; + padding: $space-4 0; + margin-bottom: $space-6; + gap: $space-6; + flex-wrap: wrap; +} + +.toolbar-left { + @include flex(row, flex-start, center, $space-5); +} + +.toolbar-right { + @include flex(row, flex-start, center, $space-5); +} + +.sort-control, +.page-size-control { + select { + padding: $space-2 24px $space-2 $space-4; + font-size: $font-size-base; + background: $bg-2; + } +} + +.page-size-control { + @include flex(row, flex-start, center, $space-2); +} + +.library-stats { + @include flex-between; + padding: $space-1 0 6px 0; + font-size: $font-size-base; +} + +.type-filter-row { + @include flex(row, flex-start, center, 6px); + padding: $space-2 0; + margin-bottom: 6px; + flex-wrap: wrap; +} + +// Pagination + +.pagination { + @include flex-center; + gap: $space-2; + margin-top: $space-8; + padding: $space-4 0; +} + +.page-btn { + min-width: 28px; + text-align: center; + font-variant-numeric: tabular-nums; +} + +.page-ellipsis { + color: $text-2; + padding: 0 $space-2; + font-size: $font-size-md; + user-select: none; +} diff --git a/crates/pinakes-ui/assets/styles/_themes.scss b/crates/pinakes-ui/assets/styles/_themes.scss new file mode 100644 index 0000000..396f672 --- /dev/null +++ b/crates/pinakes-ui/assets/styles/_themes.scss @@ -0,0 +1,281 @@ +@use 'variables' as *; +@use 'mixins' as *; + +// Light theme + +.theme-light { + // Background + --bg-0: #{$light-bg-0}; + --bg-1: #{$light-bg-1}; + --bg-2: #{$light-bg-2}; + --bg-3: #{$light-bg-3}; + + // Border + --border-subtle: #{$light-border-subtle}; + --border: #{$light-border}; + --border-strong: #{$light-border-strong}; + + // Text + --text-0: #{$light-text-0}; + --text-1: #{$light-text-1}; + --text-2: #{$light-text-2}; + + // Accent + --accent: #{$light-accent}; + --accent-dim: #{$light-accent-dim}; + --accent-text: #{$light-accent-text}; + + // Shadows + --shadow-sm: #{$light-shadow-sm}; + --shadow: #{$light-shadow}; + --shadow-lg: #{$light-shadow-lg}; + + // Scrollbar + ::-webkit-scrollbar-thumb { + background: $overlay-inverse-strong; + + &:hover { + background: $overlay-inverse-medium; + } + } + + ::-webkit-scrollbar-track { + background: $overlay-inverse-light; + } + + // Graph + .graph-nodes .graph-node text { + fill: $light-text-0; + } + + .graph-edges line { + stroke: $overlay-inverse-strong; + } + + // PDF + .pdf-container { + background: $light-bg-3; + } +} + +// Skeleton + +.skeleton-pulse { + @include skeleton-pulse; + border-radius: 4px; +} + +.skeleton-card { + @include flex(column, flex-start, stretch, $space-4); + padding: $space-4; +} + +.skeleton-thumb { + width: 100%; + aspect-ratio: 1; + border-radius: 6px; +} + +.skeleton-text { + height: 14px; + width: 80%; + + &-short { + width: 50%; + } +} + +.skeleton-row { + display: flex; + gap: $space-6; + padding: 10px $space-8; + align-items: center; +} + +.skeleton-cell { + height: 14px; + flex: 1; + border-radius: 4px; + + &-icon { + width: 32px; + height: 32px; + flex: none; + border-radius: 4px; + } + + &-wide { + flex: 3; + } +} + +.loading-overlay { + position: absolute; + inset: 0; + @include flex(column, center, center, $space-5); + background: $media-overlay-light; + z-index: 100; + border-radius: $radius-lg; +} + +.loading-spinner { + width: 32px; + height: 32px; + border: 3px solid $border; + border-top-color: $accent; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.loading-message { + color: $text-1; + font-size: 0.9rem; +} + +// Login + +.login-container { + @include flex-center; + height: 100vh; + background: $bg-0; +} + +.login-card { + background: $bg-2; + border: 1px solid $border; + border-radius: $radius-md; + padding: $space-12; + width: 360px; + box-shadow: $shadow-lg; +} + +.login-title { + font-size: 20px; + font-weight: $font-weight-bold; + color: $text-0; + text-align: center; + margin-bottom: $space-1; +} + +.login-subtitle { + font-size: $font-size-lg; + color: $text-2; + text-align: center; + margin-bottom: 20px; +} + +.login-error { + background: $error-light; + border: 1px solid $error-border; + border-radius: $radius-sm; + padding: $space-4 $space-6; + margin-bottom: $space-6; + font-size: $font-size-md; + color: $error; +} + +.login-form { + input[type='text'], + input[type='password'] { + width: 100%; + } +} + +.login-btn { + width: 100%; + padding: $space-4 $space-8; + font-size: $font-size-lg; + margin-top: $space-1; +} + +// Pagination + +.pagination { + @include flex(row, center, center, $space-1); + margin-top: $space-8; + padding: $space-4 0; +} + +.page-btn { + min-width: 28px; + text-align: center; + font-variant-numeric: tabular-nums; +} + +.page-ellipsis { + color: $text-2; + padding: 0 $space-2; + font-size: $font-size-md; + user-select: none; +} + +// Help overlay + +.help-overlay { + position: fixed; + inset: 0; + background: $media-overlay-medium; + @include flex-center; + z-index: 200; + animation: fade-in 0.1s ease-out; +} + +.help-dialog { + background: $bg-2; + border: 1px solid $border; + border-radius: $radius-md; + padding: $space-8; + min-width: 300px; + max-width: 400px; + box-shadow: $shadow-lg; + + h3 { + font-size: $font-size-3xl; + font-weight: $font-weight-semibold; + margin-bottom: $space-8; + } +} + +.help-shortcuts { + @include flex(column, flex-start, stretch, $space-4); + margin-bottom: $space-8; +} + +.shortcut-row { + @include flex(row, flex-start, center, $space-6); + + kbd { + display: inline-block; + padding: $space-1 $space-4; + background: $bg-0; + border: 1px solid $border; + border-radius: $radius-sm; + font-family: $font-family-mono; + font-size: $font-size-base; + color: $text-0; + min-width: 32px; + text-align: center; + } + + span { + font-size: $font-size-lg; + color: $text-1; + } +} + +.help-close { + display: block; + width: 100%; + padding: 6px $space-6; + background: $bg-3; + border: 1px solid $border; + border-radius: $radius-sm; + color: $text-0; + font-size: $font-size-md; + cursor: pointer; + text-align: center; + + &:hover { + background: $overlay-strong; + } +} diff --git a/crates/pinakes-ui/assets/styles/_variables.scss b/crates/pinakes-ui/assets/styles/_variables.scss new file mode 100644 index 0000000..c1ab763 --- /dev/null +++ b/crates/pinakes-ui/assets/styles/_variables.scss @@ -0,0 +1,256 @@ +// Background colors +$bg-0: #111118; +$bg-1: #18181f; +$bg-2: #1f1f28; +$bg-3: #26263a; + +// Border colors +$border-subtle: rgba(255, 255, 255, 0.06); +$border: rgba(255, 255, 255, 0.09); +$border-strong: rgba(255, 255, 255, 0.14); + +// Text colors +$text-0: #dcdce4; +$text-1: #a0a0b8; +$text-2: #6c6c84; +$text-3: #4a4a5e; + +// Accent colors +$accent: #7c7ef5; +$accent-dim: rgba(124, 126, 245, 0.15); +$accent-text: #9698f7; +$accent-hover: #8b8df7; + +// Semantic colors +$success: #3ec97a; +$error: #e45858; +$warning: #d4a037; + +// Derived semantic colors with transparency +$error-bg: rgba(228, 88, 88, 0.06); +$error-border: rgba(228, 88, 88, 0.2); +$error-text: #d47070; + +$success-bg: rgba(62, 201, 122, 0.08); +$success-border: rgba(62, 201, 122, 0.2); + +$warning-bg: rgba(212, 160, 55, 0.06); +$warning-border: rgba(212, 160, 55, 0.15); + +// Type badge colors +$type-audio-bg: rgba(139, 92, 246, 0.1); +$type-audio-text: #9d8be0; + +$type-video-bg: rgba(200, 72, 130, 0.1); +$type-video-text: #d07eaa; + +$type-image-bg: rgba(34, 160, 80, 0.1); +$type-image-text: #5cb97a; + +$type-document-bg: rgba(59, 120, 200, 0.1); +$type-document-text: #6ca0d4; + +$type-text-bg: rgba(200, 160, 36, 0.1); +$type-text-text: #c4a840; + +$type-other-bg: rgba(128, 128, 160, 0.08); + +// Action badge colors (audit) +$action-updated-bg: rgba(59, 120, 200, 0.1); +$action-updated-text: #6ca0d4; + +$action-collection-bg: rgba(34, 160, 80, 0.1); +$action-collection-text: #5cb97a; + +$action-collection-remove-bg: rgba(212, 160, 55, 0.1); +$action-collection-remove-text: #c4a840; + +$action-opened-bg: rgba(139, 92, 246, 0.1); +$action-opened-text: #9d8be0; + +$action-scanned-bg: rgba(128, 128, 160, 0.08); + +// Role badge colors +$role-admin-bg: rgba(139, 92, 246, 0.1); +$role-admin-text: #9d8be0; + +$role-editor-bg: rgba(34, 160, 80, 0.1); +$role-editor-text: #5cb97a; + +$role-viewer-bg: rgba(59, 120, 200, 0.1); +$role-viewer-text: #6ca0d4; + +// Graph colors +$graph-node-fill: #4caf50; +$graph-node-stroke: #388e3c; +$graph-node-hover: #66bb6a; +$graph-node-selected: #5456d6; +$graph-edge-embed: #9d8be0; + +// Overlay backgrounds +$overlay-light: rgba(255, 255, 255, 0.03); +$overlay-medium: rgba(255, 255, 255, 0.04); +$overlay-strong: rgba(255, 255, 255, 0.06); +$overlay-subtle: rgba(255, 255, 255, 0.02); + +// Inverse overlays (for light backgrounds) +$overlay-inverse-light: rgba(0, 0, 0, 0.06); +$overlay-inverse-medium: rgba(0, 0, 0, 0.08); +$overlay-inverse-strong: rgba(0, 0, 0, 0.12); + +// Semantic variants +$error-light: rgba(228, 88, 88, 0.08); +$error-medium: rgba(228, 88, 88, 0.1); +$error-border-light: rgba(228, 88, 88, 0.25); + +$success-light: rgba(62, 201, 122, 0.1); + +$info-bg: rgba(99, 102, 241, 0.08); +$info-bg-light: rgba(99, 102, 241, 0.12); +$accent-border: rgba(124, 126, 245, 0.2); + +$purple-bg: rgba(124, 126, 245, 0.04); +$purple-light: rgba(139, 92, 246, 0.08); +$purple-border: rgba(139, 92, 246, 0.3); + +$warning-light: rgba(212, 160, 55, 0.1); +$warning-medium: rgba(212, 160, 55, 0.12); + +$green-light: rgba(76, 175, 80, 0.06); +$green-medium: rgba(76, 175, 80, 0.12); +$green-text: #4caf50; + +// UI element backgrounds +$btn-danger-hover: rgba(228, 88, 88, 0.08); +$btn-ghost-hover: rgba(255, 255, 255, 0.04); +$btn-secondary-hover: rgba(255, 255, 255, 0.06); + +// Media viewer overlays +$media-overlay-bg: rgba(0, 0, 0, 0.92); +$media-overlay-medium: rgba(0, 0, 0, 0.5); +$media-overlay-light: rgba(0, 0, 0, 0.3); +$media-controls-bg: rgba(0, 0, 0, 0.7); + +// Image viewer +$image-viewer-toolbar-bg: rgba(0, 0, 0, 0.5); +$image-viewer-border: rgba(255, 255, 255, 0.08); +$image-viewer-btn-bg: rgba(255, 255, 255, 0.06); +$image-viewer-btn-border: rgba(255, 255, 255, 0.1); +$image-viewer-btn-hover: rgba(255, 255, 255, 0.12); + +// Shadows +$drop-shadow: rgba(0, 0, 0, 0.5); + +// Gradients +$gradient-accent-end: #7c7ef3; +$gradient-success-end: #66bb6a; + +// Light theme +$light-bg-0: #f5f5f7; +$light-bg-1: #eeeef0; +$light-bg-2: #ffffff; +$light-bg-3: #e8e8ec; + +$light-border-subtle: rgba(0, 0, 0, 0.06); +$light-border: rgba(0, 0, 0, 0.1); +$light-border-strong: rgba(0, 0, 0, 0.16); + +$light-text-0: #1a1a2e; +$light-text-1: #555570; +$light-text-2: #8888a0; + +$light-accent: #6366f1; +$light-accent-dim: rgba(99, 102, 241, 0.1); +$light-accent-text: #4f52e8; + +// Spacing +$space-1: 2px; +$space-2: 4px; +$space-3: 6px; +$space-4: 8px; +$space-5: 10px; +$space-6: 12px; +$space-7: 14px; +$space-8: 16px; +$space-10: 20px; +$space-12: 24px; +$space-16: 32px; +$space-20: 40px; +$space-24: 48px; + +// Border radius +$radius-sm: 3px; +$radius: 5px; +$radius-md: 7px; +$radius-lg: 8px; +$radius-xl: 12px; +$radius-full: 50%; + +// Shadows +$shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3); +$shadow: 0 2px 8px rgba(0, 0, 0, 0.35); +$shadow-lg: 0 4px 20px rgba(0, 0, 0, 0.45); +$shadow-card-hover: 0 4px 12px rgba(0, 0, 0, 0.08); + +$light-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08); +$light-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +$light-shadow-lg: 0 4px 20px rgba(0, 0, 0, 0.12); + +// Typography +$font-family-base: 'Inter', -apple-system, 'Segoe UI', system-ui, sans-serif; +$font-family-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; +$font-family-mono-alt: 'Menlo', 'Monaco', 'Courier New', monospace; + +$font-size-xs: 9px; +$font-size-sm: 10px; +$font-size-base: 11px; +$font-size-md: 12px; +$font-size-lg: 13px; +$font-size-xl: 14px; +$font-size-2xl: 15px; +$font-size-3xl: 16px; +$font-size-4xl: 18px; +$font-size-5xl: 20px; +$font-size-6xl: 28px; +$font-size-7xl: 32px; +$font-size-8xl: 48px; + +$font-weight-normal: 400; +$font-weight-medium: 500; +$font-weight-semibold: 600; +$font-weight-bold: 700; + +$line-height-tight: 1.2; +$line-height-base: 1.3; +$line-height-normal: 1.4; +$line-height-relaxed: 1.5; +$line-height-loose: 1.7; + +$letter-spacing-tight: -0.4px; +$letter-spacing-normal: 0; +$letter-spacing-wide: 0.03em; +$letter-spacing-wider: 0.04em; +$letter-spacing-widest: 0.06em; +$letter-spacing-uppercase: 0.5px; + +// Layout dimensions +$sidebar-width: 220px; +$sidebar-collapsed-width: 48px; +$header-height: 48px; + +// Z-index scale +$z-base: 0; +$z-dropdown: 10; +$z-sticky: 20; +$z-fixed: 30; +$z-modal-backdrop: 100; +$z-modal: 200; +$z-toast: 300; + +// Transitions +$transition-fast: 0.08s; +$transition-base: 0.1s; +$transition-slow: 0.15s; +$transition-slower: 0.2s; +$transition-timing-default: ease; +$transition-timing-smooth: cubic-bezier(0.4, 0, 0.2, 1); diff --git a/crates/pinakes-ui/assets/styles/main.scss b/crates/pinakes-ui/assets/styles/main.scss new file mode 100644 index 0000000..930c7f5 --- /dev/null +++ b/crates/pinakes-ui/assets/styles/main.scss @@ -0,0 +1,13 @@ +// Main stylesheet entry point +// Imports all partials in order + +@use 'variables' as *; +@use 'mixins' as *; +@use 'base'; +@use 'layout'; +@use 'components'; +@use 'media'; +@use 'sections'; +@use 'audit'; +@use 'graph'; +@use 'themes'; diff --git a/crates/pinakes-ui/build.rs b/crates/pinakes-ui/build.rs new file mode 100644 index 0000000..058a7d7 --- /dev/null +++ b/crates/pinakes-ui/build.rs @@ -0,0 +1,27 @@ +use std::{fs, path::Path}; + +fn main() { + // Compile SCSS to CSS + let scss_dir = Path::new("assets/styles"); + let css_dir = Path::new("assets/css"); + + // Create CSS output directory if it doesn't exist + fs::create_dir_all(css_dir).expect("Failed to create CSS directory"); + + // Compile main.scss + let scss_input = scss_dir.join("main.scss"); + let css_output = css_dir.join("main.css"); + + if scss_input.exists() { + let css = grass::from_path( + &scss_input, + &grass::Options::default().style(grass::OutputStyle::Compressed), + ) + .expect("Failed to compile SCSS"); + + fs::write(&css_output, css).expect("Failed to write CSS"); + + // Tell cargo to rerun if SCSS files change + println!("cargo:rerun-if-changed=assets/styles"); + } +} diff --git a/crates/pinakes-ui/src/app.rs b/crates/pinakes-ui/src/app.rs index abd84c5..a36deed 100644 --- a/crates/pinakes-ui/src/app.rs +++ b/crates/pinakes-ui/src/app.rs @@ -1,2496 +1,2536 @@ -use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; use dioxus::prelude::*; -use dioxus_free_icons::icons::fa_solid_icons::{ - FaArrowRightFromBracket, FaBook, FaChartBar, FaChevronLeft, FaChevronRight, FaClock, - FaClockRotateLeft, FaCopy, FaDatabase, FaDiagramProject, FaDownload, FaGear, FaLayerGroup, - FaMagnifyingGlass, FaTags, +use dioxus_free_icons::{ + Icon, + IconShape, + icons::fa_solid_icons::{ + FaArrowRightFromBracket, + FaBook, + FaChartBar, + FaChevronLeft, + FaChevronRight, + FaClock, + FaClockRotateLeft, + FaCopy, + FaDatabase, + FaDiagramProject, + FaDownload, + FaGear, + FaLayerGroup, + FaMagnifyingGlass, + FaTags, + }, }; -use dioxus_free_icons::{Icon, IconShape}; use futures::future::join_all; #[component] fn NavIcon(icon: T) -> Element { - rsx! { - Icon { - width: 16, - height: 16, - fill: "currentColor", - icon, - } - } + rsx! { + Icon { + width: 16, + height: 16, + fill: "currentColor", + icon, + } + } } -use crate::client::*; -use crate::components::{ - audit, collections, database, detail, duplicates, graph_view, import, library, - media_player::PlayQueue, search, settings, statistics, tags, tasks, +use crate::{ + client::*, + components::{ + audit, + collections, + database, + detail, + duplicates, + graph_view, + import, + library, + media_player::PlayQueue, + search, + settings, + statistics, + tags, + tasks, + }, + styles, }; -use crate::styles; static TOAST_ID_COUNTER: AtomicUsize = AtomicUsize::new(0); #[derive(Debug, Clone, PartialEq)] enum View { - Library, - Search, - Detail, - Tags, - Collections, - Audit, - Import, - Duplicates, - Statistics, - Tasks, - Settings, - Database, - Graph, + Library, + Search, + Detail, + Tags, + Collections, + Audit, + Import, + Duplicates, + Statistics, + Tasks, + Settings, + Database, + Graph, } impl View { - fn title(&self) -> &'static str { - match self { - Self::Library => "Library", - Self::Search => "Search", - Self::Detail => "Detail", - Self::Tags => "Tags", - Self::Collections => "Collections", - Self::Audit => "Audit Log", - Self::Import => "Import", - Self::Duplicates => "Duplicates", - Self::Statistics => "Statistics", - Self::Tasks => "Tasks", - Self::Settings => "Settings", - Self::Database => "Database", - Self::Graph => "Note Graph", - } + fn title(&self) -> &'static str { + match self { + Self::Library => "Library", + Self::Search => "Search", + Self::Detail => "Detail", + Self::Tags => "Tags", + Self::Collections => "Collections", + Self::Audit => "Audit Log", + Self::Import => "Import", + Self::Duplicates => "Duplicates", + Self::Statistics => "Statistics", + Self::Tasks => "Tasks", + Self::Settings => "Settings", + Self::Database => "Database", + Self::Graph => "Note Graph", } + } } #[component] pub fn App() -> Element { - // Phase 1.3: Auth support - let base_url = - std::env::var("PINAKES_SERVER_URL").unwrap_or_else(|_| "http://localhost:3000".into()); - let api_key = std::env::var("PINAKES_API_KEY").ok(); - let mut client = use_signal(|| ApiClient::new(&base_url, api_key.as_deref())); - let server_url = use_signal(|| base_url.clone()); + // Phase 1.3: Auth support + let base_url = std::env::var("PINAKES_SERVER_URL") + .unwrap_or_else(|_| "http://localhost:3000".into()); + let api_key = std::env::var("PINAKES_API_KEY").ok(); + let mut client = use_signal(|| ApiClient::new(&base_url, api_key.as_deref())); + let server_url = use_signal(|| base_url.clone()); - let mut current_view = use_signal(|| View::Library); - let mut media_list = use_signal(Vec::::new); - let mut media_total_count = use_signal(|| 0u64); - let mut media_page = use_signal(|| 0u64); - let mut media_page_size = use_signal(|| 48u64); - let mut media_sort = use_signal(|| "created_at_desc".to_string()); - let mut search_results = use_signal(Vec::::new); - let mut search_total = use_signal(|| 0u64); - let mut selected_media = use_signal(|| Option::::None); - let mut media_tags = use_signal(Vec::::new); - let mut tags_list = use_signal(Vec::::new); - let mut collections_list = use_signal(Vec::::new); - let mut audit_list = use_signal(Vec::::new); - let mut config_data = use_signal(|| Option::::None); - let mut db_stats = use_signal(|| Option::::None); - let mut library_stats = use_signal(|| Option::::None); - let mut scheduled_tasks = use_signal(Vec::::new); - let mut duplicate_groups = use_signal(Vec::::new); - let mut preview_files = use_signal(Vec::::new); - let mut preview_total_size = use_signal(|| 0u64); - let mut viewing_collection = use_signal(|| Option::::None); - let mut collection_members = use_signal(Vec::::new); - let mut server_connected = use_signal(|| false); - let mut server_checking = use_signal(|| true); - let mut loading = use_signal(|| true); - let mut load_error = use_signal(|| Option::::None); + let mut current_view = use_signal(|| View::Library); + let mut media_list = use_signal(Vec::::new); + let mut media_total_count = use_signal(|| 0u64); + let mut media_page = use_signal(|| 0u64); + let mut media_page_size = use_signal(|| 48u64); + let mut media_sort = use_signal(|| "created_at_desc".to_string()); + let mut search_results = use_signal(Vec::::new); + let mut search_total = use_signal(|| 0u64); + let mut selected_media = use_signal(|| Option::::None); + let mut media_tags = use_signal(Vec::::new); + let mut tags_list = use_signal(Vec::::new); + let mut collections_list = use_signal(Vec::::new); + let mut audit_list = use_signal(Vec::::new); + let mut config_data = use_signal(|| Option::::None); + let mut db_stats = + use_signal(|| Option::::None); + let mut library_stats = + use_signal(|| Option::::None); + let mut scheduled_tasks = + use_signal(Vec::::new); + let mut duplicate_groups = + use_signal(Vec::::new); + let mut preview_files = use_signal(Vec::::new); + let mut preview_total_size = use_signal(|| 0u64); + let mut viewing_collection = use_signal(|| Option::::None); + let mut collection_members = use_signal(Vec::::new); + let mut server_connected = use_signal(|| false); + let mut server_checking = use_signal(|| true); + let mut loading = use_signal(|| true); + let mut load_error = use_signal(|| Option::::None); - // Phase 1.4: Toast queue - let mut toast_queue = use_signal(Vec::<(String, bool, usize)>::new); + // Phase 1.4: Toast queue + let mut toast_queue = use_signal(Vec::<(String, bool, usize)>::new); - // Phase 5.1: Search pagination - let mut search_page = use_signal(|| 0u64); - let search_page_size = use_signal(|| 50u64); - let mut last_search_query = use_signal(String::new); - let mut last_search_sort = use_signal(|| Option::::None); + // Phase 5.1: Search pagination + let mut search_page = use_signal(|| 0u64); + let search_page_size = use_signal(|| 50u64); + let mut last_search_query = use_signal(String::new); + let mut last_search_sort = use_signal(|| Option::::None); - // Phase 3.6: Saved searches - let mut saved_searches = use_signal(Vec::::new); + // Phase 3.6: Saved searches + let mut saved_searches = use_signal(Vec::::new); - // Phase 6.1: Audit pagination & filter - let mut audit_page = use_signal(|| 0u64); - let audit_page_size = use_signal(|| 200u64); - let audit_total_count = use_signal(|| 0u64); - let mut audit_filter = use_signal(|| "All".to_string()); + // Phase 6.1: Audit pagination & filter + let mut audit_page = use_signal(|| 0u64); + let audit_page_size = use_signal(|| 200u64); + let audit_total_count = use_signal(|| 0u64); + let mut audit_filter = use_signal(|| "All".to_string()); - // Phase 6.2: Scan progress - let mut scan_progress = use_signal(|| Option::::None); + // Phase 6.2: Scan progress + let mut scan_progress = use_signal(|| Option::::None); - // Phase 7.1: Help overlay - let mut show_help = use_signal(|| false); + // Phase 7.1: Help overlay + let mut show_help = use_signal(|| false); - // Phase 8: Sidebar collapse - let mut sidebar_collapsed = use_signal(|| false); + // Phase 8: Sidebar collapse + let mut sidebar_collapsed = use_signal(|| false); - // Auth state - let mut auth_required = use_signal(|| false); - let mut current_user = use_signal(|| Option::::None); - let mut login_error = use_signal(|| Option::::None); - let mut login_loading = use_signal(|| false); - let mut auto_play_media = use_signal(|| false); - let mut play_queue = use_signal(PlayQueue::default); + // Auth state + let mut auth_required = use_signal(|| false); + let mut current_user = use_signal(|| Option::::None); + let mut login_error = use_signal(|| Option::::None); + let mut login_loading = use_signal(|| false); + let mut auto_play_media = use_signal(|| false); + let mut play_queue = use_signal(PlayQueue::default); - // Theme state (Phase 3.3) - let mut current_theme = use_signal(|| "dark".to_string()); - let mut system_prefers_dark = use_signal(|| true); + // Theme state (Phase 3.3) + let mut current_theme = use_signal(|| "dark".to_string()); + let mut system_prefers_dark = use_signal(|| true); - // Detect system color scheme preference - use_effect(move || { - spawn(async move { - // Check system preference using JavaScript - let result = - document::eval(r#"window.matchMedia('(prefers-color-scheme: dark)').matches"#); - if let Ok(val) = result.await - && let Some(prefers_dark) = val.as_bool() - { - system_prefers_dark.set(prefers_dark); - } - }); + // Detect system color scheme preference + use_effect(move || { + spawn(async move { + // Check system preference using JavaScript + let result = document::eval( + r#"window.matchMedia('(prefers-color-scheme: dark)').matches"#, + ); + if let Ok(val) = result.await + && let Some(prefers_dark) = val.as_bool() + { + system_prefers_dark.set(prefers_dark); + } }); - - // Compute effective theme based on preference - let effective_theme = use_memo(move || { - let theme = current_theme.read().clone(); - if theme == "system" { - if *system_prefers_dark.read() { - "dark".to_string() - } else { - "light".to_string() - } - } else { - theme - } - }); - - // Import state for UI feedback - let mut import_in_progress = use_signal(|| false); - // Extended import state: current file name, queue of pending imports, progress (completed, total) - let mut import_current_file = use_signal(|| Option::::None); - let mut import_queue = use_signal(Vec::::new); - let mut import_progress = use_signal(|| (0usize, 0usize)); // (completed, total) - - // Check auth on startup - let client_auth = client.read().clone(); - use_effect(move || { - let client = client_auth.clone(); - spawn(async move { - match client.get_current_user().await { - Ok(user) => { - current_user.set(Some(user)); - auth_required.set(false); - } - Err(e) => { - // Check if this is an auth error (401) vs network error - let err_str = e.to_string(); - if err_str.contains("401") - || err_str.contains("unauthorized") - || err_str.contains("Unauthorized") - { - auth_required.set(true); - } - // For network errors, don't require auth (server offline state handles this) - } - } - // Load UI config - if let Ok(cfg) = client.get_config().await { - auto_play_media.set(cfg.ui.auto_play_media); - sidebar_collapsed.set(cfg.ui.sidebar_collapsed); - current_theme.set(cfg.ui.theme.clone()); - if cfg.ui.default_page_size > 0 { - media_page_size.set(cfg.ui.default_page_size as u64); - } - config_data.set(Some(cfg)); - } - }); - }); - - // Health check polling - let client_health = client.read().clone(); - use_effect(move || { - let client = client_health.clone(); - spawn(async move { - loop { - server_checking.set(true); - let ok = client.health_check().await; - server_connected.set(ok); - server_checking.set(false); - tokio::time::sleep(std::time::Duration::from_secs(10)).await; - } - }); - }); - - // Load initial data (Phase 2.2: pass sort to list_media) - let client_init = client.read().clone(); - let init_sort = media_sort.read().clone(); - use_effect(move || { - let client = client_init.clone(); - let sort = init_sort.clone(); - spawn(async move { - loading.set(true); - load_error.set(None); - match client.list_media(0, 48, Some(&sort)).await { - Ok(items) => media_list.set(items), - Err(e) => { - load_error.set(Some(format!("Failed to load media: {e}"))); - } - } - if let Ok(count) = client.get_media_count().await { - media_total_count.set(count); - } - if let Ok(t) = client.list_tags().await { - tags_list.set(t); - } - if let Ok(c) = client.list_collections().await { - collections_list.set(c); - } - // Phase 3.6: Load saved searches - if let Ok(ss) = client.list_saved_searches().await { - saved_searches.set(ss); - } - loading.set(false); - }); - }); - - // Phase 1.4: Toast helper with queue support - let mut show_toast = move |msg: String, is_error: bool| { - let id = TOAST_ID_COUNTER.fetch_add(1, Ordering::Relaxed); - toast_queue.write().push((msg, is_error, id)); - // Keep at most 3 toasts - let len = toast_queue.read().len(); - if len > 3 { - toast_queue.write().drain(0..len - 3); - } - spawn(async move { - tokio::time::sleep(std::time::Duration::from_secs(3)).await; - toast_queue.write().retain(|(_, _, tid)| *tid != id); - }); - }; - - // Helper: refresh media list with current pagination (Phase 2.2: pass sort) - let refresh_media = { - let client = client.read().clone(); - move || { - let client = client.clone(); - spawn(async move { - loading.set(true); - let offset = *media_page.read() * *media_page_size.read(); - let limit = *media_page_size.read(); - let sort = media_sort.read().clone(); - if let Ok(items) = client.list_media(offset, limit, Some(&sort)).await { - media_list.set(items); - } - if let Ok(count) = client.get_media_count().await { - media_total_count.set(count); - } - loading.set(false); - }); - } - }; - - // Helper: refresh tags - let refresh_tags = { - let client = client.read().clone(); - move || { - let client = client.clone(); - spawn(async move { - if let Ok(t) = client.list_tags().await { - tags_list.set(t); - } - }); - } - }; - - // Helper: refresh collections - let refresh_collections = { - let client = client.read().clone(); - move || { - let client = client.clone(); - spawn(async move { - if let Ok(c) = client.list_collections().await { - collections_list.set(c); - } - }); - } - }; - - // Helper: refresh audit with pagination and filter (Phase 6.1) - let refresh_audit = { - let client = client.read().clone(); - move || { - let client = client.clone(); - spawn(async move { - let offset = *audit_page.read() * *audit_page_size.read(); - let limit = *audit_page_size.read(); - if let Ok(entries) = client.list_audit(offset, limit).await { - audit_list.set(entries); - } - }); - } - }; - - // Login handler for auth flow - let on_login_submit = { - move |(username, password): (String, String)| { - let login_client = client.read().clone(); - spawn(async move { - login_loading.set(true); - login_error.set(None); - - match login_client.login(&username, &password).await { - Ok(resp) => { - // Update the signal with a new client that has the token set - client.write().set_token(&resp.token); - current_user.set(Some(UserInfoResponse { - username: resp.username, - role: resp.role, - })); - auth_required.set(false); - } - Err(e) => { - login_error.set(Some(format!("Login failed: {e}"))); - } - } - login_loading.set(false); - }); - } - }; - - let view_title = use_memo(move || current_view.read().title()); - let _total_pages = use_memo(move || { - let ps = *media_page_size.read(); - let tc = *media_total_count.read(); - if ps > 0 { tc.div_ceil(ps) } else { 1 } - }); - - rsx! { - style { {styles::CSS} } - - if *auth_required.read() { - crate::components::login::Login { - on_login: on_login_submit, - error: login_error.read().clone(), - loading: *login_loading.read(), - } - } else { - // Phase 7.1: Keyboard shortcuts - div { - class: if *effective_theme.read() == "light" { "app theme-light" } else { "app" }, - tabindex: "0", - onkeydown: { - move |evt: KeyboardEvent| { - let key = evt.key(); - let ctrl = evt.modifiers().contains(Modifiers::CONTROL); - let meta = evt.modifiers().contains(Modifiers::META); - let shift = evt.modifiers().contains(Modifiers::SHIFT); - - match key { - // Escape - close modal/go back - Key::Escape => { - if *show_help.read() { - show_help.set(false); - } else if *current_view.read() == View::Detail { - current_view.set(View::Library); - } - } - // / or Ctrl+K - focus search - Key::Character(ref c) if c == "/" && !ctrl && !meta => { - evt.prevent_default(); - current_view.set(View::Search); - } - Key::Character(ref c) if c == "k" && (ctrl || meta) => { - evt.prevent_default(); - current_view.set(View::Search); - } - // ? - toggle help overlay - Key::Character(ref c) if c == "?" && !ctrl && !meta => { - show_help.toggle(); - } - // Ctrl+, - open settings - Key::Character(ref c) if c == "," && (ctrl || meta) => { - evt.prevent_default(); - current_view.set(View::Settings); - } - // Number keys 1-6 for quick view switching (without modifiers) - Key::Character(ref c) if c == "1" && !ctrl && !meta && !shift => { - evt.prevent_default(); - current_view.set(View::Library); - } - Key::Character(ref c) if c == "2" && !ctrl && !meta && !shift => { - evt.prevent_default(); - current_view.set(View::Search); - } - Key::Character(ref c) if c == "3" && !ctrl && !meta && !shift => { - evt.prevent_default(); - current_view.set(View::Import); - } - Key::Character(ref c) if c == "4" && !ctrl && !meta && !shift => { - evt.prevent_default(); - current_view.set(View::Tags); - } - Key::Character(ref c) if c == "5" && !ctrl && !meta && !shift => { - evt.prevent_default(); - current_view.set(View::Collections); - } - Key::Character(ref c) if c == "6" && !ctrl && !meta && !shift => { - evt.prevent_default(); - current_view.set(View::Audit); - } - // g then l - go to library (vim-style) - // Could implement g-prefix commands in the future - Key::Character(ref c) if c == "g" && !ctrl && !meta => {} - _ => {} - } - } - }, - - // Sidebar - div { class: if *sidebar_collapsed.read() { "sidebar collapsed" } else { "sidebar" }, - div { class: "sidebar-header", - span { class: "logo", "Pinakes" } - span { class: "version", "v0.1" } - } - - div { class: "nav-section", - div { class: "nav-label", "Media" } - button { - class: if *current_view.read() == View::Library { "nav-item active" } else { "nav-item" }, - onclick: { - let refresh_media = refresh_media.clone(); - move |_| { - current_view.set(View::Library); - refresh_media(); - } - }, - span { class: "nav-icon", - NavIcon { icon: FaBook } - } - span { class: "nav-item-text", "Library" } - span { class: "nav-badge", "{media_total_count}" } - } - button { - class: if *current_view.read() == View::Search { "nav-item active" } else { "nav-item" }, - onclick: move |_| current_view.set(View::Search), - span { class: "nav-icon", - NavIcon { icon: FaMagnifyingGlass } - } - span { class: "nav-item-text", "Search" } - } - button { - class: if *current_view.read() == View::Import { "nav-item active" } else { "nav-item" }, - onclick: { - let refresh_tags = refresh_tags.clone(); - let refresh_collections = refresh_collections.clone(); - move |_| { - current_view.set(View::Import); - preview_files.set(Vec::new()); - preview_total_size.set(0); - scan_progress.set(None); - refresh_tags(); - refresh_collections(); - } - }, - span { class: "nav-icon", - NavIcon { icon: FaDownload } - } - span { class: "nav-item-text", "Import" } - } - button { - class: if *current_view.read() == View::Graph { "nav-item active" } else { "nav-item" }, - onclick: move |_| { - current_view.set(View::Graph); - }, - span { class: "nav-icon", - NavIcon { icon: FaDiagramProject } - } - span { class: "nav-item-text", "Graph" } - } - } - - div { class: "nav-section", - div { class: "nav-label", "Organize" } - button { - class: if *current_view.read() == View::Tags { "nav-item active" } else { "nav-item" }, - onclick: { - let refresh_tags = refresh_tags.clone(); - move |_| { - current_view.set(View::Tags); - refresh_tags(); - } - }, - span { class: "nav-icon", - NavIcon { icon: FaTags } - } - span { class: "nav-item-text", "Tags" } - span { class: "nav-badge", "{tags_list.read().len()}" } - } - button { - class: if *current_view.read() == View::Collections { "nav-item active" } else { "nav-item" }, - onclick: { - let refresh_collections = refresh_collections.clone(); - move |_| { - current_view.set(View::Collections); - viewing_collection.set(None); - collection_members.set(Vec::new()); - refresh_collections(); - } - }, - span { class: "nav-icon", - NavIcon { icon: FaLayerGroup } - } - span { class: "nav-item-text", "Collections" } - span { class: "nav-badge", "{collections_list.read().len()}" } - } - } - - div { class: "nav-section", - div { class: "nav-label", "System" } - button { - class: if *current_view.read() == View::Audit { "nav-item active" } else { "nav-item" }, - onclick: { - let refresh_audit = refresh_audit.clone(); - move |_| { - current_view.set(View::Audit); - refresh_audit(); - } - }, - span { class: "nav-icon", - NavIcon { icon: FaClockRotateLeft } - } - span { class: "nav-item-text", "Audit" } - } - button { - class: if *current_view.read() == View::Duplicates { "nav-item active" } else { "nav-item" }, - onclick: { - let client = client.read().clone(); - move |_| { - current_view.set(View::Duplicates); - let client = client.clone(); - spawn(async move { - if let Ok(groups) = client.list_duplicates().await { - duplicate_groups.set(groups); - } - }); - } - }, - span { class: "nav-icon", - NavIcon { icon: FaCopy } - } - span { class: "nav-item-text", "Duplicates" } - } - button { - class: if *current_view.read() == View::Statistics { "nav-item active" } else { "nav-item" }, - onclick: { - let client = client.read().clone(); - move |_| { - current_view.set(View::Statistics); - let client = client.clone(); - spawn(async move { - if let Ok(stats) = client.library_statistics().await { - library_stats.set(Some(stats)); - } - }); - } - }, - span { class: "nav-icon", - NavIcon { icon: FaChartBar } - } - span { class: "nav-item-text", "Statistics" } - } - button { - class: if *current_view.read() == View::Tasks { "nav-item active" } else { "nav-item" }, - onclick: { - let client = client.read().clone(); - move |_| { - current_view.set(View::Tasks); - let client = client.clone(); - spawn(async move { - if let Ok(tasks_data) = client.list_scheduled_tasks().await { - scheduled_tasks.set(tasks_data); - } - }); - } - }, - span { class: "nav-icon", - NavIcon { icon: FaClock } - } - span { class: "nav-item-text", "Tasks" } - } - button { - class: if *current_view.read() == View::Settings { "nav-item active" } else { "nav-item" }, - onclick: { - let client = client.read().clone(); - move |_| { - current_view.set(View::Settings); - let client = client.clone(); - spawn(async move { - if let Ok(cfg) = client.get_config().await { - config_data.set(Some(cfg)); - } - }); - } - }, - span { class: "nav-icon", - NavIcon { icon: FaGear } - } - span { class: "nav-item-text", "Settings" } - } - button { - class: if *current_view.read() == View::Database { "nav-item active" } else { "nav-item" }, - onclick: { - let client = client.read().clone(); - move |_| { - current_view.set(View::Database); - let client = client.clone(); - spawn(async move { - if let Ok(stats) = client.database_stats().await { - db_stats.set(Some(stats)); - } - }); - } - }, - span { class: "nav-icon", - NavIcon { icon: FaDatabase } - } - span { class: "nav-item-text", "Database" } - } - } - - div { class: "sidebar-spacer" } - - // Show import progress in sidebar when not on import page - if *import_in_progress.read() && *current_view.read() != View::Import { - { - let (completed, total) = *import_progress.read(); - let has_progress = total > 0; - let pct = (completed * 100).checked_div(total).unwrap_or(0); - let current = import_current_file.read().clone(); - let queue_len = import_queue.read().len(); - rsx! { - div { class: "sidebar-import-progress", - div { class: "sidebar-import-header", - div { class: "status-dot checking" } - span { - if has_progress { - "Importing {completed}/{total}" - } else { - "Importing..." - } - } - if queue_len > 0 { - span { class: "import-queue-badge", "+{queue_len}" } - } - } - if let Some(ref file_name) = current { - div { class: "sidebar-import-file", "{file_name}" } - } - div { class: "progress-bar", - if has_progress { - div { class: "progress-fill", style: "width: {pct}%;" } - } else { - div { class: "progress-fill indeterminate" } - } - } - } - } - } - } - - // Sidebar collapse toggle - button { - class: "sidebar-toggle", - onclick: move |_| sidebar_collapsed.toggle(), - if *sidebar_collapsed.read() { - NavIcon { icon: FaChevronRight } - } else { - NavIcon { icon: FaChevronLeft } - } - } - - // User info (when logged in) - if let Some(ref user) = *current_user.read() { - div { class: "sidebar-footer user-info", - span { class: "user-name", "{user.username}" } - span { class: "role-badge role-{user.role}", "{user.role}" } - button { - class: "btn btn-ghost btn-sm", - onclick: { - let client = client.read().clone(); - move |_| { - let client = client.clone(); - spawn(async move { - let _ = client.logout().await; - current_user.set(None); - auth_required.set(true); - }); - } - }, - NavIcon { icon: FaArrowRightFromBracket } - "Logout" - } - } - } - - // Server status indicator - div { class: "sidebar-footer", - div { class: "status-indicator", - { - let is_checking = *server_checking.read(); - let is_connected = *server_connected.read(); - let dot_class = if is_checking { - "status-dot checking" - } else if is_connected { - "status-dot connected" - } else { - "status-dot disconnected" - }; - let label = if is_checking { - "Checking..." - } else if is_connected { - "Server connected" - } else { - "Server offline" - }; - rsx! { - span { class: "{dot_class}" } - span { class: "status-text", "{label}" } - } - } - } - } - } - - // Main content - div { class: "main", - div { class: "header", - span { class: "page-title", "{view_title}" } - div { class: "header-spacer" } - } - - div { class: "content", - // Offline banner - if !*server_checking.read() && !*server_connected.read() { - div { class: "offline-banner", - span { class: "offline-icon", "\u{26a0}" } - "Cannot reach the server. Make sure pinakes-server is running." - } - } - - // Error banner - if let Some(ref err) = *load_error.read() { - div { class: "error-banner", - span { class: "error-icon", "\u{26a0}" } - "{err}" - } - } - - // Loading indicator - if *loading.read() { - div { class: "loading-overlay", - div { class: "spinner" } - "Loading..." - } - } - - { - // Phase 2.2: Sort wiring - actually refetch with sort - // Phase 4.1 + 4.2: Search improvements - // Phase 3.1 + 3.2: Detail view enhancements - // Phase 3.2: Delete from detail navigates back and refreshes - // Phase 5.1: Tags on_delete - confirmation handled inside Tags component - // Phase 5.2: Collections enhancements - // Phase 5.2: Navigate to detail when clicking a collection member - // Phase 5.2: Add member to collection - // Phase 6.1: Audit improvements - // Phase 6.2: Scan progress - // Phase 6.2: Scan with polling for progress - // Poll scan status until done - // Refresh duplicates list - // Reload full config - match *current_view.read() { - View::Library => rsx! { - div { class: "stats-grid", - div { class: "stat-card", - div { class: "stat-value", "{media_total_count}" } - div { class: "stat-label", "Media Files" } - } - div { class: "stat-card", - div { class: "stat-value", "{tags_list.read().len()}" } - div { class: "stat-label", "Tags" } - } - div { class: "stat-card", - div { class: "stat-value", "{collections_list.read().len()}" } - div { class: "stat-label", "Collections" } - } - } - library::Library { - media: media_list.read().clone(), - tags: tags_list.read().clone(), - collections: collections_list.read().clone(), - total_count: *media_total_count.read(), - current_page: *media_page.read(), - page_size: *media_page_size.read(), - server_url: server_url.read().clone(), - on_select: { - let client = client.read().clone(); - move |id: String| { - let client = client.clone(); - spawn(async move { - match client.get_media(&id).await { - Ok(item) => { - let mtags = client.get_media_tags(&id).await.unwrap_or_default(); - media_tags.set(mtags); - selected_media.set(Some(item)); - current_view.set(View::Detail); - } - Err(e) => show_toast(format!("Failed to load: {e}"), true), - } - }); - } - }, - on_delete: { - let client = client.read().clone(); - let refresh_media = refresh_media.clone(); - move |id: String| { - let client = client.clone(); - let refresh_media = refresh_media.clone(); - spawn(async move { - match client.delete_media(&id).await { - Ok(_) => { - show_toast("Media deleted".into(), false); - refresh_media(); - } - Err(e) => show_toast(format!("Delete failed: {e}"), true), - } - }); - } - }, - on_batch_delete: { - let client = client.read().clone(); - let refresh_media = refresh_media.clone(); - move |ids: Vec| { - let client = client.clone(); - let refresh_media = refresh_media.clone(); - spawn(async move { - match client.batch_delete(&ids).await { - Ok(resp) => { - show_toast(format!("Deleted {} items", resp.processed), false); - refresh_media(); - } - Err(e) => show_toast(format!("Batch delete failed: {e}"), true), - } - }); - } - }, - on_batch_tag: { - let client = client.read().clone(); - let refresh_media = refresh_media.clone(); - move |(ids, tag_ids): (Vec, Vec)| { - let client = client.clone(); - let refresh_media = refresh_media.clone(); - spawn(async move { - match client.batch_tag(&ids, &tag_ids).await { - Ok(resp) => { - show_toast(format!("Tagged {} items", resp.processed), false); - refresh_media(); - } - Err(e) => show_toast(format!("Batch tag failed: {e}"), true), - } - }); - } - }, - on_batch_collection: { - let client = client.read().clone(); - let refresh_media = refresh_media.clone(); - move |(ids, col_id): (Vec, String)| { - let client = client.clone(); - let refresh_media = refresh_media.clone(); - spawn(async move { - match client.batch_add_to_collection(&ids, &col_id).await { - Ok(resp) => { - show_toast( - format!("Added {} items to collection", resp.processed), - false, - ); - refresh_media(); - } - Err(e) => show_toast(format!("Failed: {e}"), true), - } - }); - } - }, - on_page_change: { - let client = client.read().clone(); - move |page: u64| { - media_page.set(page); - let client = client.clone(); - spawn(async move { - loading.set(true); - let offset = page * *media_page_size.read(); - let limit = *media_page_size.read(); - let sort = media_sort.read().clone(); - if let Ok(items) = client.list_media(offset, limit, Some(&sort)).await { - media_list.set(items); - } - loading.set(false); - }); - } - }, - on_page_size_change: { - let client = client.read().clone(); - move |size: u64| { - media_page_size.set(size); - media_page.set(0); - let client = client.clone(); - spawn(async move { - loading.set(true); - let sort = media_sort.read().clone(); - if let Ok(items) = client.list_media(0, size, Some(&sort)).await { - media_list.set(items); - } - loading.set(false); - }); - } - }, - on_sort_change: { - let client = client.read().clone(); - move |sort: String| { - media_sort.set(sort.clone()); - media_page.set(0); - let client = client.clone(); - spawn(async move { - loading.set(true); - let limit = *media_page_size.read(); - if let Ok(items) = client.list_media(0, limit, Some(&sort)).await { - media_list.set(items); - } - loading.set(false); - }); - } - }, - on_select_all_global: { - let client = client.read().clone(); - move |callback: EventHandler>| { - let client = client.clone(); - spawn(async move { - let total = *media_total_count.read(); - let sort = media_sort.read().clone(); - match client.list_media(0, total, Some(&sort)).await { - Ok(items) => { - let all_ids: Vec = items - .iter() - .map(|m| m.id.clone()) - .collect(); - callback.call(all_ids); - } - Err(e) => show_toast(format!("Failed to select all: {e}"), true), - } - }); - } - }, - on_delete_all: { - let client = client.read().clone(); - let refresh_media = refresh_media.clone(); - move |_: ()| { - let client = client.clone(); - let refresh_media = refresh_media.clone(); - spawn(async move { - match client.delete_all_media().await { - Ok(resp) => { - show_toast(format!("Deleted {} items", resp.processed), false); - refresh_media(); - } - Err(e) => show_toast(format!("Delete all failed: {e}"), true), - } - }); - } - }, - } - }, - View::Search => rsx! { - search::Search { - results: search_results.read().clone(), - total_count: *search_total.read(), - search_page: *search_page.read(), - page_size: *search_page_size.read(), - server_url: server_url.read().clone(), - on_search: { - let client = client.read().clone(); - move |(q, sort): (String, Option)| { - let client = client.clone(); - search_page.set(0); - last_search_query.set(q.clone()); - last_search_sort.set(sort.clone()); - spawn(async move { - loading.set(true); - let offset = 0; - let limit = *search_page_size.read(); - match client.search(&q, sort.as_deref(), offset, limit).await { - Ok(resp) => { - search_total.set(resp.total_count); - search_results.set(resp.items); - } - Err(e) => show_toast(format!("Search failed: {e}"), true), - } - loading.set(false); - }); - } - }, - on_select: { - let client = client.read().clone(); - move |id: String| { - let client = client.clone(); - spawn(async move { - if let Ok(item) = client.get_media(&id).await { - let mtags = client.get_media_tags(&id).await.unwrap_or_default(); - media_tags.set(mtags); - selected_media.set(Some(item)); - current_view.set(View::Detail); - } - }); - } - }, - on_page_change: { - let client = client.read().clone(); - move |page: u64| { - search_page.set(page); - let client = client.clone(); - spawn(async move { - loading.set(true); - let offset = page * *search_page_size.read(); - let limit = *search_page_size.read(); - let q = last_search_query.read().clone(); - let sort = last_search_sort.read().clone(); - match client.search(&q, sort.as_deref(), offset, limit).await { - Ok(resp) => { - search_total.set(resp.total_count); - search_results.set(resp.items); - } - Err(e) => show_toast(format!("Search failed: {e}"), true), - } - loading.set(false); - }); - } - }, - // Phase 3.6: Saved searches - saved_searches: saved_searches.read().clone(), - on_save_search: { - let client = client.read().clone(); - move |(name, query, sort): (String, String, Option)| { - let client = client.clone(); - spawn(async move { - match client.create_saved_search(&name, &query, sort.as_deref()).await { - Ok(ss) => { - saved_searches.write().push(ss); - show_toast(format!("Search '{}' saved", name), false); - } - Err(e) => show_toast(format!("Failed to save search: {e}"), true), - } - }); - } - }, - on_delete_saved_search: { - let client = client.read().clone(); - move |id: String| { - let client = client.clone(); - spawn(async move { - match client.delete_saved_search(&id).await { - Ok(_) => { - saved_searches.write().retain(|s| s.id != id); - show_toast("Search deleted".into(), false); - } - Err(e) => show_toast(format!("Failed to delete: {e}"), true), - } - }); - } - }, - on_load_saved_search: { - let client = client.read().clone(); - move |ss: SavedSearchResponse| { - let client = client.clone(); - let query = ss.query.clone(); - let sort = ss.sort_order.clone(); - search_page.set(0); - last_search_query.set(query.clone()); - last_search_sort.set(sort.clone()); - spawn(async move { - loading.set(true); - let offset = 0; - let limit = *search_page_size.read(); - match client.search(&query, sort.as_deref(), offset, limit).await { - Ok(resp) => { - search_total.set(resp.total_count); - search_results.set(resp.items); - } - Err(e) => show_toast(format!("Search failed: {e}"), true), - } - loading.set(false); - }); - } - }, - } - }, - View::Detail => { - let media_ref = selected_media.read(); - match media_ref.as_ref() { - Some(media) => rsx! { - detail::Detail { - media: media.clone(), - media_tags: media_tags.read().clone(), - all_tags: tags_list.read().clone(), - server_url: server_url.read().clone(), - autoplay: *auto_play_media.read(), - on_back: move |_| current_view.set(View::Library), - on_open: { - let client = client.read().clone(); - move |id: String| { - let client = client.clone(); - spawn(async move { - match client.open_media(&id).await { - Ok(_) => show_toast("File opened".into(), false), - Err(e) => show_toast(format!("Open failed: {e}"), true), - } - }); - } - }, - on_update: { - let client = client.read().clone(); - move |event: MediaUpdateEvent| { - let client = client.clone(); - spawn(async move { - match client.update_media(&event).await { - Ok(updated) => { - selected_media.set(Some(updated)); - show_toast("Metadata updated".into(), false); - } - Err(e) => show_toast(format!("Update failed: {e}"), true), - } - }); - } - }, - on_tag: { - let client = client.read().clone(); - move |(media_id, tag_id): (String, String)| { - let client = client.clone(); - spawn(async move { - match client.tag_media(&media_id, &tag_id).await { - Ok(_) => { - if let Ok(mtags) = client.get_media_tags(&media_id).await { - media_tags.set(mtags); - } - show_toast("Tag added".into(), false); - } - Err(e) => show_toast(format!("Failed: {e}"), true), - } - }); - } - }, - on_untag: { - let client = client.read().clone(); - move |(media_id, tag_id): (String, String)| { - let client = client.clone(); - spawn(async move { - match client.untag_media(&media_id, &tag_id).await { - Ok(_) => { - if let Ok(mtags) = client.get_media_tags(&media_id).await { - media_tags.set(mtags); - } - show_toast("Tag removed".into(), false); - } - Err(e) => show_toast(format!("Failed: {e}"), true), - } - }); - } - }, - on_set_custom_field: { - let client = client.read().clone(); - move |(media_id, name, field_type, value): (String, String, String, String)| { - let client = client.clone(); - spawn(async move { - match client - .set_custom_field(&media_id, &name, &field_type, &value) - .await - { - Ok(_) => { - if let Ok(updated) = client.get_media(&media_id).await { - selected_media.set(Some(updated)); - } - show_toast("Field added".into(), false); - } - Err(e) => show_toast(format!("Failed: {e}"), true), - } - }); - } - }, - on_delete_custom_field: { - let client = client.read().clone(); - move |(media_id, name): (String, String)| { - let client = client.clone(); - spawn(async move { - match client.delete_custom_field(&media_id, &name).await { - Ok(_) => { - if let Ok(updated) = client.get_media(&media_id).await { - selected_media.set(Some(updated)); - } - show_toast("Field removed".into(), false); - } - Err(e) => show_toast(format!("Failed: {e}"), true), - } - }); - } - }, - on_delete: { - let client = client.read().clone(); - let refresh_media = refresh_media.clone(); - move |id: String| { - let client = client.clone(); - let refresh_media = refresh_media.clone(); - spawn(async move { - match client.delete_media(&id).await { - Ok(_) => { - show_toast("Media deleted".into(), false); - selected_media.set(None); - current_view.set(View::Library); - refresh_media(); - } - Err(e) => show_toast(format!("Delete failed: {e}"), true), - } - }); - } - }, - play_queue: if play_queue.read().is_empty() { None } else { Some(play_queue.read().clone()) }, - on_queue_select: { - move |idx: usize| { - let mut q = play_queue.write(); - q.current_index = idx; - // Update selected_media to the item at this index - if let Some(item) = q.items.get(idx) { - let media_id = item.media_id.clone(); - let client = client.read().clone(); - spawn(async move { - if let Ok(media) = client.get_media(&media_id).await { - selected_media.set(Some(media)); - auto_play_media.set(true); - } - }); - } - } - }, - on_queue_remove: { - move |idx: usize| { - play_queue.write().remove(idx); - } - }, - on_queue_clear: { - move |_| { - play_queue.write().clear(); - } - }, - on_queue_toggle_repeat: { - move |_| { - play_queue.write().toggle_repeat(); - } - }, - on_queue_toggle_shuffle: { - move |_| { - play_queue.write().toggle_shuffle(); - } - }, - on_queue_next: { - move |_| { - let mut q = play_queue.write(); - if let Some(item) = q.next() { - let media_id = item.media_id.clone(); - drop(q); - let client = client.read().clone(); - spawn(async move { - if let Ok(media) = client.get_media(&media_id).await { - selected_media.set(Some(media)); - auto_play_media.set(true); - } - }); - } - } - }, - on_queue_previous: { - move |_| { - let mut q = play_queue.write(); - if let Some(item) = q.previous() { - let media_id = item.media_id.clone(); - drop(q); - let client = client.read().clone(); - spawn(async move { - if let Ok(media) = client.get_media(&media_id).await { - selected_media.set(Some(media)); - auto_play_media.set(true); - } - }); - } - } - }, - on_track_ended: { - move |_| { - let mut q = play_queue.write(); - if let Some(item) = q.next() { - let media_id = item.media_id.clone(); - drop(q); - let client = client.read().clone(); - spawn(async move { - if let Ok(media) = client.get_media(&media_id).await { - selected_media.set(Some(media)); - auto_play_media.set(true); - } - }); - } - } - }, - on_add_to_queue: { - move |item: crate::components::media_player::QueueItem| { - play_queue.write().add(item); - show_toast("Added to queue".into(), false); - } - }, - on_navigate_to_media: { - let client = client.read().clone(); - move |media_id: String| { - let client = client.clone(); - spawn(async move { - match client.get_media(&media_id).await { - Ok(media) => { - // Load tags for the new media - if let Ok(mtags) = client.get_media_tags(&media_id).await { - media_tags.set(mtags); - } - selected_media.set(Some(media)); - auto_play_media.set(false); - } - // Extract file name from path - - // Check if already importing - if so, add to queue - - // Extract directory name from path - - // Check if already importing - if so, add to queue - - // Get preview files if available for per-file progress - - // Use parallel import with per-batch progress - - // Show first file in batch as current - - // Process batch in parallel - - // Update progress after batch - - // Fallback: use server-side directory import (no per-file progress) - // Check if already importing - if so, add to queue - - // Update progress from scan status - - // Check if already importing - if so, add to queue - - // Process files in parallel batches for better performance - - // Show first file in batch as current - - // Process batch in parallel - - // Update progress after batch - - // Extended import state - - - - // Load tags for the media - - - - - - - - - - - - - - - - - - Err(e) => { - - - show_toast(format!("Failed to load linked note: {e}"), true) - } - } - }); - } - }, - } - }, - None => rsx! { - div { class: "empty-state", - h3 { class: "empty-title", "No media selected" } - } - }, - } - } - View::Tags => rsx! { - tags::Tags { - tags: tags_list.read().clone(), - on_create: { - let client = client.read().clone(); - let refresh_tags = refresh_tags.clone(); - move |(name, parent_id): (String, Option)| { - let client = client.clone(); - let refresh_tags = refresh_tags.clone(); - spawn(async move { - match client.create_tag(&name, parent_id.as_deref()).await { - Ok(_) => { - show_toast("Tag created".into(), false); - refresh_tags(); - } - Err(e) => show_toast(format!("Failed: {e}"), true), - } - }); - } - }, - on_delete: { - let client = client.read().clone(); - let refresh_tags = refresh_tags.clone(); - move |id: String| { - let client = client.clone(); - let refresh_tags = refresh_tags.clone(); - spawn(async move { - match client.delete_tag(&id).await { - Ok(_) => { - show_toast("Tag deleted".into(), false); - refresh_tags(); - } - Err(e) => show_toast(format!("Failed: {e}"), true), - } - }); - } - }, - } - }, - View::Collections => rsx! { - collections::Collections { - collections: collections_list.read().clone(), - collection_members: collection_members.read().clone(), - viewing_collection: viewing_collection.read().clone(), - all_media: media_list.read().clone(), - on_create: { - let client = client.read().clone(); - let refresh_collections = refresh_collections.clone(); - move | - (name, kind, desc, filter): (String, String, Option, Option)| - { - let client = client.clone(); - let refresh_collections = refresh_collections.clone(); - spawn(async move { - match client - .create_collection(&name, &kind, desc.as_deref(), filter.as_deref()) - .await - { - Ok(_) => { - show_toast("Collection created".into(), false); - refresh_collections(); - } - Err(e) => show_toast(format!("Failed: {e}"), true), - } - }); - } - }, - on_delete: { - let client = client.read().clone(); - let refresh_collections = refresh_collections.clone(); - move |id: String| { - let client = client.clone(); - let refresh_collections = refresh_collections.clone(); - spawn(async move { - match client.delete_collection(&id).await { - Ok(_) => { - show_toast("Collection deleted".into(), false); - refresh_collections(); - } - Err(e) => show_toast(format!("Failed: {e}"), true), - } - }); - } - }, - on_view_members: { - let client = client.read().clone(); - move |col_id: String| { - let client = client.clone(); - let col_id2 = col_id.clone(); - spawn(async move { - match client.get_collection_members(&col_id2).await { - Ok(members) => { - collection_members.set(members); - viewing_collection.set(Some(col_id2)); - } - Err(e) => show_toast(format!("Failed: {e}"), true), - } - }); - } - }, - on_back_to_list: move |_| { - viewing_collection.set(None); - collection_members.set(Vec::new()); - }, - on_remove_member: { - let client = client.read().clone(); - move |(col_id, media_id): (String, String)| { - let client = client.clone(); - let col_id2 = col_id.clone(); - spawn(async move { - match client.remove_from_collection(&col_id, &media_id).await { - Ok(_) => { - show_toast("Removed from collection".into(), false); - if let Ok(members) = client - .get_collection_members(&col_id2) - .await - { - collection_members.set(members); - } - } - Err(e) => show_toast(format!("Failed: {e}"), true), - } - }); - } - }, - on_select: { - let client = client.read().clone(); - move |id: String| { - let client = client.clone(); - spawn(async move { - if let Ok(item) = client.get_media(&id).await { - let mtags = client.get_media_tags(&id).await.unwrap_or_default(); - media_tags.set(mtags); - selected_media.set(Some(item)); - current_view.set(View::Detail); - } - }); - } - }, - on_add_member: { - let client = client.read().clone(); - move |(col_id, media_id): (String, String)| { - let client = client.clone(); - let col_id2 = col_id.clone(); - spawn(async move { - match client.add_to_collection(&col_id, &media_id, 0).await { - Ok(_) => { - show_toast("Added to collection".into(), false); - if let Ok(members) = client - .get_collection_members(&col_id2) - .await - { - collection_members.set(members); - } - } - Err(e) => show_toast(format!("Failed: {e}"), true), - } - }); - } - }, - } - }, - View::Audit => { - let page_size = *audit_page_size.read(); - let total = *audit_total_count.read(); - let total_pages = if page_size > 0 { - total.div_ceil(page_size) - } else { - 1 - }; - rsx! { - audit::AuditLog { - entries: audit_list.read().clone(), - audit_page: *audit_page.read(), - total_pages, - audit_filter: audit_filter.read().clone(), - on_select: { - let client = client.read().clone(); - move |id: String| { - let client = client.clone(); - spawn(async move { - if let Ok(item) = client.get_media(&id).await { - let mtags = client.get_media_tags(&id).await.unwrap_or_default(); - media_tags.set(mtags); - selected_media.set(Some(item)); - current_view.set(View::Detail); - } - }); - } - }, - on_page_change: { - let refresh_audit = refresh_audit.clone(); - move |page: u64| { - audit_page.set(page); - refresh_audit(); - } - }, - on_filter_change: { - let refresh_audit = refresh_audit.clone(); - move |filter: String| { - audit_filter.set(filter); - audit_page.set(0); - refresh_audit(); - } - }, - } - } - } - View::Import => rsx! { - import::Import { - tags: tags_list.read().clone(), - collections: collections_list.read().clone(), - scan_progress: scan_progress.read().clone(), - is_importing: *import_in_progress.read(), - on_import_file: { - let client = client.read().clone(); - let refresh_media = refresh_media.clone(); - let refresh_tags = refresh_tags.clone(); - move |(path, tag_ids, new_tags, col_id): ImportEvent| { - let file_name = path.rsplit('/').next().unwrap_or(&path).to_string(); - - if *import_in_progress.read() { - - import_queue.write().push(file_name); - show_toast("Added to import queue".into(), false); - return; - } - - let client = client.clone(); - let refresh_media = refresh_media.clone(); - let refresh_tags = refresh_tags.clone(); - import_in_progress.set(true); - import_current_file.set(Some(file_name)); - import_progress.set((0, 1)); - spawn(async move { - if tag_ids.is_empty() && new_tags.is_empty() && col_id.is_none() { - match client.import_file(&path).await { - Ok(resp) => { - if resp.was_duplicate { - show_toast( - "Duplicate file (already imported)".into(), - false, - ); - } else { - show_toast(format!("Imported: {}", resp.media_id), false); - } - refresh_media(); - } - Err(e) => show_toast(format!("Import failed: {e}"), true), - } - } else { - match client - .import_with_options( - &path, - &tag_ids, - &new_tags, - col_id.as_deref(), - ) - .await - { - Ok(resp) => { - if resp.was_duplicate { - show_toast( - "Duplicate file (already imported)".into(), - false, - ); - } else { - show_toast( - format!("Imported with tags/collection: {}", resp.media_id), - false, - ); - } - refresh_media(); - if !new_tags.is_empty() { - refresh_tags(); - } - } - Err(e) => show_toast(format!("Import failed: {e}"), true), - } - } - import_progress.set((1, 1)); - import_current_file.set(None); - import_in_progress.set(false); - }); - } - }, - on_import_directory: { - let client = client.read().clone(); - let refresh_media = refresh_media.clone(); - let refresh_tags = refresh_tags.clone(); - move |(path, tag_ids, new_tags, col_id): ImportEvent| { - let dir_name = path.rsplit('/').next().unwrap_or(&path).to_string(); - - if *import_in_progress.read() { - import_queue.write().push(format!("{dir_name}/ (directory)")); - show_toast("Added directory to import queue".into(), false); - return; - } - - let files_to_import: Vec = preview_files - .read() - .iter() - .map(|f| f.path.clone()) - .collect(); - - let client = client.clone(); - let refresh_media = refresh_media.clone(); - let refresh_tags = refresh_tags.clone(); - import_in_progress.set(true); - - if !files_to_import.is_empty() { - let file_count = files_to_import.len(); - import_progress.set((0, file_count)); - - let client = Arc::new(client); - let tag_ids = Arc::new(tag_ids); - let new_tags = Arc::new(new_tags); - let col_id = Arc::new(col_id); - - const BATCH_SIZE: usize = 6; - spawn(async move { - let imported = Arc::new(AtomicUsize::new(0)); - let duplicates = Arc::new(AtomicUsize::new(0)); - let errors = Arc::new(AtomicUsize::new(0)); - let completed = Arc::new(AtomicUsize::new(0)); - - for chunk in files_to_import.chunks(BATCH_SIZE) { - if let Some(first_path) = chunk.first() { - let file_name = first_path - - .rsplit('/') - .next() - .unwrap_or(first_path); - import_current_file.set(Some(file_name.to_string())); - } - let futures: Vec<_> = chunk - .iter() - .map(|file_path| { - let client = Arc::clone(&client); - let tag_ids = Arc::clone(&tag_ids); - let new_tags = Arc::clone(&new_tags); - let col_id = Arc::clone(&col_id); - let imported = Arc::clone(&imported); - let duplicates = Arc::clone(&duplicates); - let errors = Arc::clone(&errors); - let completed = Arc::clone(&completed); - let file_path = file_path.clone(); - async move { - let result = if tag_ids.is_empty() && new_tags.is_empty() - && col_id.is_none() - { - client.import_file(&file_path).await - } else { - client - .import_with_options( - &file_path, - &tag_ids, - &new_tags, - col_id.as_deref(), - ) - .await - }; - match result { - Ok(resp) => { - if resp.was_duplicate { - duplicates.fetch_add(1, Ordering::Relaxed); - } else { - imported.fetch_add(1, Ordering::Relaxed); - } - } - Err(_) => { - errors.fetch_add(1, Ordering::Relaxed); - } - } - completed.fetch_add(1, Ordering::Relaxed); - } - }) - .collect(); - join_all(futures).await; - let done = completed.load(Ordering::Relaxed); - import_progress.set((done, file_count)); - } - let imported = imported.load(Ordering::Relaxed); - let duplicates = duplicates.load(Ordering::Relaxed); - let errors = errors.load(Ordering::Relaxed); - show_toast( - format!( - "Done: {imported} imported, {duplicates} duplicates, {errors} errors", - ), - errors > 0, - ); - refresh_media(); - if !new_tags.is_empty() { - refresh_tags(); - } - preview_files.set(Vec::new()); - preview_total_size.set(0); - import_progress.set((file_count, file_count)); - import_current_file.set(None); - import_in_progress.set(false); - }); - } else { - import_current_file.set(Some(format!("{dir_name}/"))); - import_progress.set((0, 0)); - spawn(async move { - match client - .import_directory(&path, &tag_ids, &new_tags, col_id.as_deref()) - .await - { - Ok(resp) => { - show_toast( - format!( - "Done: {} imported, {} duplicates, {} errors", - resp.imported, - resp.duplicates, - resp.errors, - ), - resp.errors > 0, - ); - refresh_media(); - if !new_tags.is_empty() { - refresh_tags(); - } - preview_files.set(Vec::new()); - preview_total_size.set(0); - } - Err(e) => { - show_toast(format!("Directory import failed: {e}"), true) - } - } - import_current_file.set(None); - import_progress.set((0, 0)); - import_in_progress.set(false); - }); - } - } - }, - on_scan: { - let client = client.read().clone(); - let refresh_media = refresh_media.clone(); - move |_| { - if *import_in_progress.read() { - import_queue.write().push("Scan roots".to_string()); - show_toast("Added scan to import queue".into(), false); - return; - } - - let client = client.clone(); - let refresh_media = refresh_media.clone(); - import_in_progress.set(true); - import_current_file.set(Some("Scanning roots...".to_string())); - import_progress.set((0, 0)); // Will be updated from scan_progress - spawn(async move { - match client.trigger_scan().await { - Ok(_results) => { - loop { - match client.scan_status().await { - Ok(status) => { - let done = !status.scanning; - import_progress - .set((status.files_processed, status.files_found)); - if status.files_found > 0 { - import_current_file - .set( - Some( - format!( - "Scanning ({}/{})", - status.files_processed, - status.files_found, - ), - ), - ); - } - scan_progress.set(Some(status.clone())); - if done { - let total = status.files_processed; - show_toast( - format!("Scan complete: {total} files processed"), - false, - ); - break; - } - } - Err(_) => break, - } - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - } - refresh_media(); - } - Err(e) => show_toast(format!("Scan failed: {e}"), true), - } - import_current_file.set(None); - import_progress.set((0, 0)); - import_in_progress.set(false); - }); - } - }, - on_import_batch: { - let client = client.read().clone(); - let refresh_media = refresh_media.clone(); - let refresh_tags = refresh_tags.clone(); - move |(paths, tag_ids, new_tags, col_id): import::BatchImportEvent| { - let file_count = paths.len(); - - if *import_in_progress.read() { - import_queue.write().push(format!("{file_count} files (batch)")); - show_toast("Added batch to import queue".into(), false); - return; - } - - let client = Arc::new(client.clone()); - let refresh_media = refresh_media.clone(); - let refresh_tags = refresh_tags.clone(); - let tag_ids = Arc::new(tag_ids); - let new_tags = Arc::new(new_tags); - let col_id = Arc::new(col_id); - import_in_progress.set(true); - import_progress.set((0, file_count)); - - const BATCH_SIZE: usize = 6; - spawn(async move { - let imported = Arc::new(AtomicUsize::new(0)); - let duplicates = Arc::new(AtomicUsize::new(0)); - let errors = Arc::new(AtomicUsize::new(0)); - let completed = Arc::new(AtomicUsize::new(0)); - - for chunk in paths.chunks(BATCH_SIZE) { - if let Some(first_path) = chunk.first() { - let file_name = first_path - - .rsplit('/') - .next() - .unwrap_or(first_path); - import_current_file.set(Some(file_name.to_string())); - } - let futures: Vec<_> = chunk - .iter() - .map(|path| { - let client = Arc::clone(&client); - let tag_ids = Arc::clone(&tag_ids); - let new_tags = Arc::clone(&new_tags); - let col_id = Arc::clone(&col_id); - let imported = Arc::clone(&imported); - let duplicates = Arc::clone(&duplicates); - let errors = Arc::clone(&errors); - let completed = Arc::clone(&completed); - let path = path.clone(); - async move { - let result = if tag_ids.is_empty() && new_tags.is_empty() - && col_id.is_none() - { - client.import_file(&path).await - } else { - client - .import_with_options( - &path, - &tag_ids, - &new_tags, - col_id.as_deref(), - ) - .await - }; - match result { - Ok(resp) => { - if resp.was_duplicate { - duplicates.fetch_add(1, Ordering::Relaxed); - } else { - imported.fetch_add(1, Ordering::Relaxed); - } - } - Err(_) => { - errors.fetch_add(1, Ordering::Relaxed); - } - } - completed.fetch_add(1, Ordering::Relaxed); - } - }) - .collect(); - join_all(futures).await; - let done = completed.load(Ordering::Relaxed); - import_progress.set((done, file_count)); - } - let imported = imported.load(Ordering::Relaxed); - let duplicates = duplicates.load(Ordering::Relaxed); - let errors = errors.load(Ordering::Relaxed); - show_toast( - format!( - "Done: {imported} imported, {duplicates} duplicates, {errors} errors", - ), - errors > 0, - ); - refresh_media(); - if !new_tags.is_empty() { - refresh_tags(); - } - preview_files.set(Vec::new()); - preview_total_size.set(0); - import_progress.set((file_count, file_count)); - import_current_file.set(None); - import_in_progress.set(false); - }); - } - }, - on_preview_directory: { - let client = client.read().clone(); - move |(path, recursive): (String, bool)| { - let client = client.clone(); - spawn(async move { - match client.preview_directory(&path, recursive).await { - Ok(resp) => { - preview_total_size.set(resp.total_size); - preview_files.set(resp.files); - } - Err(e) => { - show_toast(format!("Preview failed: {e}"), true); - preview_files.set(Vec::new()); - preview_total_size.set(0); - } - } - }); - } - }, - preview_files: preview_files.read().clone(), - preview_total_size: *preview_total_size.read(), - current_file: import_current_file.read().clone(), - import_queue: import_queue.read().clone(), - import_progress: *import_progress.read(), - } - }, - View::Database => { - let refresh_db_stats = { - let client = client.read().clone(); - move || { - let client = client.clone(); - spawn(async move { - match client.database_stats().await { - Ok(stats) => db_stats.set(Some(stats)), - Err(e) => { - show_toast(format!("Failed to load stats: {e}"), true) - } - } - }); - } - }; - rsx! { - database::Database { - stats: db_stats.read().clone(), - on_refresh: { - let refresh_db_stats = refresh_db_stats.clone(); - move |_| refresh_db_stats() - }, - on_vacuum: { - let client = client.read().clone(); - let refresh_db_stats = refresh_db_stats.clone(); - move |_| { - let client = client.clone(); - let refresh_db_stats = refresh_db_stats.clone(); - spawn(async move { - show_toast("Vacuuming database...".into(), false); - match client.vacuum_database().await { - Ok(()) => { - show_toast("Vacuum complete".into(), false); - refresh_db_stats(); - } - Err(e) => show_toast(format!("Vacuum failed: {e}"), true), - } - }); - } - }, - on_clear: { - let client = client.read().clone(); - let refresh_media = refresh_media.clone(); - let refresh_tags = refresh_tags.clone(); - let refresh_collections = refresh_collections.clone(); - let refresh_db_stats = refresh_db_stats.clone(); - move |_| { - let client = client.clone(); - let refresh_media = refresh_media.clone(); - let refresh_tags = refresh_tags.clone(); - let refresh_collections = refresh_collections.clone(); - let refresh_db_stats = refresh_db_stats.clone(); - spawn(async move { - match client.clear_database().await { - Ok(()) => { - show_toast("All data cleared".into(), false); - refresh_media(); - refresh_tags(); - refresh_collections(); - refresh_db_stats(); - } - Err(e) => show_toast(format!("Clear failed: {e}"), true), - } - }); - } - }, - on_backup: { - move |_path: String| { - show_toast("Backup not yet implemented on server".into(), false); - } - }, - } - } - } - View::Duplicates => { - rsx! { - duplicates::Duplicates { - groups: duplicate_groups.read().clone(), - server_url: server_url.read().clone(), - on_delete: { - let client = client.read().clone(); - move |media_id: String| { - let client = client.clone(); - spawn(async move { - match client.delete_media(&media_id).await { - Ok(_) => { - show_toast("Deleted duplicate".into(), false); - if let Ok(groups) = client.list_duplicates().await { - duplicate_groups.set(groups); - } - } - Err(e) => show_toast(format!("Delete failed: {e}"), true), - } - }); - } - }, - on_refresh: { - let client = client.read().clone(); - move |_| { - let client = client.clone(); - spawn(async move { - match client.list_duplicates().await { - Ok(groups) => duplicate_groups.set(groups), - Err(e) => show_toast(format!("Failed to load duplicates: {e}"), true), - } - }); - } - }, - } - } - } - View::Statistics => { - let refresh_stats = { - let client = client.read().clone(); - move || { - let client = client.clone(); - spawn(async move { - match client.library_statistics().await { - Ok(stats) => library_stats.set(Some(stats)), - Err(e) => { - show_toast(format!("Failed to load statistics: {e}"), true) - } - } - }); - } - }; - rsx! { - statistics::Statistics { - stats: library_stats.read().clone(), - on_refresh: { - let refresh_stats = refresh_stats.clone(); - move |_| refresh_stats() - }, - } - } - } - View::Tasks => { - let refresh_tasks = { - let client = client.read().clone(); - move || { - let client = client.clone(); - spawn(async move { - match client.list_scheduled_tasks().await { - Ok(tasks_data) => scheduled_tasks.set(tasks_data), - Err(e) => { - show_toast(format!("Failed to load tasks: {e}"), true) - } - } - }); - } - }; - rsx! { - tasks::Tasks { - tasks: scheduled_tasks.read().clone(), - on_refresh: { - let refresh_tasks = refresh_tasks.clone(); - move |_| refresh_tasks() - }, - on_toggle: { - let client = client.read().clone(); - let refresh_tasks = refresh_tasks.clone(); - move |task_id: String| { - let client = client.clone(); - let refresh_tasks = refresh_tasks.clone(); - spawn(async move { - match client.toggle_scheduled_task(&task_id).await { - Ok(_) => { - show_toast("Task toggled".into(), false); - refresh_tasks(); - } - Err(e) => show_toast(format!("Toggle failed: {e}"), true), - } - }); - } - }, - on_run_now: { - let client = client.read().clone(); - let refresh_tasks = refresh_tasks.clone(); - move |task_id: String| { - let client = client.clone(); - let refresh_tasks = refresh_tasks.clone(); - spawn(async move { - match client.run_scheduled_task_now(&task_id).await { - Ok(_) => { - show_toast("Task started".into(), false); - refresh_tasks(); - } - Err(e) => show_toast(format!("Run failed: {e}"), true), - } - }); - } - }, - } - } - } - View::Settings => { - let cfg_ref = config_data.read(); - match cfg_ref.as_ref() { - Some(cfg) => rsx! { - settings::Settings { - config: cfg.clone(), - on_add_root: { - let client = client.read().clone(); - move |path: String| { - let client = client.clone(); - spawn(async move { - match client.add_root(&path).await { - Ok(new_cfg) => { - config_data.set(Some(new_cfg)); - show_toast("Root added".into(), false); - } - Err(e) => show_toast(format!("Failed: {e}"), true), - } - }); - } - }, - on_remove_root: { - let client = client.read().clone(); - move |path: String| { - let client = client.clone(); - spawn(async move { - match client.remove_root(&path).await { - Ok(new_cfg) => { - config_data.set(Some(new_cfg)); - show_toast("Root removed".into(), false); - } - Err(e) => show_toast(format!("Failed: {e}"), true), - } - }); - } - }, - on_toggle_watch: { - let client = client.read().clone(); - move |enabled: bool| { - let client = client.clone(); - spawn(async move { - match client.update_scanning(Some(enabled), None, None).await { - Ok(new_cfg) => { - config_data.set(Some(new_cfg)); - let state = if enabled { "enabled" } else { "disabled" }; - show_toast(format!("Watching {state}"), false); - } - Err(e) => show_toast(format!("Failed: {e}"), true), - } - }); - } - }, - on_update_poll_interval: { - let client = client.read().clone(); - move |secs: u64| { - let client = client.clone(); - spawn(async move { - match client.update_scanning(None, Some(secs), None).await { - Ok(new_cfg) => { - config_data.set(Some(new_cfg)); - show_toast(format!("Poll interval set to {secs}s"), false); - } - Err(e) => show_toast(format!("Failed: {e}"), true), - } - }); - } - }, - on_update_ignore_patterns: { - let client = client.read().clone(); - move |patterns: Vec| { - let client = client.clone(); - spawn(async move { - match client.update_scanning(None, None, Some(patterns)).await { - Ok(new_cfg) => { - config_data.set(Some(new_cfg)); - show_toast("Ignore patterns updated".into(), false); - } - Err(e) => show_toast(format!("Failed: {e}"), true), - } - }); - } - }, - on_update_ui_config: { - let client = client.read().clone(); - move |updates: serde_json::Value| { - let client = client.clone(); - spawn(async move { - match client.update_ui_config(updates).await { - Ok(ui_cfg) => { - auto_play_media.set(ui_cfg.auto_play_media); - sidebar_collapsed.set(ui_cfg.sidebar_collapsed); - current_theme.set(ui_cfg.theme.clone()); - if let Ok(cfg) = client.get_config().await { - config_data.set(Some(cfg)); - } - show_toast("UI preferences updated".into(), false); - } - Err(e) => show_toast(format!("Failed: {e}"), true), - } - }); - } - }, - } - }, - None => rsx! { - div { class: "empty-state", - h3 { class: "empty-title", "Loading settings..." } - } - }, - } - } - View::Graph => { - rsx! { - graph_view::GraphView { - client: client.read().clone(), - center_id: None, - on_navigate: { - let client = client.read().clone(); - move |media_id: String| { - let client = client.clone(); - spawn(async move { - match client.get_media(&media_id).await { - Ok(media) => { - if let Ok(mtags) = client.get_media_tags(&media_id).await { - media_tags.set(mtags); - } - selected_media.set(Some(media)); - current_view.set(View::Detail); - } - Err(e) => show_toast(format!("Failed to load: {e}"), true), - } - }); - } - }, - } - } - } - } - } - } - } - - // Phase 7.1: Help overlay - if *show_help.read() { - div { - class: "help-overlay", - onclick: move |_| show_help.set(false), - div { - class: "help-dialog", - onclick: move |evt: MouseEvent| evt.stop_propagation(), - h3 { "Keyboard Shortcuts" } - div { class: "help-shortcuts", - h4 { "Navigation" } - div { class: "shortcut-row", - kbd { "Esc" } - span { "Go back / close overlay" } - } - div { class: "shortcut-row", - kbd { "/" } - span { "Focus search" } - } - div { class: "shortcut-row", - kbd { "Ctrl+K" } - span { "Focus search (alternative)" } - } - div { class: "shortcut-row", - kbd { "Ctrl+," } - span { "Open settings" } - } - div { class: "shortcut-row", - kbd { "?" } - span { "Toggle this help" } - } - - h4 { "Quick Views" } - div { class: "shortcut-row", - kbd { "1" } - span { "Library" } - } - div { class: "shortcut-row", - kbd { "2" } - span { "Search" } - } - div { class: "shortcut-row", - kbd { "3" } - span { "Import" } - } - div { class: "shortcut-row", - kbd { "4" } - span { "Tags" } - } - div { class: "shortcut-row", - kbd { "5" } - span { "Collections" } - } - div { class: "shortcut-row", - kbd { "6" } - span { "Audit Log" } - } - } - button { - class: "help-close", - onclick: move |_| show_help.set(false), - "Close" - } - } - } - } - } - } // end else (auth not required) - - // Phase 1.4: Toast queue - show up to 3 stacked from bottom - div { class: "toast-container", - { - let toasts = toast_queue.read().clone(); - let visible: Vec<_> = toasts.iter().rev().take(3).rev().cloned().collect(); - rsx! { - for (msg , is_error , id) in visible { - div { key: "{id}", class: if is_error { "toast error" } else { "toast success" }, "{msg}" } - } - } - } - } + }); + + // Compute effective theme based on preference + let effective_theme = use_memo(move || { + let theme = current_theme.read().clone(); + if theme == "system" { + if *system_prefers_dark.read() { + "dark".to_string() + } else { + "light".to_string() + } + } else { + theme } + }); + + // Import state for UI feedback + let mut import_in_progress = use_signal(|| false); + // Extended import state: current file name, queue of pending imports, + // progress (completed, total) + let mut import_current_file = use_signal(|| Option::::None); + let mut import_queue = use_signal(Vec::::new); + let mut import_progress = use_signal(|| (0usize, 0usize)); // (completed, total) + + // Check auth on startup + let client_auth = client.read().clone(); + use_effect(move || { + let client = client_auth.clone(); + spawn(async move { + match client.get_current_user().await { + Ok(user) => { + current_user.set(Some(user)); + auth_required.set(false); + }, + Err(e) => { + // Check if this is an auth error (401) vs network error + let err_str = e.to_string(); + if err_str.contains("401") + || err_str.contains("unauthorized") + || err_str.contains("Unauthorized") + { + auth_required.set(true); + } + // For network errors, don't require auth (server offline state + // handles this) + }, + } + // Load UI config + if let Ok(cfg) = client.get_config().await { + auto_play_media.set(cfg.ui.auto_play_media); + sidebar_collapsed.set(cfg.ui.sidebar_collapsed); + current_theme.set(cfg.ui.theme.clone()); + if cfg.ui.default_page_size > 0 { + media_page_size.set(cfg.ui.default_page_size as u64); + } + config_data.set(Some(cfg)); + } + }); + }); + + // Health check polling + let client_health = client.read().clone(); + use_effect(move || { + let client = client_health.clone(); + spawn(async move { + loop { + server_checking.set(true); + let ok = client.health_check().await; + server_connected.set(ok); + server_checking.set(false); + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + } + }); + }); + + // Load initial data (Phase 2.2: pass sort to list_media) + let client_init = client.read().clone(); + let init_sort = media_sort.read().clone(); + use_effect(move || { + let client = client_init.clone(); + let sort = init_sort.clone(); + spawn(async move { + loading.set(true); + load_error.set(None); + match client.list_media(0, 48, Some(&sort)).await { + Ok(items) => media_list.set(items), + Err(e) => { + load_error.set(Some(format!("Failed to load media: {e}"))); + }, + } + if let Ok(count) = client.get_media_count().await { + media_total_count.set(count); + } + if let Ok(t) = client.list_tags().await { + tags_list.set(t); + } + if let Ok(c) = client.list_collections().await { + collections_list.set(c); + } + // Phase 3.6: Load saved searches + if let Ok(ss) = client.list_saved_searches().await { + saved_searches.set(ss); + } + loading.set(false); + }); + }); + + // Phase 1.4: Toast helper with queue support + let mut show_toast = move |msg: String, is_error: bool| { + let id = TOAST_ID_COUNTER.fetch_add(1, Ordering::Relaxed); + toast_queue.write().push((msg, is_error, id)); + // Keep at most 3 toasts + let len = toast_queue.read().len(); + if len > 3 { + toast_queue.write().drain(0..len - 3); + } + spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + toast_queue.write().retain(|(_, _, tid)| *tid != id); + }); + }; + + // Helper: refresh media list with current pagination (Phase 2.2: pass sort) + let refresh_media = { + let client = client.read().clone(); + move || { + let client = client.clone(); + spawn(async move { + loading.set(true); + let offset = *media_page.read() * *media_page_size.read(); + let limit = *media_page_size.read(); + let sort = media_sort.read().clone(); + if let Ok(items) = client.list_media(offset, limit, Some(&sort)).await { + media_list.set(items); + } + if let Ok(count) = client.get_media_count().await { + media_total_count.set(count); + } + loading.set(false); + }); + } + }; + + // Helper: refresh tags + let refresh_tags = { + let client = client.read().clone(); + move || { + let client = client.clone(); + spawn(async move { + if let Ok(t) = client.list_tags().await { + tags_list.set(t); + } + }); + } + }; + + // Helper: refresh collections + let refresh_collections = { + let client = client.read().clone(); + move || { + let client = client.clone(); + spawn(async move { + if let Ok(c) = client.list_collections().await { + collections_list.set(c); + } + }); + } + }; + + // Helper: refresh audit with pagination and filter (Phase 6.1) + let refresh_audit = { + let client = client.read().clone(); + move || { + let client = client.clone(); + spawn(async move { + let offset = *audit_page.read() * *audit_page_size.read(); + let limit = *audit_page_size.read(); + if let Ok(entries) = client.list_audit(offset, limit).await { + audit_list.set(entries); + } + }); + } + }; + + // Login handler for auth flow + let on_login_submit = { + move |(username, password): (String, String)| { + let login_client = client.read().clone(); + spawn(async move { + login_loading.set(true); + login_error.set(None); + + match login_client.login(&username, &password).await { + Ok(resp) => { + // Update the signal with a new client that has the token set + client.write().set_token(&resp.token); + current_user.set(Some(UserInfoResponse { + username: resp.username, + role: resp.role, + })); + auth_required.set(false); + }, + Err(e) => { + login_error.set(Some(format!("Login failed: {e}"))); + }, + } + login_loading.set(false); + }); + } + }; + + let view_title = use_memo(move || current_view.read().title()); + let _total_pages = use_memo(move || { + let ps = *media_page_size.read(); + let tc = *media_total_count.read(); + if ps > 0 { tc.div_ceil(ps) } else { 1 } + }); + + rsx! { + document::Stylesheet { href: styles::STYLES } + + if *auth_required.read() { + crate::components::login::Login { + on_login: on_login_submit, + error: login_error.read().clone(), + loading: *login_loading.read(), + } + } else { + // Phase 7.1: Keyboard shortcuts + div { + class: if *effective_theme.read() == "light" { "app theme-light" } else { "app" }, + tabindex: "0", + onkeydown: { + move |evt: KeyboardEvent| { + let key = evt.key(); + let ctrl = evt.modifiers().contains(Modifiers::CONTROL); + let meta = evt.modifiers().contains(Modifiers::META); + let shift = evt.modifiers().contains(Modifiers::SHIFT); + + match key { + // Escape - close modal/go back + Key::Escape => { + if *show_help.read() { + show_help.set(false); + } else if *current_view.read() == View::Detail { + current_view.set(View::Library); + } + } + // / or Ctrl+K - focus search + Key::Character(ref c) if c == "/" && !ctrl && !meta => { + evt.prevent_default(); + current_view.set(View::Search); + } + Key::Character(ref c) if c == "k" && (ctrl || meta) => { + evt.prevent_default(); + current_view.set(View::Search); + } + // ? - toggle help overlay + Key::Character(ref c) if c == "?" && !ctrl && !meta => { + show_help.toggle(); + } + // Ctrl+, - open settings + Key::Character(ref c) if c == "," && (ctrl || meta) => { + evt.prevent_default(); + current_view.set(View::Settings); + } + // Number keys 1-6 for quick view switching (without modifiers) + Key::Character(ref c) if c == "1" && !ctrl && !meta && !shift => { + evt.prevent_default(); + current_view.set(View::Library); + } + Key::Character(ref c) if c == "2" && !ctrl && !meta && !shift => { + evt.prevent_default(); + current_view.set(View::Search); + } + Key::Character(ref c) if c == "3" && !ctrl && !meta && !shift => { + evt.prevent_default(); + current_view.set(View::Import); + } + Key::Character(ref c) if c == "4" && !ctrl && !meta && !shift => { + evt.prevent_default(); + current_view.set(View::Tags); + } + Key::Character(ref c) if c == "5" && !ctrl && !meta && !shift => { + evt.prevent_default(); + current_view.set(View::Collections); + } + Key::Character(ref c) if c == "6" && !ctrl && !meta && !shift => { + evt.prevent_default(); + current_view.set(View::Audit); + } + // g then l - go to library (vim-style) + // Could implement g-prefix commands in the future + Key::Character(ref c) if c == "g" && !ctrl && !meta => {} + _ => {} + } + } + }, + + // Sidebar + div { class: if *sidebar_collapsed.read() { "sidebar collapsed" } else { "sidebar" }, + div { class: "sidebar-header", + span { class: "logo", "Pinakes" } + span { class: "version", "v0.1" } + } + + div { class: "nav-section", + div { class: "nav-label", "Media" } + button { + class: if *current_view.read() == View::Library { "nav-item active" } else { "nav-item" }, + onclick: { + let refresh_media = refresh_media.clone(); + move |_| { + current_view.set(View::Library); + refresh_media(); + } + }, + span { class: "nav-icon", + NavIcon { icon: FaBook } + } + span { class: "nav-item-text", "Library" } + span { class: "nav-badge", "{media_total_count}" } + } + button { + class: if *current_view.read() == View::Search { "nav-item active" } else { "nav-item" }, + onclick: move |_| current_view.set(View::Search), + span { class: "nav-icon", + NavIcon { icon: FaMagnifyingGlass } + } + span { class: "nav-item-text", "Search" } + } + button { + class: if *current_view.read() == View::Import { "nav-item active" } else { "nav-item" }, + onclick: { + let refresh_tags = refresh_tags.clone(); + let refresh_collections = refresh_collections.clone(); + move |_| { + current_view.set(View::Import); + preview_files.set(Vec::new()); + preview_total_size.set(0); + scan_progress.set(None); + refresh_tags(); + refresh_collections(); + } + }, + span { class: "nav-icon", + NavIcon { icon: FaDownload } + } + span { class: "nav-item-text", "Import" } + } + button { + class: if *current_view.read() == View::Graph { "nav-item active" } else { "nav-item" }, + onclick: move |_| { + current_view.set(View::Graph); + }, + span { class: "nav-icon", + NavIcon { icon: FaDiagramProject } + } + span { class: "nav-item-text", "Graph" } + } + } + + div { class: "nav-section", + div { class: "nav-label", "Organize" } + button { + class: if *current_view.read() == View::Tags { "nav-item active" } else { "nav-item" }, + onclick: { + let refresh_tags = refresh_tags.clone(); + move |_| { + current_view.set(View::Tags); + refresh_tags(); + } + }, + span { class: "nav-icon", + NavIcon { icon: FaTags } + } + span { class: "nav-item-text", "Tags" } + span { class: "nav-badge", "{tags_list.read().len()}" } + } + button { + class: if *current_view.read() == View::Collections { "nav-item active" } else { "nav-item" }, + onclick: { + let refresh_collections = refresh_collections.clone(); + move |_| { + current_view.set(View::Collections); + viewing_collection.set(None); + collection_members.set(Vec::new()); + refresh_collections(); + } + }, + span { class: "nav-icon", + NavIcon { icon: FaLayerGroup } + } + span { class: "nav-item-text", "Collections" } + span { class: "nav-badge", "{collections_list.read().len()}" } + } + } + + div { class: "nav-section", + div { class: "nav-label", "System" } + button { + class: if *current_view.read() == View::Audit { "nav-item active" } else { "nav-item" }, + onclick: { + let refresh_audit = refresh_audit.clone(); + move |_| { + current_view.set(View::Audit); + refresh_audit(); + } + }, + span { class: "nav-icon", + NavIcon { icon: FaClockRotateLeft } + } + span { class: "nav-item-text", "Audit" } + } + button { + class: if *current_view.read() == View::Duplicates { "nav-item active" } else { "nav-item" }, + onclick: { + let client = client.read().clone(); + move |_| { + current_view.set(View::Duplicates); + let client = client.clone(); + spawn(async move { + if let Ok(groups) = client.list_duplicates().await { + duplicate_groups.set(groups); + } + }); + } + }, + span { class: "nav-icon", + NavIcon { icon: FaCopy } + } + span { class: "nav-item-text", "Duplicates" } + } + button { + class: if *current_view.read() == View::Statistics { "nav-item active" } else { "nav-item" }, + onclick: { + let client = client.read().clone(); + move |_| { + current_view.set(View::Statistics); + let client = client.clone(); + spawn(async move { + if let Ok(stats) = client.library_statistics().await { + library_stats.set(Some(stats)); + } + }); + } + }, + span { class: "nav-icon", + NavIcon { icon: FaChartBar } + } + span { class: "nav-item-text", "Statistics" } + } + button { + class: if *current_view.read() == View::Tasks { "nav-item active" } else { "nav-item" }, + onclick: { + let client = client.read().clone(); + move |_| { + current_view.set(View::Tasks); + let client = client.clone(); + spawn(async move { + if let Ok(tasks_data) = client.list_scheduled_tasks().await { + scheduled_tasks.set(tasks_data); + } + }); + } + }, + span { class: "nav-icon", + NavIcon { icon: FaClock } + } + span { class: "nav-item-text", "Tasks" } + } + button { + class: if *current_view.read() == View::Settings { "nav-item active" } else { "nav-item" }, + onclick: { + let client = client.read().clone(); + move |_| { + current_view.set(View::Settings); + let client = client.clone(); + spawn(async move { + if let Ok(cfg) = client.get_config().await { + config_data.set(Some(cfg)); + } + }); + } + }, + span { class: "nav-icon", + NavIcon { icon: FaGear } + } + span { class: "nav-item-text", "Settings" } + } + button { + class: if *current_view.read() == View::Database { "nav-item active" } else { "nav-item" }, + onclick: { + let client = client.read().clone(); + move |_| { + current_view.set(View::Database); + let client = client.clone(); + spawn(async move { + if let Ok(stats) = client.database_stats().await { + db_stats.set(Some(stats)); + } + }); + } + }, + span { class: "nav-icon", + NavIcon { icon: FaDatabase } + } + span { class: "nav-item-text", "Database" } + } + } + + div { class: "sidebar-spacer" } + + // Show import progress in sidebar when not on import page + if *import_in_progress.read() && *current_view.read() != View::Import { + { + let (completed, total) = *import_progress.read(); + let has_progress = total > 0; + let pct = (completed * 100).checked_div(total).unwrap_or(0); + let current = import_current_file.read().clone(); + let queue_len = import_queue.read().len(); + rsx! { + div { class: "sidebar-import-progress", + div { class: "sidebar-import-header", + div { class: "status-dot checking" } + span { + if has_progress { + "Importing {completed}/{total}" + } else { + "Importing..." + } + } + if queue_len > 0 { + span { class: "import-queue-badge", "+{queue_len}" } + } + } + if let Some(ref file_name) = current { + div { class: "sidebar-import-file", "{file_name}" } + } + div { class: "progress-bar", + if has_progress { + div { class: "progress-fill", style: "width: {pct}%;" } + } else { + div { class: "progress-fill indeterminate" } + } + } + } + } + } + } + + // Sidebar collapse toggle + button { + class: "sidebar-toggle", + onclick: move |_| sidebar_collapsed.toggle(), + if *sidebar_collapsed.read() { + NavIcon { icon: FaChevronRight } + } else { + NavIcon { icon: FaChevronLeft } + } + } + + // User info (when logged in) + if let Some(ref user) = *current_user.read() { + div { class: "sidebar-footer user-info", + span { class: "user-name", "{user.username}" } + span { class: "role-badge role-{user.role}", "{user.role}" } + button { + class: "btn btn-ghost btn-sm", + onclick: { + let client = client.read().clone(); + move |_| { + let client = client.clone(); + spawn(async move { + let _ = client.logout().await; + current_user.set(None); + auth_required.set(true); + }); + } + }, + NavIcon { icon: FaArrowRightFromBracket } + "Logout" + } + } + } + + // Server status indicator + div { class: "sidebar-footer", + div { class: "status-indicator", + { + let is_checking = *server_checking.read(); + let is_connected = *server_connected.read(); + let dot_class = if is_checking { + "status-dot checking" + } else if is_connected { + "status-dot connected" + } else { + "status-dot disconnected" + }; + let label = if is_checking { + "Checking..." + } else if is_connected { + "Server connected" + } else { + "Server offline" + }; + rsx! { + span { class: "{dot_class}" } + span { class: "status-text", "{label}" } + } + } + } + } + } + + // Main content + div { class: "main", + div { class: "header", + span { class: "page-title", "{view_title}" } + div { class: "header-spacer" } + } + + div { class: "content", + // Offline banner + if !*server_checking.read() && !*server_connected.read() { + div { class: "offline-banner", + span { class: "offline-icon", "\u{26a0}" } + "Cannot reach the server. Make sure pinakes-server is running." + } + } + + // Error banner + if let Some(ref err) = *load_error.read() { + div { class: "error-banner", + span { class: "error-icon", "\u{26a0}" } + "{err}" + } + } + + // Loading indicator + if *loading.read() { + div { class: "loading-overlay", + div { class: "spinner" } + "Loading..." + } + } + + { + // Phase 2.2: Sort wiring - actually refetch with sort + // Phase 4.1 + 4.2: Search improvements + // Phase 3.1 + 3.2: Detail view enhancements + // Phase 3.2: Delete from detail navigates back and refreshes + // Phase 5.1: Tags on_delete - confirmation handled inside Tags component + // Phase 5.2: Collections enhancements + // Phase 5.2: Navigate to detail when clicking a collection member + // Phase 5.2: Add member to collection + // Phase 6.1: Audit improvements + // Phase 6.2: Scan progress + // Phase 6.2: Scan with polling for progress + // Poll scan status until done + // Refresh duplicates list + // Reload full config + match *current_view.read() { + View::Library => rsx! { + div { class: "stats-grid", + div { class: "stat-card", + div { class: "stat-value", "{media_total_count}" } + div { class: "stat-label", "Media Files" } + } + div { class: "stat-card", + div { class: "stat-value", "{tags_list.read().len()}" } + div { class: "stat-label", "Tags" } + } + div { class: "stat-card", + div { class: "stat-value", "{collections_list.read().len()}" } + div { class: "stat-label", "Collections" } + } + } + library::Library { + media: media_list.read().clone(), + tags: tags_list.read().clone(), + collections: collections_list.read().clone(), + total_count: *media_total_count.read(), + current_page: *media_page.read(), + page_size: *media_page_size.read(), + server_url: server_url.read().clone(), + on_select: { + let client = client.read().clone(); + move |id: String| { + let client = client.clone(); + spawn(async move { + match client.get_media(&id).await { + Ok(item) => { + let mtags = client.get_media_tags(&id).await.unwrap_or_default(); + media_tags.set(mtags); + selected_media.set(Some(item)); + current_view.set(View::Detail); + } + Err(e) => show_toast(format!("Failed to load: {e}"), true), + } + }); + } + }, + on_delete: { + let client = client.read().clone(); + let refresh_media = refresh_media.clone(); + move |id: String| { + let client = client.clone(); + let refresh_media = refresh_media.clone(); + spawn(async move { + match client.delete_media(&id).await { + Ok(_) => { + show_toast("Media deleted".into(), false); + refresh_media(); + } + Err(e) => show_toast(format!("Delete failed: {e}"), true), + } + }); + } + }, + on_batch_delete: { + let client = client.read().clone(); + let refresh_media = refresh_media.clone(); + move |ids: Vec| { + let client = client.clone(); + let refresh_media = refresh_media.clone(); + spawn(async move { + match client.batch_delete(&ids).await { + Ok(resp) => { + show_toast(format!("Deleted {} items", resp.processed), false); + refresh_media(); + } + Err(e) => show_toast(format!("Batch delete failed: {e}"), true), + } + }); + } + }, + on_batch_tag: { + let client = client.read().clone(); + let refresh_media = refresh_media.clone(); + move |(ids, tag_ids): (Vec, Vec)| { + let client = client.clone(); + let refresh_media = refresh_media.clone(); + spawn(async move { + match client.batch_tag(&ids, &tag_ids).await { + Ok(resp) => { + show_toast(format!("Tagged {} items", resp.processed), false); + refresh_media(); + } + Err(e) => show_toast(format!("Batch tag failed: {e}"), true), + } + }); + } + }, + on_batch_collection: { + let client = client.read().clone(); + let refresh_media = refresh_media.clone(); + move |(ids, col_id): (Vec, String)| { + let client = client.clone(); + let refresh_media = refresh_media.clone(); + spawn(async move { + match client.batch_add_to_collection(&ids, &col_id).await { + Ok(resp) => { + show_toast( + format!("Added {} items to collection", resp.processed), + false, + ); + refresh_media(); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_page_change: { + let client = client.read().clone(); + move |page: u64| { + media_page.set(page); + let client = client.clone(); + spawn(async move { + loading.set(true); + let offset = page * *media_page_size.read(); + let limit = *media_page_size.read(); + let sort = media_sort.read().clone(); + if let Ok(items) = client.list_media(offset, limit, Some(&sort)).await { + media_list.set(items); + } + loading.set(false); + }); + } + }, + on_page_size_change: { + let client = client.read().clone(); + move |size: u64| { + media_page_size.set(size); + media_page.set(0); + let client = client.clone(); + spawn(async move { + loading.set(true); + let sort = media_sort.read().clone(); + if let Ok(items) = client.list_media(0, size, Some(&sort)).await { + media_list.set(items); + } + loading.set(false); + }); + } + }, + on_sort_change: { + let client = client.read().clone(); + move |sort: String| { + media_sort.set(sort.clone()); + media_page.set(0); + let client = client.clone(); + spawn(async move { + loading.set(true); + let limit = *media_page_size.read(); + if let Ok(items) = client.list_media(0, limit, Some(&sort)).await { + media_list.set(items); + } + loading.set(false); + }); + } + }, + on_select_all_global: { + let client = client.read().clone(); + move |callback: EventHandler>| { + let client = client.clone(); + spawn(async move { + let total = *media_total_count.read(); + let sort = media_sort.read().clone(); + match client.list_media(0, total, Some(&sort)).await { + Ok(items) => { + let all_ids: Vec = items + .iter() + .map(|m| m.id.clone()) + .collect(); + callback.call(all_ids); + } + Err(e) => show_toast(format!("Failed to select all: {e}"), true), + } + }); + } + }, + on_delete_all: { + let client = client.read().clone(); + let refresh_media = refresh_media.clone(); + move |_: ()| { + let client = client.clone(); + let refresh_media = refresh_media.clone(); + spawn(async move { + match client.delete_all_media().await { + Ok(resp) => { + show_toast(format!("Deleted {} items", resp.processed), false); + refresh_media(); + } + Err(e) => show_toast(format!("Delete all failed: {e}"), true), + } + }); + } + }, + } + }, + View::Search => rsx! { + search::Search { + results: search_results.read().clone(), + total_count: *search_total.read(), + search_page: *search_page.read(), + page_size: *search_page_size.read(), + server_url: server_url.read().clone(), + on_search: { + let client = client.read().clone(); + move |(q, sort): (String, Option)| { + let client = client.clone(); + search_page.set(0); + last_search_query.set(q.clone()); + last_search_sort.set(sort.clone()); + spawn(async move { + loading.set(true); + let offset = 0; + let limit = *search_page_size.read(); + match client.search(&q, sort.as_deref(), offset, limit).await { + Ok(resp) => { + search_total.set(resp.total_count); + search_results.set(resp.items); + } + Err(e) => show_toast(format!("Search failed: {e}"), true), + } + loading.set(false); + }); + } + }, + on_select: { + let client = client.read().clone(); + move |id: String| { + let client = client.clone(); + spawn(async move { + if let Ok(item) = client.get_media(&id).await { + let mtags = client.get_media_tags(&id).await.unwrap_or_default(); + media_tags.set(mtags); + selected_media.set(Some(item)); + current_view.set(View::Detail); + } + }); + } + }, + on_page_change: { + let client = client.read().clone(); + move |page: u64| { + search_page.set(page); + let client = client.clone(); + spawn(async move { + loading.set(true); + let offset = page * *search_page_size.read(); + let limit = *search_page_size.read(); + let q = last_search_query.read().clone(); + let sort = last_search_sort.read().clone(); + match client.search(&q, sort.as_deref(), offset, limit).await { + Ok(resp) => { + search_total.set(resp.total_count); + search_results.set(resp.items); + } + Err(e) => show_toast(format!("Search failed: {e}"), true), + } + loading.set(false); + }); + } + }, + // Phase 3.6: Saved searches + saved_searches: saved_searches.read().clone(), + on_save_search: { + let client = client.read().clone(); + move |(name, query, sort): (String, String, Option)| { + let client = client.clone(); + spawn(async move { + match client.create_saved_search(&name, &query, sort.as_deref()).await { + Ok(ss) => { + saved_searches.write().push(ss); + show_toast(format!("Search '{}' saved", name), false); + } + Err(e) => show_toast(format!("Failed to save search: {e}"), true), + } + }); + } + }, + on_delete_saved_search: { + let client = client.read().clone(); + move |id: String| { + let client = client.clone(); + spawn(async move { + match client.delete_saved_search(&id).await { + Ok(_) => { + saved_searches.write().retain(|s| s.id != id); + show_toast("Search deleted".into(), false); + } + Err(e) => show_toast(format!("Failed to delete: {e}"), true), + } + }); + } + }, + on_load_saved_search: { + let client = client.read().clone(); + move |ss: SavedSearchResponse| { + let client = client.clone(); + let query = ss.query.clone(); + let sort = ss.sort_order.clone(); + search_page.set(0); + last_search_query.set(query.clone()); + last_search_sort.set(sort.clone()); + spawn(async move { + loading.set(true); + let offset = 0; + let limit = *search_page_size.read(); + match client.search(&query, sort.as_deref(), offset, limit).await { + Ok(resp) => { + search_total.set(resp.total_count); + search_results.set(resp.items); + } + Err(e) => show_toast(format!("Search failed: {e}"), true), + } + loading.set(false); + }); + } + }, + } + }, + View::Detail => { + let media_ref = selected_media.read(); + match media_ref.as_ref() { + Some(media) => rsx! { + detail::Detail { + media: media.clone(), + media_tags: media_tags.read().clone(), + all_tags: tags_list.read().clone(), + server_url: server_url.read().clone(), + autoplay: *auto_play_media.read(), + on_back: move |_| current_view.set(View::Library), + on_open: { + let client = client.read().clone(); + move |id: String| { + let client = client.clone(); + spawn(async move { + match client.open_media(&id).await { + Ok(_) => show_toast("File opened".into(), false), + Err(e) => show_toast(format!("Open failed: {e}"), true), + } + }); + } + }, + on_update: { + let client = client.read().clone(); + move |event: MediaUpdateEvent| { + let client = client.clone(); + spawn(async move { + match client.update_media(&event).await { + Ok(updated) => { + selected_media.set(Some(updated)); + show_toast("Metadata updated".into(), false); + } + Err(e) => show_toast(format!("Update failed: {e}"), true), + } + }); + } + }, + on_tag: { + let client = client.read().clone(); + move |(media_id, tag_id): (String, String)| { + let client = client.clone(); + spawn(async move { + match client.tag_media(&media_id, &tag_id).await { + Ok(_) => { + if let Ok(mtags) = client.get_media_tags(&media_id).await { + media_tags.set(mtags); + } + show_toast("Tag added".into(), false); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_untag: { + let client = client.read().clone(); + move |(media_id, tag_id): (String, String)| { + let client = client.clone(); + spawn(async move { + match client.untag_media(&media_id, &tag_id).await { + Ok(_) => { + if let Ok(mtags) = client.get_media_tags(&media_id).await { + media_tags.set(mtags); + } + show_toast("Tag removed".into(), false); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_set_custom_field: { + let client = client.read().clone(); + move |(media_id, name, field_type, value): (String, String, String, String)| { + let client = client.clone(); + spawn(async move { + match client + .set_custom_field(&media_id, &name, &field_type, &value) + .await + { + Ok(_) => { + if let Ok(updated) = client.get_media(&media_id).await { + selected_media.set(Some(updated)); + } + show_toast("Field added".into(), false); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_delete_custom_field: { + let client = client.read().clone(); + move |(media_id, name): (String, String)| { + let client = client.clone(); + spawn(async move { + match client.delete_custom_field(&media_id, &name).await { + Ok(_) => { + if let Ok(updated) = client.get_media(&media_id).await { + selected_media.set(Some(updated)); + } + show_toast("Field removed".into(), false); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_delete: { + let client = client.read().clone(); + let refresh_media = refresh_media.clone(); + move |id: String| { + let client = client.clone(); + let refresh_media = refresh_media.clone(); + spawn(async move { + match client.delete_media(&id).await { + Ok(_) => { + show_toast("Media deleted".into(), false); + selected_media.set(None); + current_view.set(View::Library); + refresh_media(); + } + Err(e) => show_toast(format!("Delete failed: {e}"), true), + } + }); + } + }, + play_queue: if play_queue.read().is_empty() { None } else { Some(play_queue.read().clone()) }, + on_queue_select: { + move |idx: usize| { + let mut q = play_queue.write(); + q.current_index = idx; + // Update selected_media to the item at this index + if let Some(item) = q.items.get(idx) { + let media_id = item.media_id.clone(); + let client = client.read().clone(); + spawn(async move { + if let Ok(media) = client.get_media(&media_id).await { + selected_media.set(Some(media)); + auto_play_media.set(true); + } + }); + } + } + }, + on_queue_remove: { + move |idx: usize| { + play_queue.write().remove(idx); + } + }, + on_queue_clear: { + move |_| { + play_queue.write().clear(); + } + }, + on_queue_toggle_repeat: { + move |_| { + play_queue.write().toggle_repeat(); + } + }, + on_queue_toggle_shuffle: { + move |_| { + play_queue.write().toggle_shuffle(); + } + }, + on_queue_next: { + move |_| { + let mut q = play_queue.write(); + if let Some(item) = q.next() { + let media_id = item.media_id.clone(); + drop(q); + let client = client.read().clone(); + spawn(async move { + if let Ok(media) = client.get_media(&media_id).await { + selected_media.set(Some(media)); + auto_play_media.set(true); + } + }); + } + } + }, + on_queue_previous: { + move |_| { + let mut q = play_queue.write(); + if let Some(item) = q.previous() { + let media_id = item.media_id.clone(); + drop(q); + let client = client.read().clone(); + spawn(async move { + if let Ok(media) = client.get_media(&media_id).await { + selected_media.set(Some(media)); + auto_play_media.set(true); + } + }); + } + } + }, + on_track_ended: { + move |_| { + let mut q = play_queue.write(); + if let Some(item) = q.next() { + let media_id = item.media_id.clone(); + drop(q); + let client = client.read().clone(); + spawn(async move { + if let Ok(media) = client.get_media(&media_id).await { + selected_media.set(Some(media)); + auto_play_media.set(true); + } + }); + } + } + }, + on_add_to_queue: { + move |item: crate::components::media_player::QueueItem| { + play_queue.write().add(item); + show_toast("Added to queue".into(), false); + } + }, + on_navigate_to_media: { + let client = client.read().clone(); + move |media_id: String| { + let client = client.clone(); + spawn(async move { + match client.get_media(&media_id).await { + Ok(media) => { + // Load tags for the new media + if let Ok(mtags) = client.get_media_tags(&media_id).await { + media_tags.set(mtags); + } + selected_media.set(Some(media)); + auto_play_media.set(false); + } + // Extract file name from path + + // Check if already importing - if so, add to queue + + // Extract directory name from path + + // Check if already importing - if so, add to queue + + // Get preview files if available for per-file progress + + // Use parallel import with per-batch progress + + // Show first file in batch as current + + // Process batch in parallel + + // Update progress after batch + + // Fallback: use server-side directory import (no per-file progress) + // Check if already importing - if so, add to queue + + // Update progress from scan status + + // Check if already importing - if so, add to queue + + // Process files in parallel batches for better performance + + // Show first file in batch as current + + // Process batch in parallel + + // Update progress after batch + + // Extended import state + + // Load tags for the media + + + + + + + + + + + + + + + + + + + + + + Err(e) => { + + + show_toast(format!("Failed to load linked note: {e}"), true) + } + } + }); + } + }, + } + }, + None => rsx! { + div { class: "empty-state", + h3 { class: "empty-title", "No media selected" } + } + }, + } + } + View::Tags => rsx! { + tags::Tags { + tags: tags_list.read().clone(), + on_create: { + let client = client.read().clone(); + let refresh_tags = refresh_tags.clone(); + move |(name, parent_id): (String, Option)| { + let client = client.clone(); + let refresh_tags = refresh_tags.clone(); + spawn(async move { + match client.create_tag(&name, parent_id.as_deref()).await { + Ok(_) => { + show_toast("Tag created".into(), false); + refresh_tags(); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_delete: { + let client = client.read().clone(); + let refresh_tags = refresh_tags.clone(); + move |id: String| { + let client = client.clone(); + let refresh_tags = refresh_tags.clone(); + spawn(async move { + match client.delete_tag(&id).await { + Ok(_) => { + show_toast("Tag deleted".into(), false); + refresh_tags(); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + } + }, + View::Collections => rsx! { + collections::Collections { + collections: collections_list.read().clone(), + collection_members: collection_members.read().clone(), + viewing_collection: viewing_collection.read().clone(), + all_media: media_list.read().clone(), + on_create: { + let client = client.read().clone(); + let refresh_collections = refresh_collections.clone(); + move | + (name, kind, desc, filter): (String, String, Option, Option)| + { + let client = client.clone(); + let refresh_collections = refresh_collections.clone(); + spawn(async move { + match client + .create_collection(&name, &kind, desc.as_deref(), filter.as_deref()) + .await + { + Ok(_) => { + show_toast("Collection created".into(), false); + refresh_collections(); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_delete: { + let client = client.read().clone(); + let refresh_collections = refresh_collections.clone(); + move |id: String| { + let client = client.clone(); + let refresh_collections = refresh_collections.clone(); + spawn(async move { + match client.delete_collection(&id).await { + Ok(_) => { + show_toast("Collection deleted".into(), false); + refresh_collections(); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_view_members: { + let client = client.read().clone(); + move |col_id: String| { + let client = client.clone(); + let col_id2 = col_id.clone(); + spawn(async move { + match client.get_collection_members(&col_id2).await { + Ok(members) => { + collection_members.set(members); + viewing_collection.set(Some(col_id2)); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_back_to_list: move |_| { + viewing_collection.set(None); + collection_members.set(Vec::new()); + }, + on_remove_member: { + let client = client.read().clone(); + move |(col_id, media_id): (String, String)| { + let client = client.clone(); + let col_id2 = col_id.clone(); + spawn(async move { + match client.remove_from_collection(&col_id, &media_id).await { + Ok(_) => { + show_toast("Removed from collection".into(), false); + if let Ok(members) = client + .get_collection_members(&col_id2) + .await + { + collection_members.set(members); + } + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_select: { + let client = client.read().clone(); + move |id: String| { + let client = client.clone(); + spawn(async move { + if let Ok(item) = client.get_media(&id).await { + let mtags = client.get_media_tags(&id).await.unwrap_or_default(); + media_tags.set(mtags); + selected_media.set(Some(item)); + current_view.set(View::Detail); + } + }); + } + }, + on_add_member: { + let client = client.read().clone(); + move |(col_id, media_id): (String, String)| { + let client = client.clone(); + let col_id2 = col_id.clone(); + spawn(async move { + match client.add_to_collection(&col_id, &media_id, 0).await { + Ok(_) => { + show_toast("Added to collection".into(), false); + if let Ok(members) = client + .get_collection_members(&col_id2) + .await + { + collection_members.set(members); + } + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + } + }, + View::Audit => { + let page_size = *audit_page_size.read(); + let total = *audit_total_count.read(); + let total_pages = if page_size > 0 { + total.div_ceil(page_size) + } else { + 1 + }; + rsx! { + audit::AuditLog { + entries: audit_list.read().clone(), + audit_page: *audit_page.read(), + total_pages, + audit_filter: audit_filter.read().clone(), + on_select: { + let client = client.read().clone(); + move |id: String| { + let client = client.clone(); + spawn(async move { + if let Ok(item) = client.get_media(&id).await { + let mtags = client.get_media_tags(&id).await.unwrap_or_default(); + media_tags.set(mtags); + selected_media.set(Some(item)); + current_view.set(View::Detail); + } + }); + } + }, + on_page_change: { + let refresh_audit = refresh_audit.clone(); + move |page: u64| { + audit_page.set(page); + refresh_audit(); + } + }, + on_filter_change: { + let refresh_audit = refresh_audit.clone(); + move |filter: String| { + audit_filter.set(filter); + audit_page.set(0); + refresh_audit(); + } + }, + } + } + } + View::Import => rsx! { + import::Import { + tags: tags_list.read().clone(), + collections: collections_list.read().clone(), + scan_progress: scan_progress.read().clone(), + is_importing: *import_in_progress.read(), + on_import_file: { + let client = client.read().clone(); + let refresh_media = refresh_media.clone(); + let refresh_tags = refresh_tags.clone(); + move |(path, tag_ids, new_tags, col_id): ImportEvent| { + let file_name = path.rsplit('/').next().unwrap_or(&path).to_string(); + + if *import_in_progress.read() { + + import_queue.write().push(file_name); + show_toast("Added to import queue".into(), false); + return; + } + + let client = client.clone(); + let refresh_media = refresh_media.clone(); + let refresh_tags = refresh_tags.clone(); + import_in_progress.set(true); + import_current_file.set(Some(file_name)); + import_progress.set((0, 1)); + spawn(async move { + if tag_ids.is_empty() && new_tags.is_empty() && col_id.is_none() { + match client.import_file(&path).await { + Ok(resp) => { + if resp.was_duplicate { + show_toast( + "Duplicate file (already imported)".into(), + false, + ); + } else { + show_toast(format!("Imported: {}", resp.media_id), false); + } + refresh_media(); + } + Err(e) => show_toast(format!("Import failed: {e}"), true), + } + } else { + match client + .import_with_options( + &path, + &tag_ids, + &new_tags, + col_id.as_deref(), + ) + .await + { + Ok(resp) => { + if resp.was_duplicate { + show_toast( + "Duplicate file (already imported)".into(), + false, + ); + } else { + show_toast( + format!("Imported with tags/collection: {}", resp.media_id), + false, + ); + } + refresh_media(); + if !new_tags.is_empty() { + refresh_tags(); + } + } + Err(e) => show_toast(format!("Import failed: {e}"), true), + } + } + import_progress.set((1, 1)); + import_current_file.set(None); + import_in_progress.set(false); + }); + } + }, + on_import_directory: { + let client = client.read().clone(); + let refresh_media = refresh_media.clone(); + let refresh_tags = refresh_tags.clone(); + move |(path, tag_ids, new_tags, col_id): ImportEvent| { + let dir_name = path.rsplit('/').next().unwrap_or(&path).to_string(); + + if *import_in_progress.read() { + import_queue.write().push(format!("{dir_name}/ (directory)")); + show_toast("Added directory to import queue".into(), false); + return; + } + + let files_to_import: Vec = preview_files + .read() + .iter() + .map(|f| f.path.clone()) + .collect(); + + let client = client.clone(); + let refresh_media = refresh_media.clone(); + let refresh_tags = refresh_tags.clone(); + import_in_progress.set(true); + + if !files_to_import.is_empty() { + let file_count = files_to_import.len(); + import_progress.set((0, file_count)); + + let client = Arc::new(client); + let tag_ids = Arc::new(tag_ids); + let new_tags = Arc::new(new_tags); + let col_id = Arc::new(col_id); + + const BATCH_SIZE: usize = 6; + spawn(async move { + let imported = Arc::new(AtomicUsize::new(0)); + let duplicates = Arc::new(AtomicUsize::new(0)); + let errors = Arc::new(AtomicUsize::new(0)); + let completed = Arc::new(AtomicUsize::new(0)); + + for chunk in files_to_import.chunks(BATCH_SIZE) { + if let Some(first_path) = chunk.first() { + let file_name = first_path + + .rsplit('/') + .next() + .unwrap_or(first_path); + import_current_file.set(Some(file_name.to_string())); + } + let futures: Vec<_> = chunk + .iter() + .map(|file_path| { + let client = Arc::clone(&client); + let tag_ids = Arc::clone(&tag_ids); + let new_tags = Arc::clone(&new_tags); + let col_id = Arc::clone(&col_id); + let imported = Arc::clone(&imported); + let duplicates = Arc::clone(&duplicates); + let errors = Arc::clone(&errors); + let completed = Arc::clone(&completed); + let file_path = file_path.clone(); + async move { + let result = if tag_ids.is_empty() && new_tags.is_empty() + && col_id.is_none() + { + client.import_file(&file_path).await + } else { + client + .import_with_options( + &file_path, + &tag_ids, + &new_tags, + col_id.as_deref(), + ) + .await + }; + match result { + Ok(resp) => { + if resp.was_duplicate { + duplicates.fetch_add(1, Ordering::Relaxed); + } else { + imported.fetch_add(1, Ordering::Relaxed); + } + } + Err(_) => { + errors.fetch_add(1, Ordering::Relaxed); + } + } + completed.fetch_add(1, Ordering::Relaxed); + } + }) + .collect(); + join_all(futures).await; + let done = completed.load(Ordering::Relaxed); + import_progress.set((done, file_count)); + } + let imported = imported.load(Ordering::Relaxed); + let duplicates = duplicates.load(Ordering::Relaxed); + let errors = errors.load(Ordering::Relaxed); + show_toast( + format!( + "Done: {imported} imported, {duplicates} duplicates, {errors} errors", + ), + errors > 0, + ); + refresh_media(); + if !new_tags.is_empty() { + refresh_tags(); + } + preview_files.set(Vec::new()); + preview_total_size.set(0); + import_progress.set((file_count, file_count)); + import_current_file.set(None); + import_in_progress.set(false); + }); + } else { + import_current_file.set(Some(format!("{dir_name}/"))); + import_progress.set((0, 0)); + spawn(async move { + match client + .import_directory(&path, &tag_ids, &new_tags, col_id.as_deref()) + .await + { + Ok(resp) => { + show_toast( + format!( + "Done: {} imported, {} duplicates, {} errors", + resp.imported, + resp.duplicates, + resp.errors, + ), + resp.errors > 0, + ); + refresh_media(); + if !new_tags.is_empty() { + refresh_tags(); + } + preview_files.set(Vec::new()); + preview_total_size.set(0); + } + Err(e) => { + show_toast(format!("Directory import failed: {e}"), true) + } + } + import_current_file.set(None); + import_progress.set((0, 0)); + import_in_progress.set(false); + }); + } + } + }, + on_scan: { + let client = client.read().clone(); + let refresh_media = refresh_media.clone(); + move |_| { + if *import_in_progress.read() { + import_queue.write().push("Scan roots".to_string()); + show_toast("Added scan to import queue".into(), false); + return; + } + + let client = client.clone(); + let refresh_media = refresh_media.clone(); + import_in_progress.set(true); + import_current_file.set(Some("Scanning roots...".to_string())); + import_progress.set((0, 0)); // Will be updated from scan_progress + spawn(async move { + match client.trigger_scan().await { + Ok(_results) => { + loop { + match client.scan_status().await { + Ok(status) => { + let done = !status.scanning; + import_progress + .set((status.files_processed, status.files_found)); + if status.files_found > 0 { + import_current_file + .set( + Some( + format!( + "Scanning ({}/{})", + status.files_processed, + status.files_found, + ), + ), + ); + } + scan_progress.set(Some(status.clone())); + if done { + let total = status.files_processed; + show_toast( + format!("Scan complete: {total} files processed"), + false, + ); + break; + } + } + Err(_) => break, + } + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + refresh_media(); + } + Err(e) => show_toast(format!("Scan failed: {e}"), true), + } + import_current_file.set(None); + import_progress.set((0, 0)); + import_in_progress.set(false); + }); + } + }, + on_import_batch: { + let client = client.read().clone(); + let refresh_media = refresh_media.clone(); + let refresh_tags = refresh_tags.clone(); + move |(paths, tag_ids, new_tags, col_id): import::BatchImportEvent| { + let file_count = paths.len(); + + if *import_in_progress.read() { + import_queue.write().push(format!("{file_count} files (batch)")); + show_toast("Added batch to import queue".into(), false); + return; + } + + let client = Arc::new(client.clone()); + let refresh_media = refresh_media.clone(); + let refresh_tags = refresh_tags.clone(); + let tag_ids = Arc::new(tag_ids); + let new_tags = Arc::new(new_tags); + let col_id = Arc::new(col_id); + import_in_progress.set(true); + import_progress.set((0, file_count)); + + const BATCH_SIZE: usize = 6; + spawn(async move { + let imported = Arc::new(AtomicUsize::new(0)); + let duplicates = Arc::new(AtomicUsize::new(0)); + let errors = Arc::new(AtomicUsize::new(0)); + let completed = Arc::new(AtomicUsize::new(0)); + + for chunk in paths.chunks(BATCH_SIZE) { + if let Some(first_path) = chunk.first() { + let file_name = first_path + + .rsplit('/') + .next() + .unwrap_or(first_path); + import_current_file.set(Some(file_name.to_string())); + } + let futures: Vec<_> = chunk + .iter() + .map(|path| { + let client = Arc::clone(&client); + let tag_ids = Arc::clone(&tag_ids); + let new_tags = Arc::clone(&new_tags); + let col_id = Arc::clone(&col_id); + let imported = Arc::clone(&imported); + let duplicates = Arc::clone(&duplicates); + let errors = Arc::clone(&errors); + let completed = Arc::clone(&completed); + let path = path.clone(); + async move { + let result = if tag_ids.is_empty() && new_tags.is_empty() + && col_id.is_none() + { + client.import_file(&path).await + } else { + client + .import_with_options( + &path, + &tag_ids, + &new_tags, + col_id.as_deref(), + ) + .await + }; + match result { + Ok(resp) => { + if resp.was_duplicate { + duplicates.fetch_add(1, Ordering::Relaxed); + } else { + imported.fetch_add(1, Ordering::Relaxed); + } + } + Err(_) => { + errors.fetch_add(1, Ordering::Relaxed); + } + } + completed.fetch_add(1, Ordering::Relaxed); + } + }) + .collect(); + join_all(futures).await; + let done = completed.load(Ordering::Relaxed); + import_progress.set((done, file_count)); + } + let imported = imported.load(Ordering::Relaxed); + let duplicates = duplicates.load(Ordering::Relaxed); + let errors = errors.load(Ordering::Relaxed); + show_toast( + format!( + "Done: {imported} imported, {duplicates} duplicates, {errors} errors", + ), + errors > 0, + ); + refresh_media(); + if !new_tags.is_empty() { + refresh_tags(); + } + preview_files.set(Vec::new()); + preview_total_size.set(0); + import_progress.set((file_count, file_count)); + import_current_file.set(None); + import_in_progress.set(false); + }); + } + }, + on_preview_directory: { + let client = client.read().clone(); + move |(path, recursive): (String, bool)| { + let client = client.clone(); + spawn(async move { + match client.preview_directory(&path, recursive).await { + Ok(resp) => { + preview_total_size.set(resp.total_size); + preview_files.set(resp.files); + } + Err(e) => { + show_toast(format!("Preview failed: {e}"), true); + preview_files.set(Vec::new()); + preview_total_size.set(0); + } + } + }); + } + }, + preview_files: preview_files.read().clone(), + preview_total_size: *preview_total_size.read(), + current_file: import_current_file.read().clone(), + import_queue: import_queue.read().clone(), + import_progress: *import_progress.read(), + } + }, + View::Database => { + let refresh_db_stats = { + let client = client.read().clone(); + move || { + let client = client.clone(); + spawn(async move { + match client.database_stats().await { + Ok(stats) => db_stats.set(Some(stats)), + Err(e) => { + show_toast(format!("Failed to load stats: {e}"), true) + } + } + }); + } + }; + rsx! { + database::Database { + stats: db_stats.read().clone(), + on_refresh: { + let refresh_db_stats = refresh_db_stats.clone(); + move |_| refresh_db_stats() + }, + on_vacuum: { + let client = client.read().clone(); + let refresh_db_stats = refresh_db_stats.clone(); + move |_| { + let client = client.clone(); + let refresh_db_stats = refresh_db_stats.clone(); + spawn(async move { + show_toast("Vacuuming database...".into(), false); + match client.vacuum_database().await { + Ok(()) => { + show_toast("Vacuum complete".into(), false); + refresh_db_stats(); + } + Err(e) => show_toast(format!("Vacuum failed: {e}"), true), + } + }); + } + }, + on_clear: { + let client = client.read().clone(); + let refresh_media = refresh_media.clone(); + let refresh_tags = refresh_tags.clone(); + let refresh_collections = refresh_collections.clone(); + let refresh_db_stats = refresh_db_stats.clone(); + move |_| { + let client = client.clone(); + let refresh_media = refresh_media.clone(); + let refresh_tags = refresh_tags.clone(); + let refresh_collections = refresh_collections.clone(); + let refresh_db_stats = refresh_db_stats.clone(); + spawn(async move { + match client.clear_database().await { + Ok(()) => { + show_toast("All data cleared".into(), false); + refresh_media(); + refresh_tags(); + refresh_collections(); + refresh_db_stats(); + } + Err(e) => show_toast(format!("Clear failed: {e}"), true), + } + }); + } + }, + on_backup: { + move |_path: String| { + show_toast("Backup not yet implemented on server".into(), false); + } + }, + } + } + } + View::Duplicates => { + rsx! { + duplicates::Duplicates { + groups: duplicate_groups.read().clone(), + server_url: server_url.read().clone(), + on_delete: { + let client = client.read().clone(); + move |media_id: String| { + let client = client.clone(); + spawn(async move { + match client.delete_media(&media_id).await { + Ok(_) => { + show_toast("Deleted duplicate".into(), false); + if let Ok(groups) = client.list_duplicates().await { + duplicate_groups.set(groups); + } + } + Err(e) => show_toast(format!("Delete failed: {e}"), true), + } + }); + } + }, + on_refresh: { + let client = client.read().clone(); + move |_| { + let client = client.clone(); + spawn(async move { + match client.list_duplicates().await { + Ok(groups) => duplicate_groups.set(groups), + Err(e) => show_toast(format!("Failed to load duplicates: {e}"), true), + } + }); + } + }, + } + } + } + View::Statistics => { + let refresh_stats = { + let client = client.read().clone(); + move || { + let client = client.clone(); + spawn(async move { + match client.library_statistics().await { + Ok(stats) => library_stats.set(Some(stats)), + Err(e) => { + show_toast(format!("Failed to load statistics: {e}"), true) + } + } + }); + } + }; + rsx! { + statistics::Statistics { + stats: library_stats.read().clone(), + on_refresh: { + let refresh_stats = refresh_stats.clone(); + move |_| refresh_stats() + }, + } + } + } + View::Tasks => { + let refresh_tasks = { + let client = client.read().clone(); + move || { + let client = client.clone(); + spawn(async move { + match client.list_scheduled_tasks().await { + Ok(tasks_data) => scheduled_tasks.set(tasks_data), + Err(e) => { + show_toast(format!("Failed to load tasks: {e}"), true) + } + } + }); + } + }; + rsx! { + tasks::Tasks { + tasks: scheduled_tasks.read().clone(), + on_refresh: { + let refresh_tasks = refresh_tasks.clone(); + move |_| refresh_tasks() + }, + on_toggle: { + let client = client.read().clone(); + let refresh_tasks = refresh_tasks.clone(); + move |task_id: String| { + let client = client.clone(); + let refresh_tasks = refresh_tasks.clone(); + spawn(async move { + match client.toggle_scheduled_task(&task_id).await { + Ok(_) => { + show_toast("Task toggled".into(), false); + refresh_tasks(); + } + Err(e) => show_toast(format!("Toggle failed: {e}"), true), + } + }); + } + }, + on_run_now: { + let client = client.read().clone(); + let refresh_tasks = refresh_tasks.clone(); + move |task_id: String| { + let client = client.clone(); + let refresh_tasks = refresh_tasks.clone(); + spawn(async move { + match client.run_scheduled_task_now(&task_id).await { + Ok(_) => { + show_toast("Task started".into(), false); + refresh_tasks(); + } + Err(e) => show_toast(format!("Run failed: {e}"), true), + } + }); + } + }, + } + } + } + View::Settings => { + let cfg_ref = config_data.read(); + match cfg_ref.as_ref() { + Some(cfg) => rsx! { + settings::Settings { + config: cfg.clone(), + on_add_root: { + let client = client.read().clone(); + move |path: String| { + let client = client.clone(); + spawn(async move { + match client.add_root(&path).await { + Ok(new_cfg) => { + config_data.set(Some(new_cfg)); + show_toast("Root added".into(), false); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_remove_root: { + let client = client.read().clone(); + move |path: String| { + let client = client.clone(); + spawn(async move { + match client.remove_root(&path).await { + Ok(new_cfg) => { + config_data.set(Some(new_cfg)); + show_toast("Root removed".into(), false); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_toggle_watch: { + let client = client.read().clone(); + move |enabled: bool| { + let client = client.clone(); + spawn(async move { + match client.update_scanning(Some(enabled), None, None).await { + Ok(new_cfg) => { + config_data.set(Some(new_cfg)); + let state = if enabled { "enabled" } else { "disabled" }; + show_toast(format!("Watching {state}"), false); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_update_poll_interval: { + let client = client.read().clone(); + move |secs: u64| { + let client = client.clone(); + spawn(async move { + match client.update_scanning(None, Some(secs), None).await { + Ok(new_cfg) => { + config_data.set(Some(new_cfg)); + show_toast(format!("Poll interval set to {secs}s"), false); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_update_ignore_patterns: { + let client = client.read().clone(); + move |patterns: Vec| { + let client = client.clone(); + spawn(async move { + match client.update_scanning(None, None, Some(patterns)).await { + Ok(new_cfg) => { + config_data.set(Some(new_cfg)); + show_toast("Ignore patterns updated".into(), false); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_update_ui_config: { + let client = client.read().clone(); + move |updates: serde_json::Value| { + let client = client.clone(); + spawn(async move { + match client.update_ui_config(updates).await { + Ok(ui_cfg) => { + auto_play_media.set(ui_cfg.auto_play_media); + sidebar_collapsed.set(ui_cfg.sidebar_collapsed); + current_theme.set(ui_cfg.theme.clone()); + if let Ok(cfg) = client.get_config().await { + config_data.set(Some(cfg)); + } + show_toast("UI preferences updated".into(), false); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + } + }, + None => rsx! { + div { class: "empty-state", + h3 { class: "empty-title", "Loading settings..." } + } + }, + } + } + View::Graph => { + rsx! { + graph_view::GraphView { + client: client.read().clone(), + center_id: None, + on_navigate: { + let client = client.read().clone(); + move |media_id: String| { + let client = client.clone(); + spawn(async move { + match client.get_media(&media_id).await { + Ok(media) => { + if let Ok(mtags) = client.get_media_tags(&media_id).await { + media_tags.set(mtags); + } + selected_media.set(Some(media)); + current_view.set(View::Detail); + } + Err(e) => show_toast(format!("Failed to load: {e}"), true), + } + }); + } + }, + } + } + } + } + } + } + } + + // Phase 7.1: Help overlay + if *show_help.read() { + div { + class: "help-overlay", + onclick: move |_| show_help.set(false), + div { + class: "help-dialog", + onclick: move |evt: MouseEvent| evt.stop_propagation(), + h3 { "Keyboard Shortcuts" } + div { class: "help-shortcuts", + h4 { "Navigation" } + div { class: "shortcut-row", + kbd { "Esc" } + span { "Go back / close overlay" } + } + div { class: "shortcut-row", + kbd { "/" } + span { "Focus search" } + } + div { class: "shortcut-row", + kbd { "Ctrl+K" } + span { "Focus search (alternative)" } + } + div { class: "shortcut-row", + kbd { "Ctrl+," } + span { "Open settings" } + } + div { class: "shortcut-row", + kbd { "?" } + span { "Toggle this help" } + } + + h4 { "Quick Views" } + div { class: "shortcut-row", + kbd { "1" } + span { "Library" } + } + div { class: "shortcut-row", + kbd { "2" } + span { "Search" } + } + div { class: "shortcut-row", + kbd { "3" } + span { "Import" } + } + div { class: "shortcut-row", + kbd { "4" } + span { "Tags" } + } + div { class: "shortcut-row", + kbd { "5" } + span { "Collections" } + } + div { class: "shortcut-row", + kbd { "6" } + span { "Audit Log" } + } + } + button { + class: "help-close", + onclick: move |_| show_help.set(false), + "Close" + } + } + } + } + } + } // end else (auth not required) + + // Phase 1.4: Toast queue - show up to 3 stacked from bottom + div { class: "toast-container", + { + let toasts = toast_queue.read().clone(); + let visible: Vec<_> = toasts.iter().rev().take(3).rev().cloned().collect(); + rsx! { + for (msg , is_error , id) in visible { + div { key: "{id}", class: if is_error { "toast error" } else { "toast success" }, "{msg}" } + } + } + } + } + } } diff --git a/crates/pinakes-ui/src/client.rs b/crates/pinakes-ui/src/client.rs index 7099d50..f99f52c 100644 --- a/crates/pinakes-ui/src/client.rs +++ b/crates/pinakes-ui/src/client.rs @@ -1,853 +1,923 @@ -use anyhow::Result; -use reqwest::Client; -use reqwest::header; -use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use anyhow::Result; +use reqwest::{Client, header}; +use serde::{Deserialize, Serialize}; + /// Payload for import events: (path, tag_ids, new_tags, collection_id) pub type ImportEvent = (String, Vec, Vec, Option); /// Payload for media update events #[derive(Debug, Clone, PartialEq)] pub struct MediaUpdateEvent { - pub id: String, - pub title: Option, - pub artist: Option, - pub album: Option, - pub genre: Option, - pub year: Option, - pub description: Option, + pub id: String, + pub title: Option, + pub artist: Option, + pub album: Option, + pub genre: Option, + pub year: Option, + pub description: Option, } #[derive(Clone)] pub struct ApiClient { - client: Client, - base_url: String, + client: Client, + base_url: String, } impl PartialEq for ApiClient { - fn eq(&self, other: &Self) -> bool { - self.base_url == other.base_url - } + fn eq(&self, other: &Self) -> bool { + self.base_url == other.base_url + } } // ── Response types ── #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct MediaResponse { - pub id: String, - pub path: String, - pub file_name: String, - pub media_type: String, - pub content_hash: String, - pub file_size: u64, - pub title: Option, - pub artist: Option, - pub album: Option, - pub genre: Option, - pub year: Option, - pub duration_secs: Option, - pub description: Option, - #[serde(default)] - pub has_thumbnail: bool, - pub custom_fields: HashMap, - pub created_at: String, - pub updated_at: String, - #[serde(default)] - pub links_extracted_at: Option, + pub id: String, + pub path: String, + pub file_name: String, + pub media_type: String, + pub content_hash: String, + pub file_size: u64, + pub title: Option, + pub artist: Option, + pub album: Option, + pub genre: Option, + pub year: Option, + pub duration_secs: Option, + pub description: Option, + #[serde(default)] + pub has_thumbnail: bool, + pub custom_fields: HashMap, + pub created_at: String, + pub updated_at: String, + #[serde(default)] + pub links_extracted_at: Option, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct CustomFieldResponse { - pub field_type: String, - pub value: String, + pub field_type: String, + pub value: String, } #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct ImportResponse { - pub media_id: String, - pub was_duplicate: bool, + pub media_id: String, + pub was_duplicate: bool, } #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct BatchImportResponse { - pub results: Vec, - pub total: usize, - pub imported: usize, - pub duplicates: usize, - pub errors: usize, + pub results: Vec, + pub total: usize, + pub imported: usize, + pub duplicates: usize, + pub errors: usize, } #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct BatchImportItemResult { - pub path: String, - pub media_id: Option, - pub was_duplicate: bool, - pub error: Option, + pub path: String, + pub media_id: Option, + pub was_duplicate: bool, + pub error: Option, } #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct DirectoryPreviewResponse { - pub files: Vec, - pub total_count: usize, - pub total_size: u64, + pub files: Vec, + pub total_count: usize, + pub total_size: u64, } #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct DirectoryPreviewFile { - pub path: String, - pub file_name: String, - pub media_type: String, - pub file_size: u64, + pub path: String, + pub file_name: String, + pub media_type: String, + pub file_size: u64, } #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct DuplicateGroupResponse { - pub content_hash: String, - pub items: Vec, + pub content_hash: String, + pub items: Vec, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct TagResponse { - pub id: String, - pub name: String, - pub parent_id: Option, - pub created_at: String, + pub id: String, + pub name: String, + pub parent_id: Option, + pub created_at: String, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct CollectionResponse { - pub id: String, - pub name: String, - pub description: Option, - pub kind: String, - pub filter_query: Option, - pub created_at: String, - pub updated_at: String, + pub id: String, + pub name: String, + pub description: Option, + pub kind: String, + pub filter_query: Option, + pub created_at: String, + pub updated_at: String, } #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct SearchResponse { - pub items: Vec, - pub total_count: u64, + pub items: Vec, + pub total_count: u64, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct AuditEntryResponse { - pub id: String, - pub media_id: Option, - pub action: String, - pub details: Option, - pub timestamp: String, + pub id: String, + pub media_id: Option, + pub action: String, + pub details: Option, + pub timestamp: String, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct ConfigResponse { - pub backend: String, - pub database_path: Option, - pub roots: Vec, - pub scanning: ScanningConfigResponse, - pub server: ServerConfigResponse, - #[serde(default)] - pub ui: UiConfigResponse, - pub config_path: Option, - pub config_writable: bool, + pub backend: String, + pub database_path: Option, + pub roots: Vec, + pub scanning: ScanningConfigResponse, + pub server: ServerConfigResponse, + #[serde(default)] + pub ui: UiConfigResponse, + pub config_path: Option, + pub config_writable: bool, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Default)] pub struct UiConfigResponse { - #[serde(default = "default_theme")] - pub theme: String, - #[serde(default = "default_view")] - pub default_view: String, - #[serde(default = "default_page_size")] - pub default_page_size: usize, - #[serde(default = "default_view_mode")] - pub default_view_mode: String, - #[serde(default)] - pub auto_play_media: bool, - #[serde(default = "default_true")] - pub show_thumbnails: bool, - #[serde(default)] - pub sidebar_collapsed: bool, + #[serde(default = "default_theme")] + pub theme: String, + #[serde(default = "default_view")] + pub default_view: String, + #[serde(default = "default_page_size")] + pub default_page_size: usize, + #[serde(default = "default_view_mode")] + pub default_view_mode: String, + #[serde(default)] + pub auto_play_media: bool, + #[serde(default = "default_true")] + pub show_thumbnails: bool, + #[serde(default)] + pub sidebar_collapsed: bool, } fn default_theme() -> String { - "dark".to_string() + "dark".to_string() } fn default_view() -> String { - "library".to_string() + "library".to_string() } fn default_page_size() -> usize { - 48 + 48 } fn default_view_mode() -> String { - "grid".to_string() + "grid".to_string() } fn default_true() -> bool { - true + true } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct LoginResponse { - pub token: String, - pub username: String, - pub role: String, + pub token: String, + pub username: String, + pub role: String, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct UserInfoResponse { - pub username: String, - pub role: String, + pub username: String, + pub role: String, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct ScanningConfigResponse { - pub watch: bool, - pub poll_interval_secs: u64, - pub ignore_patterns: Vec, + pub watch: bool, + pub poll_interval_secs: u64, + pub ignore_patterns: Vec, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct ServerConfigResponse { - pub host: String, - pub port: u16, + pub host: String, + pub port: u16, } #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct ScanResponse { - pub files_found: usize, - pub files_processed: usize, - pub errors: Vec, + pub files_found: usize, + pub files_processed: usize, + pub errors: Vec, } #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct ScanStatusResponse { - pub scanning: bool, - pub files_found: usize, - pub files_processed: usize, - pub error_count: usize, - pub errors: Vec, + pub scanning: bool, + pub files_found: usize, + pub files_processed: usize, + pub error_count: usize, + pub errors: Vec, } #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct BatchOperationResponse { - pub processed: usize, - pub errors: Vec, + pub processed: usize, + pub errors: Vec, } #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct LibraryStatisticsResponse { - pub total_media: u64, - pub total_size_bytes: u64, - pub avg_file_size_bytes: u64, - pub media_by_type: Vec, - pub storage_by_type: Vec, - pub newest_item: Option, - pub oldest_item: Option, - pub top_tags: Vec, - pub top_collections: Vec, - pub total_tags: u64, - pub total_collections: u64, - pub total_duplicates: u64, + pub total_media: u64, + pub total_size_bytes: u64, + pub avg_file_size_bytes: u64, + pub media_by_type: Vec, + pub storage_by_type: Vec, + pub newest_item: Option, + pub oldest_item: Option, + pub top_tags: Vec, + pub top_collections: Vec, + pub total_tags: u64, + pub total_collections: u64, + pub total_duplicates: u64, } #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct TypeCountResponse { - pub name: String, - pub count: u64, + pub name: String, + pub count: u64, } #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct ScheduledTaskResponse { - pub id: String, - pub name: String, - pub schedule: String, - pub enabled: bool, - pub last_run: Option, - pub next_run: Option, - pub last_status: Option, + pub id: String, + pub name: String, + pub schedule: String, + pub enabled: bool, + pub last_run: Option, + pub next_run: Option, + pub last_status: Option, } #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct DatabaseStatsResponse { - pub media_count: u64, - pub tag_count: u64, - pub collection_count: u64, - pub audit_count: u64, - pub database_size_bytes: u64, - pub backend_name: String, + pub media_count: u64, + pub tag_count: u64, + pub collection_count: u64, + pub audit_count: u64, + pub database_size_bytes: u64, + pub backend_name: String, } // ── Markdown Notes/Links Response Types ── #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct BacklinksResponse { - pub backlinks: Vec, - pub count: usize, + pub backlinks: Vec, + pub count: usize, } #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct BacklinkItem { - pub link_id: String, - pub source_id: String, - pub source_title: Option, - pub source_path: String, - pub link_text: Option, - pub line_number: Option, - pub context: Option, - pub link_type: String, + pub link_id: String, + pub source_id: String, + pub source_title: Option, + pub source_path: String, + pub link_text: Option, + pub line_number: Option, + pub context: Option, + pub link_type: String, } #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct OutgoingLinksResponse { - pub links: Vec, - pub count: usize, + pub links: Vec, + pub count: usize, } #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct OutgoingLinkItem { - pub id: String, - pub target_path: String, - pub target_id: Option, - pub link_text: Option, - pub line_number: Option, - pub link_type: String, - pub is_resolved: bool, + pub id: String, + pub target_path: String, + pub target_id: Option, + pub link_text: Option, + pub line_number: Option, + pub link_type: String, + pub is_resolved: bool, } #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct GraphResponse { - pub nodes: Vec, - pub edges: Vec, - pub node_count: usize, - pub edge_count: usize, + pub nodes: Vec, + pub edges: Vec, + pub node_count: usize, + pub edge_count: usize, } #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct GraphNodeResponse { - pub id: String, - pub label: String, - pub title: Option, - pub media_type: String, - pub link_count: u32, - pub backlink_count: u32, + pub id: String, + pub label: String, + pub title: Option, + pub media_type: String, + pub link_count: u32, + pub backlink_count: u32, } #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct GraphEdgeResponse { - pub source: String, - pub target: String, - pub link_type: String, + pub source: String, + pub target: String, + pub link_type: String, } #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct ReindexLinksResponse { - pub message: String, - pub links_extracted: usize, + pub message: String, + pub links_extracted: usize, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct SavedSearchResponse { - pub id: String, - pub name: String, - pub query: String, - pub sort_order: Option, - pub created_at: chrono::DateTime, + pub id: String, + pub name: String, + pub query: String, + pub sort_order: Option, + pub created_at: chrono::DateTime, } #[derive(Debug, Clone, Serialize)] pub struct CreateSavedSearchRequest { - pub name: String, - pub query: String, - pub sort_order: Option, + pub name: String, + pub query: String, + pub sort_order: Option, } impl ApiClient { - pub fn new(base_url: &str, api_key: Option<&str>) -> Self { - let mut headers = header::HeaderMap::new(); - if let Some(key) = api_key - && let Ok(val) = header::HeaderValue::from_str(&format!("Bearer {key}")) - { - headers.insert(header::AUTHORIZATION, val); - } - let client = Client::builder() - .default_headers(headers) - .build() - .unwrap_or_else(|_| Client::new()); - Self { - client, - base_url: base_url.trim_end_matches('/').to_string(), - } + pub fn new(base_url: &str, api_key: Option<&str>) -> Self { + let mut headers = header::HeaderMap::new(); + if let Some(key) = api_key + && let Ok(val) = header::HeaderValue::from_str(&format!("Bearer {key}")) + { + headers.insert(header::AUTHORIZATION, val); } - - pub fn base_url(&self) -> &str { - &self.base_url + let client = Client::builder() + .default_headers(headers) + .build() + .unwrap_or_else(|_| Client::new()); + Self { + client, + base_url: base_url.trim_end_matches('/').to_string(), } + } - fn url(&self, path: &str) -> String { - format!("{}/api/v1{}", self.base_url, path) + pub fn base_url(&self) -> &str { + &self.base_url + } + + fn url(&self, path: &str) -> String { + format!("{}/api/v1{}", self.base_url, path) + } + + pub async fn health_check(&self) -> bool { + match self + .client + .get(self.url("/health")) + .timeout(std::time::Duration::from_secs(3)) + .send() + .await + { + Ok(resp) => resp.status().is_success(), + Err(_) => false, } + } - pub async fn health_check(&self) -> bool { - match self - .client - .get(self.url("/health")) - .timeout(std::time::Duration::from_secs(3)) - .send() - .await - { - Ok(resp) => resp.status().is_success(), - Err(_) => false, - } + // ── Media ── + + pub async fn list_media( + &self, + offset: u64, + limit: u64, + sort: Option<&str>, + ) -> Result> { + let mut params = + vec![("offset", offset.to_string()), ("limit", limit.to_string())]; + if let Some(s) = sort { + params.push(("sort", s.to_string())); } + Ok( + self + .client + .get(self.url("/media")) + .query(¶ms) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } - // ── Media ── + pub async fn get_media(&self, id: &str) -> Result { + Ok( + self + .client + .get(self.url(&format!("/media/{id}"))) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } - pub async fn list_media( - &self, - offset: u64, - limit: u64, - sort: Option<&str>, - ) -> Result> { - let mut params = vec![("offset", offset.to_string()), ("limit", limit.to_string())]; - if let Some(s) = sort { - params.push(("sort", s.to_string())); - } - Ok(self - .client - .get(self.url("/media")) - .query(¶ms) - .send() - .await? - .error_for_status()? - .json() - .await?) + pub async fn update_media( + &self, + event: &MediaUpdateEvent, + ) -> Result { + let mut body = serde_json::Map::new(); + if let Some(v) = &event.title { + body.insert("title".into(), serde_json::json!(v)); } - - pub async fn get_media(&self, id: &str) -> Result { - Ok(self - .client - .get(self.url(&format!("/media/{id}"))) - .send() - .await? - .error_for_status()? - .json() - .await?) + if let Some(v) = &event.artist { + body.insert("artist".into(), serde_json::json!(v)); } - - pub async fn update_media(&self, event: &MediaUpdateEvent) -> Result { - let mut body = serde_json::Map::new(); - if let Some(v) = &event.title { - body.insert("title".into(), serde_json::json!(v)); - } - if let Some(v) = &event.artist { - body.insert("artist".into(), serde_json::json!(v)); - } - if let Some(v) = &event.album { - body.insert("album".into(), serde_json::json!(v)); - } - if let Some(v) = &event.genre { - body.insert("genre".into(), serde_json::json!(v)); - } - if let Some(v) = event.year { - body.insert("year".into(), serde_json::json!(v)); - } - if let Some(v) = &event.description { - body.insert("description".into(), serde_json::json!(v)); - } - let id = &event.id; - Ok(self - .client - .patch(self.url(&format!("/media/{id}"))) - .json(&serde_json::Value::Object(body)) - .send() - .await? - .error_for_status()? - .json() - .await?) + if let Some(v) = &event.album { + body.insert("album".into(), serde_json::json!(v)); } - - pub async fn delete_media(&self, id: &str) -> Result<()> { - self.client - .delete(self.url(&format!("/media/{id}"))) - .send() - .await? - .error_for_status()?; - Ok(()) + if let Some(v) = &event.genre { + body.insert("genre".into(), serde_json::json!(v)); } - - pub async fn open_media(&self, id: &str) -> Result<()> { - self.client - .post(self.url(&format!("/media/{id}/open"))) - .send() - .await? - .error_for_status()?; - Ok(()) + if let Some(v) = event.year { + body.insert("year".into(), serde_json::json!(v)); } - - pub fn stream_url(&self, id: &str) -> String { - self.url(&format!("/media/{id}/stream")) + if let Some(v) = &event.description { + body.insert("description".into(), serde_json::json!(v)); } + let id = &event.id; + Ok( + self + .client + .patch(self.url(&format!("/media/{id}"))) + .json(&serde_json::Value::Object(body)) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } - pub fn thumbnail_url(&self, id: &str) -> String { - self.url(&format!("/media/{id}/thumbnail")) + pub async fn delete_media(&self, id: &str) -> Result<()> { + self + .client + .delete(self.url(&format!("/media/{id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn open_media(&self, id: &str) -> Result<()> { + self + .client + .post(self.url(&format!("/media/{id}/open"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub fn stream_url(&self, id: &str) -> String { + self.url(&format!("/media/{id}/stream")) + } + + pub fn thumbnail_url(&self, id: &str) -> String { + self.url(&format!("/media/{id}/thumbnail")) + } + + pub async fn get_media_count(&self) -> Result { + #[derive(Deserialize)] + struct CountResp { + count: u64, } + let resp: CountResp = self + .client + .get(self.url("/media/count")) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp.count) + } - pub async fn get_media_count(&self) -> Result { - #[derive(Deserialize)] - struct CountResp { - count: u64, - } - let resp: CountResp = self - .client - .get(self.url("/media/count")) - .send() - .await? - .error_for_status()? - .json() - .await?; - Ok(resp.count) + // ── Import ── + + pub async fn import_file(&self, path: &str) -> Result { + Ok( + self + .client + .post(self.url("/media/import")) + .json(&serde_json::json!({"path": path})) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + pub async fn import_with_options( + &self, + path: &str, + tag_ids: &[String], + new_tags: &[String], + collection_id: Option<&str>, + ) -> Result { + let mut body = serde_json::json!({"path": path}); + if !tag_ids.is_empty() { + body["tag_ids"] = serde_json::json!(tag_ids); } - - // ── Import ── - - pub async fn import_file(&self, path: &str) -> Result { - Ok(self - .client - .post(self.url("/media/import")) - .json(&serde_json::json!({"path": path})) - .send() - .await? - .error_for_status()? - .json() - .await?) + if !new_tags.is_empty() { + body["new_tags"] = serde_json::json!(new_tags); } - - pub async fn import_with_options( - &self, - path: &str, - tag_ids: &[String], - new_tags: &[String], - collection_id: Option<&str>, - ) -> Result { - let mut body = serde_json::json!({"path": path}); - if !tag_ids.is_empty() { - body["tag_ids"] = serde_json::json!(tag_ids); - } - if !new_tags.is_empty() { - body["new_tags"] = serde_json::json!(new_tags); - } - if let Some(cid) = collection_id { - body["collection_id"] = serde_json::json!(cid); - } - Ok(self - .client - .post(self.url("/media/import/options")) - .json(&body) - .send() - .await? - .error_for_status()? - .json() - .await?) + if let Some(cid) = collection_id { + body["collection_id"] = serde_json::json!(cid); } + Ok( + self + .client + .post(self.url("/media/import/options")) + .json(&body) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } - pub async fn import_directory( - &self, - path: &str, - tag_ids: &[String], - new_tags: &[String], - collection_id: Option<&str>, - ) -> Result { - let mut body = serde_json::json!({"path": path}); - if !tag_ids.is_empty() { - body["tag_ids"] = serde_json::json!(tag_ids); - } - if !new_tags.is_empty() { - body["new_tags"] = serde_json::json!(new_tags); - } - if let Some(cid) = collection_id { - body["collection_id"] = serde_json::json!(cid); - } - Ok(self - .client - .post(self.url("/media/import/directory")) - .json(&body) - .send() - .await? - .error_for_status()? - .json() - .await?) + pub async fn import_directory( + &self, + path: &str, + tag_ids: &[String], + new_tags: &[String], + collection_id: Option<&str>, + ) -> Result { + let mut body = serde_json::json!({"path": path}); + if !tag_ids.is_empty() { + body["tag_ids"] = serde_json::json!(tag_ids); } - - pub async fn preview_directory( - &self, - path: &str, - recursive: bool, - ) -> Result { - Ok(self - .client - .post(self.url("/media/import/preview")) - .json(&serde_json::json!({"path": path, "recursive": recursive})) - .send() - .await? - .error_for_status()? - .json() - .await?) + if !new_tags.is_empty() { + body["new_tags"] = serde_json::json!(new_tags); } - - // ── Search ── - - pub async fn search( - &self, - query: &str, - sort: Option<&str>, - offset: u64, - limit: u64, - ) -> Result { - let mut params = vec![ - ("q", query.to_string()), - ("offset", offset.to_string()), - ("limit", limit.to_string()), - ]; - if let Some(s) = sort { - params.push(("sort", s.to_string())); - } - Ok(self - .client - .get(self.url("/search")) - .query(¶ms) - .send() - .await? - .error_for_status()? - .json() - .await?) + if let Some(cid) = collection_id { + body["collection_id"] = serde_json::json!(cid); } + Ok( + self + .client + .post(self.url("/media/import/directory")) + .json(&body) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } - // ── Tags ── + pub async fn preview_directory( + &self, + path: &str, + recursive: bool, + ) -> Result { + Ok( + self + .client + .post(self.url("/media/import/preview")) + .json(&serde_json::json!({"path": path, "recursive": recursive})) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } - pub async fn list_tags(&self) -> Result> { - Ok(self - .client - .get(self.url("/tags")) - .send() - .await? - .error_for_status()? - .json() - .await?) + // ── Search ── + + pub async fn search( + &self, + query: &str, + sort: Option<&str>, + offset: u64, + limit: u64, + ) -> Result { + let mut params = vec![ + ("q", query.to_string()), + ("offset", offset.to_string()), + ("limit", limit.to_string()), + ]; + if let Some(s) = sort { + params.push(("sort", s.to_string())); } + Ok( + self + .client + .get(self.url("/search")) + .query(¶ms) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } - pub async fn create_tag(&self, name: &str, parent_id: Option<&str>) -> Result { - let mut body = serde_json::json!({"name": name}); - if let Some(pid) = parent_id { - body["parent_id"] = serde_json::Value::String(pid.to_string()); - } - Ok(self - .client - .post(self.url("/tags")) - .json(&body) - .send() - .await? - .error_for_status()? - .json() - .await?) + // ── Tags ── + + pub async fn list_tags(&self) -> Result> { + Ok( + self + .client + .get(self.url("/tags")) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + pub async fn create_tag( + &self, + name: &str, + parent_id: Option<&str>, + ) -> Result { + let mut body = serde_json::json!({"name": name}); + if let Some(pid) = parent_id { + body["parent_id"] = serde_json::Value::String(pid.to_string()); } + Ok( + self + .client + .post(self.url("/tags")) + .json(&body) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } - pub async fn delete_tag(&self, id: &str) -> Result<()> { - self.client - .delete(self.url(&format!("/tags/{id}"))) - .send() - .await? - .error_for_status()?; - Ok(()) - } + pub async fn delete_tag(&self, id: &str) -> Result<()> { + self + .client + .delete(self.url(&format!("/tags/{id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } - pub async fn tag_media(&self, media_id: &str, tag_id: &str) -> Result<()> { - self.client - .post(self.url(&format!("/media/{media_id}/tags"))) - .json(&serde_json::json!({"tag_id": tag_id})) - .send() - .await? - .error_for_status()?; - Ok(()) - } + pub async fn tag_media(&self, media_id: &str, tag_id: &str) -> Result<()> { + self + .client + .post(self.url(&format!("/media/{media_id}/tags"))) + .json(&serde_json::json!({"tag_id": tag_id})) + .send() + .await? + .error_for_status()?; + Ok(()) + } - pub async fn untag_media(&self, media_id: &str, tag_id: &str) -> Result<()> { - self.client - .delete(self.url(&format!("/media/{media_id}/tags/{tag_id}"))) - .send() - .await? - .error_for_status()?; - Ok(()) - } + pub async fn untag_media(&self, media_id: &str, tag_id: &str) -> Result<()> { + self + .client + .delete(self.url(&format!("/media/{media_id}/tags/{tag_id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } - pub async fn get_media_tags(&self, media_id: &str) -> Result> { - Ok(self - .client - .get(self.url(&format!("/media/{media_id}/tags"))) - .send() - .await? - .error_for_status()? - .json() - .await?) - } + pub async fn get_media_tags( + &self, + media_id: &str, + ) -> Result> { + Ok( + self + .client + .get(self.url(&format!("/media/{media_id}/tags"))) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } - // ── Custom Fields ── + // ── Custom Fields ── - pub async fn set_custom_field( - &self, - media_id: &str, - name: &str, - field_type: &str, - value: &str, - ) -> Result<()> { - self.client + pub async fn set_custom_field( + &self, + media_id: &str, + name: &str, + field_type: &str, + value: &str, + ) -> Result<()> { + self.client .post(self.url(&format!("/media/{media_id}/custom-fields"))) .json(&serde_json::json!({"name": name, "field_type": field_type, "value": value})) .send() .await? .error_for_status()?; - Ok(()) + Ok(()) + } + + pub async fn delete_custom_field( + &self, + media_id: &str, + name: &str, + ) -> Result<()> { + self + .client + .delete(self.url(&format!("/media/{media_id}/custom-fields/{name}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + // ── Collections ── + + pub async fn list_collections(&self) -> Result> { + Ok( + self + .client + .get(self.url("/collections")) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + pub async fn create_collection( + &self, + name: &str, + kind: &str, + description: Option<&str>, + filter_query: Option<&str>, + ) -> Result { + let mut body = serde_json::json!({"name": name, "kind": kind}); + if let Some(desc) = description { + body["description"] = serde_json::Value::String(desc.to_string()); } - - pub async fn delete_custom_field(&self, media_id: &str, name: &str) -> Result<()> { - self.client - .delete(self.url(&format!("/media/{media_id}/custom-fields/{name}"))) - .send() - .await? - .error_for_status()?; - Ok(()) + if let Some(fq) = filter_query { + body["filter_query"] = serde_json::Value::String(fq.to_string()); } + Ok( + self + .client + .post(self.url("/collections")) + .json(&body) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } - // ── Collections ── + pub async fn delete_collection(&self, id: &str) -> Result<()> { + self + .client + .delete(self.url(&format!("/collections/{id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } - pub async fn list_collections(&self) -> Result> { - Ok(self - .client - .get(self.url("/collections")) - .send() - .await? - .error_for_status()? - .json() - .await?) - } + pub async fn get_collection_members( + &self, + id: &str, + ) -> Result> { + Ok( + self + .client + .get(self.url(&format!("/collections/{id}/members"))) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } - pub async fn create_collection( - &self, - name: &str, - kind: &str, - description: Option<&str>, - filter_query: Option<&str>, - ) -> Result { - let mut body = serde_json::json!({"name": name, "kind": kind}); - if let Some(desc) = description { - body["description"] = serde_json::Value::String(desc.to_string()); - } - if let Some(fq) = filter_query { - body["filter_query"] = serde_json::Value::String(fq.to_string()); - } - Ok(self - .client - .post(self.url("/collections")) - .json(&body) - .send() - .await? - .error_for_status()? - .json() - .await?) - } + pub async fn add_to_collection( + &self, + collection_id: &str, + media_id: &str, + position: i32, + ) -> Result<()> { + self + .client + .post(self.url(&format!("/collections/{collection_id}/members"))) + .json(&serde_json::json!({"media_id": media_id, "position": position})) + .send() + .await? + .error_for_status()?; + Ok(()) + } - pub async fn delete_collection(&self, id: &str) -> Result<()> { - self.client - .delete(self.url(&format!("/collections/{id}"))) - .send() - .await? - .error_for_status()?; - Ok(()) - } + pub async fn remove_from_collection( + &self, + collection_id: &str, + media_id: &str, + ) -> Result<()> { + self + .client + .delete( + self.url(&format!("/collections/{collection_id}/members/{media_id}")), + ) + .send() + .await? + .error_for_status()?; + Ok(()) + } - pub async fn get_collection_members(&self, id: &str) -> Result> { - Ok(self - .client - .get(self.url(&format!("/collections/{id}/members"))) - .send() - .await? - .error_for_status()? - .json() - .await?) - } + // ── Batch Operations ── - pub async fn add_to_collection( - &self, - collection_id: &str, - media_id: &str, - position: i32, - ) -> Result<()> { - self.client - .post(self.url(&format!("/collections/{collection_id}/members"))) - .json(&serde_json::json!({"media_id": media_id, "position": position})) - .send() - .await? - .error_for_status()?; - Ok(()) - } + pub async fn batch_tag( + &self, + media_ids: &[String], + tag_ids: &[String], + ) -> Result { + Ok( + self + .client + .post(self.url("/media/batch/tag")) + .json(&serde_json::json!({"media_ids": media_ids, "tag_ids": tag_ids})) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } - pub async fn remove_from_collection(&self, collection_id: &str, media_id: &str) -> Result<()> { - self.client - .delete(self.url(&format!("/collections/{collection_id}/members/{media_id}"))) - .send() - .await? - .error_for_status()?; - Ok(()) - } + pub async fn batch_delete( + &self, + media_ids: &[String], + ) -> Result { + Ok( + self + .client + .post(self.url("/media/batch/delete")) + .json(&serde_json::json!({"media_ids": media_ids})) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } - // ── Batch Operations ── + pub async fn delete_all_media(&self) -> Result { + Ok( + self + .client + .delete(self.url("/media/all")) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } - pub async fn batch_tag( - &self, - media_ids: &[String], - tag_ids: &[String], - ) -> Result { - Ok(self - .client - .post(self.url("/media/batch/tag")) - .json(&serde_json::json!({"media_ids": media_ids, "tag_ids": tag_ids})) - .send() - .await? - .error_for_status()? - .json() - .await?) - } - - pub async fn batch_delete(&self, media_ids: &[String]) -> Result { - Ok(self - .client - .post(self.url("/media/batch/delete")) - .json(&serde_json::json!({"media_ids": media_ids})) - .send() - .await? - .error_for_status()? - .json() - .await?) - } - - pub async fn delete_all_media(&self) -> Result { - Ok(self - .client - .delete(self.url("/media/all")) - .send() - .await? - .error_for_status()? - .json() - .await?) - } - - pub async fn batch_add_to_collection( - &self, - media_ids: &[String], - collection_id: &str, - ) -> Result { - Ok(self + pub async fn batch_add_to_collection( + &self, + media_ids: &[String], + collection_id: &str, + ) -> Result { + Ok(self .client .post(self.url("/media/batch/collection")) .json(&serde_json::json!({"media_ids": media_ids, "collection_id": collection_id})) @@ -856,424 +926,494 @@ impl ApiClient { .error_for_status()? .json() .await?) + } + + // ── Audit ── + + pub async fn list_audit( + &self, + offset: u64, + limit: u64, + ) -> Result> { + Ok( + self + .client + .get(self.url("/audit")) + .query(&[("offset", offset.to_string()), ("limit", limit.to_string())]) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + // ── Scan ── + + pub async fn trigger_scan(&self) -> Result> { + Ok( + self + .client + .post(self.url("/scan")) + .json(&serde_json::json!({"path": serde_json::Value::Null})) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + pub async fn scan_status(&self) -> Result { + Ok( + self + .client + .get(self.url("/scan/status")) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + // ── Config ── + + pub async fn get_config(&self) -> Result { + Ok( + self + .client + .get(self.url("/config")) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + pub async fn update_scanning( + &self, + watch: Option, + poll_interval: Option, + ignore_patterns: Option>, + ) -> Result { + let mut body = serde_json::Map::new(); + if let Some(w) = watch { + body.insert("watch".into(), serde_json::Value::Bool(w)); + } + if let Some(p) = poll_interval { + body.insert("poll_interval_secs".into(), serde_json::json!(p)); + } + if let Some(pat) = ignore_patterns { + body.insert("ignore_patterns".into(), serde_json::json!(pat)); + } + Ok( + self + .client + .put(self.url("/config/scanning")) + .json(&body) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + pub async fn add_root(&self, path: &str) -> Result { + Ok( + self + .client + .post(self.url("/config/roots")) + .json(&serde_json::json!({"path": path})) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + pub async fn remove_root(&self, path: &str) -> Result { + Ok( + self + .client + .delete(self.url("/config/roots")) + .json(&serde_json::json!({"path": path})) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + // ── Database Management ── + + pub async fn database_stats(&self) -> Result { + Ok( + self + .client + .get(self.url("/database/stats")) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + pub async fn vacuum_database(&self) -> Result<()> { + self + .client + .post(self.url("/database/vacuum")) + .json(&serde_json::json!({})) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn clear_database(&self) -> Result<()> { + self + .client + .post(self.url("/database/clear")) + .json(&serde_json::json!({})) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + // ── Duplicates ── + + pub async fn list_duplicates(&self) -> Result> { + Ok( + self + .client + .get(self.url("/duplicates")) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + // ── UI Config ── + + pub async fn update_ui_config( + &self, + updates: serde_json::Value, + ) -> Result { + Ok( + self + .client + .put(self.url("/config/ui")) + .json(&updates) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + // ── Auth ── + + pub async fn login( + &self, + username: &str, + password: &str, + ) -> Result { + Ok( + self + .client + .post(self.url("/auth/login")) + .json(&serde_json::json!({"username": username, "password": password})) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + pub async fn logout(&self) -> Result<()> { + self + .client + .post(self.url("/auth/logout")) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn get_current_user(&self) -> Result { + Ok( + self + .client + .get(self.url("/auth/me")) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + pub async fn library_statistics(&self) -> Result { + Ok( + self + .client + .get(self.url("/statistics")) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + pub async fn list_scheduled_tasks( + &self, + ) -> Result> { + Ok( + self + .client + .get(self.url("/tasks/scheduled")) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + pub async fn toggle_scheduled_task( + &self, + id: &str, + ) -> Result { + Ok( + self + .client + .post(self.url(&format!("/tasks/scheduled/{}/toggle", id))) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + pub async fn run_scheduled_task_now( + &self, + id: &str, + ) -> Result { + Ok( + self + .client + .post(self.url(&format!("/tasks/scheduled/{}/run-now", id))) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + // ── Saved Searches ── + + pub async fn list_saved_searches(&self) -> Result> { + Ok( + self + .client + .get(self.url("/saved-searches")) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + pub async fn create_saved_search( + &self, + name: &str, + query: &str, + sort_order: Option<&str>, + ) -> Result { + let req = CreateSavedSearchRequest { + name: name.to_string(), + query: query.to_string(), + sort_order: sort_order.map(|s| s.to_string()), + }; + Ok( + self + .client + .post(self.url("/saved-searches")) + .json(&req) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + pub async fn delete_saved_search(&self, id: &str) -> Result<()> { + self + .client + .delete(self.url(&format!("/saved-searches/{id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + // ── Markdown Notes/Links ── + + /// Get backlinks (incoming links) to a media item. + pub async fn get_backlinks(&self, id: &str) -> Result { + Ok( + self + .client + .get(self.url(&format!("/media/{id}/backlinks"))) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + /// Get outgoing links from a media item. + pub async fn get_outgoing_links( + &self, + id: &str, + ) -> Result { + Ok( + self + .client + .get(self.url(&format!("/media/{id}/outgoing-links"))) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + /// Get graph data for visualization. + pub async fn get_graph( + &self, + center_id: Option<&str>, + depth: Option, + ) -> Result { + let mut url = self.url("/notes/graph"); + let mut query_parts = Vec::new(); + if let Some(center) = center_id { + query_parts.push(format!("center={}", center)); + } + if let Some(d) = depth { + query_parts.push(format!("depth={}", d)); + } + if !query_parts.is_empty() { + url = format!("{}?{}", url, query_parts.join("&")); } - // ── Audit ── + Ok( + self + .client + .get(&url) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } - pub async fn list_audit(&self, offset: u64, limit: u64) -> Result> { - Ok(self - .client - .get(self.url("/audit")) - .query(&[("offset", offset.to_string()), ("limit", limit.to_string())]) - .send() - .await? - .error_for_status()? - .json() - .await?) + /// Re-extract links from a media item. + pub async fn reindex_links(&self, id: &str) -> Result { + Ok( + self + .client + .post(self.url(&format!("/media/{id}/reindex-links"))) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + /// Get count of unresolved links. + pub async fn get_unresolved_links_count(&self) -> Result { + #[derive(Deserialize)] + struct CountResp { + count: u64, } + let resp: CountResp = self + .client + .get(self.url("/notes/unresolved-count")) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp.count) + } - // ── Scan ── - - pub async fn trigger_scan(&self) -> Result> { - Ok(self - .client - .post(self.url("/scan")) - .json(&serde_json::json!({"path": serde_json::Value::Null})) - .send() - .await? - .error_for_status()? - .json() - .await?) - } - - pub async fn scan_status(&self) -> Result { - Ok(self - .client - .get(self.url("/scan/status")) - .send() - .await? - .error_for_status()? - .json() - .await?) - } - - // ── Config ── - - pub async fn get_config(&self) -> Result { - Ok(self - .client - .get(self.url("/config")) - .send() - .await? - .error_for_status()? - .json() - .await?) - } - - pub async fn update_scanning( - &self, - watch: Option, - poll_interval: Option, - ignore_patterns: Option>, - ) -> Result { - let mut body = serde_json::Map::new(); - if let Some(w) = watch { - body.insert("watch".into(), serde_json::Value::Bool(w)); - } - if let Some(p) = poll_interval { - body.insert("poll_interval_secs".into(), serde_json::json!(p)); - } - if let Some(pat) = ignore_patterns { - body.insert("ignore_patterns".into(), serde_json::json!(pat)); - } - Ok(self - .client - .put(self.url("/config/scanning")) - .json(&body) - .send() - .await? - .error_for_status()? - .json() - .await?) - } - - pub async fn add_root(&self, path: &str) -> Result { - Ok(self - .client - .post(self.url("/config/roots")) - .json(&serde_json::json!({"path": path})) - .send() - .await? - .error_for_status()? - .json() - .await?) - } - - pub async fn remove_root(&self, path: &str) -> Result { - Ok(self - .client - .delete(self.url("/config/roots")) - .json(&serde_json::json!({"path": path})) - .send() - .await? - .error_for_status()? - .json() - .await?) - } - - // ── Database Management ── - - pub async fn database_stats(&self) -> Result { - Ok(self - .client - .get(self.url("/database/stats")) - .send() - .await? - .error_for_status()? - .json() - .await?) - } - - pub async fn vacuum_database(&self) -> Result<()> { - self.client - .post(self.url("/database/vacuum")) - .json(&serde_json::json!({})) - .send() - .await? - .error_for_status()?; - Ok(()) - } - - pub async fn clear_database(&self) -> Result<()> { - self.client - .post(self.url("/database/clear")) - .json(&serde_json::json!({})) - .send() - .await? - .error_for_status()?; - Ok(()) - } - - // ── Duplicates ── - - pub async fn list_duplicates(&self) -> Result> { - Ok(self - .client - .get(self.url("/duplicates")) - .send() - .await? - .error_for_status()? - .json() - .await?) - } - - // ── UI Config ── - - pub async fn update_ui_config(&self, updates: serde_json::Value) -> Result { - Ok(self - .client - .put(self.url("/config/ui")) - .json(&updates) - .send() - .await? - .error_for_status()? - .json() - .await?) - } - - // ── Auth ── - - pub async fn login(&self, username: &str, password: &str) -> Result { - Ok(self - .client - .post(self.url("/auth/login")) - .json(&serde_json::json!({"username": username, "password": password})) - .send() - .await? - .error_for_status()? - .json() - .await?) - } - - pub async fn logout(&self) -> Result<()> { - self.client - .post(self.url("/auth/logout")) - .send() - .await? - .error_for_status()?; - Ok(()) - } - - pub async fn get_current_user(&self) -> Result { - Ok(self - .client - .get(self.url("/auth/me")) - .send() - .await? - .error_for_status()? - .json() - .await?) - } - - pub async fn library_statistics(&self) -> Result { - Ok(self - .client - .get(self.url("/statistics")) - .send() - .await? - .error_for_status()? - .json() - .await?) - } - - pub async fn list_scheduled_tasks(&self) -> Result> { - Ok(self - .client - .get(self.url("/tasks/scheduled")) - .send() - .await? - .error_for_status()? - .json() - .await?) - } - - pub async fn toggle_scheduled_task(&self, id: &str) -> Result { - Ok(self - .client - .post(self.url(&format!("/tasks/scheduled/{}/toggle", id))) - .send() - .await? - .error_for_status()? - .json() - .await?) - } - - pub async fn run_scheduled_task_now(&self, id: &str) -> Result { - Ok(self - .client - .post(self.url(&format!("/tasks/scheduled/{}/run-now", id))) - .send() - .await? - .error_for_status()? - .json() - .await?) - } - - // ── Saved Searches ── - - pub async fn list_saved_searches(&self) -> Result> { - Ok(self - .client - .get(self.url("/saved-searches")) - .send() - .await? - .error_for_status()? - .json() - .await?) - } - - pub async fn create_saved_search( - &self, - name: &str, - query: &str, - sort_order: Option<&str>, - ) -> Result { - let req = CreateSavedSearchRequest { - name: name.to_string(), - query: query.to_string(), - sort_order: sort_order.map(|s| s.to_string()), - }; - Ok(self - .client - .post(self.url("/saved-searches")) - .json(&req) - .send() - .await? - .error_for_status()? - .json() - .await?) - } - - pub async fn delete_saved_search(&self, id: &str) -> Result<()> { - self.client - .delete(self.url(&format!("/saved-searches/{id}"))) - .send() - .await? - .error_for_status()?; - Ok(()) - } - - // ── Markdown Notes/Links ── - - /// Get backlinks (incoming links) to a media item. - pub async fn get_backlinks(&self, id: &str) -> Result { - Ok(self - .client - .get(self.url(&format!("/media/{id}/backlinks"))) - .send() - .await? - .error_for_status()? - .json() - .await?) - } - - /// Get outgoing links from a media item. - pub async fn get_outgoing_links(&self, id: &str) -> Result { - Ok(self - .client - .get(self.url(&format!("/media/{id}/outgoing-links"))) - .send() - .await? - .error_for_status()? - .json() - .await?) - } - - /// Get graph data for visualization. - pub async fn get_graph( - &self, - center_id: Option<&str>, - depth: Option, - ) -> Result { - let mut url = self.url("/notes/graph"); - let mut query_parts = Vec::new(); - if let Some(center) = center_id { - query_parts.push(format!("center={}", center)); - } - if let Some(d) = depth { - query_parts.push(format!("depth={}", d)); - } - if !query_parts.is_empty() { - url = format!("{}?{}", url, query_parts.join("&")); - } - - Ok(self - .client - .get(&url) - .send() - .await? - .error_for_status()? - .json() - .await?) - } - - /// Re-extract links from a media item. - pub async fn reindex_links(&self, id: &str) -> Result { - Ok(self - .client - .post(self.url(&format!("/media/{id}/reindex-links"))) - .send() - .await? - .error_for_status()? - .json() - .await?) - } - - /// Get count of unresolved links. - pub async fn get_unresolved_links_count(&self) -> Result { - #[derive(Deserialize)] - struct CountResp { - count: u64, - } - let resp: CountResp = self - .client - .get(self.url("/notes/unresolved-count")) - .send() - .await? - .error_for_status()? - .json() - .await?; - Ok(resp.count) - } - - pub fn set_token(&mut self, token: &str) { - let mut headers = header::HeaderMap::new(); - if let Ok(val) = header::HeaderValue::from_str(&format!("Bearer {token}")) { - headers.insert(header::AUTHORIZATION, val); - } - self.client = Client::builder() - .default_headers(headers) - .build() - .unwrap_or_else(|_| Client::new()); + pub fn set_token(&mut self, token: &str) { + let mut headers = header::HeaderMap::new(); + if let Ok(val) = header::HeaderValue::from_str(&format!("Bearer {token}")) { + headers.insert(header::AUTHORIZATION, val); } + self.client = Client::builder() + .default_headers(headers) + .build() + .unwrap_or_else(|_| Client::new()); + } } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn test_base_url() { - let client = ApiClient::new("http://localhost:3000", None); - assert_eq!(client.base_url(), "http://localhost:3000"); - } + #[test] + fn test_base_url() { + let client = ApiClient::new("http://localhost:3000", None); + assert_eq!(client.base_url(), "http://localhost:3000"); + } - #[test] - fn test_stream_url() { - let client = ApiClient::new("http://localhost:3000", None); - let url = client.stream_url("test-id-123"); - assert_eq!(url, "http://localhost:3000/api/v1/media/test-id-123/stream"); - } + #[test] + fn test_stream_url() { + let client = ApiClient::new("http://localhost:3000", None); + let url = client.stream_url("test-id-123"); + assert_eq!(url, "http://localhost:3000/api/v1/media/test-id-123/stream"); + } - #[test] - fn test_thumbnail_url() { - let client = ApiClient::new("http://localhost:3000", None); - let url = client.thumbnail_url("test-id-456"); - assert_eq!( - url, - "http://localhost:3000/api/v1/media/test-id-456/thumbnail" - ); - } + #[test] + fn test_thumbnail_url() { + let client = ApiClient::new("http://localhost:3000", None); + let url = client.thumbnail_url("test-id-456"); + assert_eq!( + url, + "http://localhost:3000/api/v1/media/test-id-456/thumbnail" + ); + } - #[test] - fn test_client_creation_with_api_key() { - let client = ApiClient::new("http://localhost:3000", Some("test-key")); - assert_eq!(client.base_url(), "http://localhost:3000"); - } + #[test] + fn test_client_creation_with_api_key() { + let client = ApiClient::new("http://localhost:3000", Some("test-key")); + assert_eq!(client.base_url(), "http://localhost:3000"); + } - #[test] - fn test_base_url_trailing_slash() { - let client = ApiClient::new("http://localhost:3000/", None); - assert_eq!(client.base_url(), "http://localhost:3000"); - } + #[test] + fn test_base_url_trailing_slash() { + let client = ApiClient::new("http://localhost:3000/", None); + assert_eq!(client.base_url(), "http://localhost:3000"); + } } diff --git a/crates/pinakes-ui/src/components/audit.rs b/crates/pinakes-ui/src/components/audit.rs index b1846c2..b9ace5e 100644 --- a/crates/pinakes-ui/src/components/audit.rs +++ b/crates/pinakes-ui/src/components/audit.rs @@ -1,124 +1,126 @@ use dioxus::prelude::*; -use super::pagination::Pagination as PaginationControls; -use super::utils::format_timestamp; +use super::{ + pagination::Pagination as PaginationControls, + utils::format_timestamp, +}; use crate::client::AuditEntryResponse; const ACTION_OPTIONS: &[&str] = &[ - "All", - "imported", - "deleted", - "tagged", - "untagged", - "updated", - "added_to_collection", - "removed_from_collection", - "opened", - "scanned", + "All", + "imported", + "deleted", + "tagged", + "untagged", + "updated", + "added_to_collection", + "removed_from_collection", + "opened", + "scanned", ]; #[component] pub fn AuditLog( - entries: Vec, - on_select: EventHandler, - audit_page: u64, - total_pages: u64, - on_page_change: EventHandler, - audit_filter: String, - on_filter_change: EventHandler, + entries: Vec, + on_select: EventHandler, + audit_page: u64, + total_pages: u64, + on_page_change: EventHandler, + audit_filter: String, + on_filter_change: EventHandler, ) -> Element { - if entries.is_empty() { - return rsx! { - div { class: "empty-state", - h3 { class: "empty-title", "No audit entries" } - p { class: "empty-subtitle", "Activity will appear here as you use the application." } - } - }; - } - - rsx! { - div { class: "audit-controls", - select { - class: "filter-select", - value: "{audit_filter}", - onchange: move |evt: Event| { - on_filter_change.call(evt.value().to_string()); - }, - for option in ACTION_OPTIONS.iter() { - option { - key: "{option}", - value: "{option}", - selected: audit_filter == *option, - "{option}" - } - } - } + if entries.is_empty() { + return rsx! { + div { class: "empty-state", + h3 { class: "empty-title", "No audit entries" } + p { class: "empty-subtitle", "Activity will appear here as you use the application." } } + }; + } - table { class: "data-table", - thead { - tr { - th { "Action" } - th { "Media ID" } - th { "Details" } - th { "Timestamp" } - } - } - tbody { - for entry in entries.iter() { - { - let media_id = entry.media_id.clone().unwrap_or_default(); - let truncated_id = if media_id.len() > 8 { - format!("{}...", &media_id[..8]) - } else { - media_id.clone() - }; - let details = entry.details.clone().unwrap_or_default(); - let action_class = action_badge_class(&entry.action); - let timestamp = format_timestamp(&entry.timestamp); - let click_id = media_id.clone(); - let has_media_id = !media_id.is_empty(); - rsx! { - tr { key: "{entry.id}", - td { - span { class: "type-badge {action_class}", "{entry.action}" } - } - td { - if has_media_id { - span { - class: "mono clickable", - onclick: move |_| { - on_select.call(click_id.clone()); - }, - "{truncated_id}" - } - } else { - span { class: "mono", "{truncated_id}" } - } - } - td { "{details}" } - td { "{timestamp}" } - } - } - } - } - } - } + rsx! { + div { class: "audit-controls", + select { + class: "filter-select", + value: "{audit_filter}", + onchange: move |evt: Event| { + on_filter_change.call(evt.value().to_string()); + }, + for option in ACTION_OPTIONS.iter() { + option { + key: "{option}", + value: "{option}", + selected: audit_filter == *option, + "{option}" + } + } + } + } - PaginationControls { current_page: audit_page, total_pages, on_page_change } - } + table { class: "data-table", + thead { + tr { + th { "Action" } + th { "Media ID" } + th { "Details" } + th { "Timestamp" } + } + } + tbody { + for entry in entries.iter() { + { + let media_id = entry.media_id.clone().unwrap_or_default(); + let truncated_id = if media_id.len() > 8 { + format!("{}...", &media_id[..8]) + } else { + media_id.clone() + }; + let details = entry.details.clone().unwrap_or_default(); + let action_class = action_badge_class(&entry.action); + let timestamp = format_timestamp(&entry.timestamp); + let click_id = media_id.clone(); + let has_media_id = !media_id.is_empty(); + rsx! { + tr { key: "{entry.id}", + td { + span { class: "type-badge {action_class}", "{entry.action}" } + } + td { + if has_media_id { + span { + class: "mono clickable", + onclick: move |_| { + on_select.call(click_id.clone()); + }, + "{truncated_id}" + } + } else { + span { class: "mono", "{truncated_id}" } + } + } + td { "{details}" } + td { "{timestamp}" } + } + } + } + } + } + } + + PaginationControls { current_page: audit_page, total_pages, on_page_change } + } } fn action_badge_class(action: &str) -> &'static str { - match action { - "imported" => "type-image", - "deleted" => "action-danger", - "tagged" | "untagged" => "tag-badge", - "updated" => "action-updated", - "added_to_collection" => "action-collection", - "removed_from_collection" => "action-collection-remove", - "opened" => "action-opened", - "scanned" => "action-scanned", - _ => "type-other", - } + match action { + "imported" => "type-image", + "deleted" => "action-danger", + "tagged" | "untagged" => "tag-badge", + "updated" => "action-updated", + "added_to_collection" => "action-collection", + "removed_from_collection" => "action-collection-remove", + "opened" => "action-opened", + "scanned" => "action-scanned", + _ => "type-other", + } } diff --git a/crates/pinakes-ui/src/components/backlinks_panel.rs b/crates/pinakes-ui/src/components/backlinks_panel.rs index 7f8f9e0..b224eb2 100644 --- a/crates/pinakes-ui/src/components/backlinks_panel.rs +++ b/crates/pinakes-ui/src/components/backlinks_panel.rs @@ -7,374 +7,384 @@ use crate::client::{ApiClient, BacklinkItem, BacklinksResponse}; /// Panel displaying backlinks (incoming links) to a media item. #[component] pub fn BacklinksPanel( - media_id: String, - client: ApiClient, - on_navigate: EventHandler, + media_id: String, + client: ApiClient, + on_navigate: EventHandler, ) -> Element { - let mut backlinks = use_signal(|| Option::::None); - let mut loading = use_signal(|| true); - let mut error = use_signal(|| Option::::None); - let mut collapsed = use_signal(|| false); - let mut reindexing = use_signal(|| false); - let mut reindex_message = use_signal(|| Option::<(String, bool)>::None); // (message, is_error) + let mut backlinks = use_signal(|| Option::::None); + let mut loading = use_signal(|| true); + let mut error = use_signal(|| Option::::None); + let mut collapsed = use_signal(|| false); + let mut reindexing = use_signal(|| false); + let mut reindex_message = use_signal(|| Option::<(String, bool)>::None); // (message, is_error) - // Clone values for manual fetch function (used after reindex) - let fetch_client = client.clone(); - let fetch_media_id = media_id.clone(); + // Clone values for manual fetch function (used after reindex) + let fetch_client = client.clone(); + let fetch_media_id = media_id.clone(); - // Clone for reindex handler - let reindex_client = client.clone(); - let reindex_media_id = media_id.clone(); + // Clone for reindex handler + let reindex_client = client.clone(); + let reindex_media_id = media_id.clone(); - // Fetch backlinks using use_resource to automatically track media_id changes - // This ensures the backlinks are reloaded whenever we navigate to a different note - let backlinks_resource = use_resource(move || { - let client = client.clone(); - let id = media_id.clone(); - async move { client.get_backlinks(&id).await } - }); + // Fetch backlinks using use_resource to automatically track media_id changes + // This ensures the backlinks are reloaded whenever we navigate to a different + // note + let backlinks_resource = use_resource(move || { + let client = client.clone(); + let id = media_id.clone(); + async move { client.get_backlinks(&id).await } + }); - // Update local state based on resource state - use_effect(move || match &*backlinks_resource.read_unchecked() { - Some(Ok(resp)) => { - backlinks.set(Some(resp.clone())); - loading.set(false); - error.set(None); - } - Some(Err(e)) => { - error.set(Some(format!("Failed to load backlinks: {e}"))); - loading.set(false); - } - None => { - loading.set(true); - } - }); - - // Fetch backlinks function for manual refresh (like after reindex) - let fetch_backlinks = { - let client = fetch_client; - let id = fetch_media_id; - move || { - let client = client.clone(); - let id = id.clone(); - spawn(async move { - loading.set(true); - error.set(None); - match client.get_backlinks(&id).await { - Ok(resp) => { - backlinks.set(Some(resp)); - } - Err(e) => { - error.set(Some(format!("Failed to load backlinks: {e}"))); - } - } - loading.set(false); - }); - } - }; - - // Reindex links handler - let on_reindex = { - let client = reindex_client; - let id = reindex_media_id; - let fetch_backlinks = fetch_backlinks.clone(); - move |evt: MouseEvent| { - evt.stop_propagation(); // Don't toggle collapse - let client = client.clone(); - let id = id.clone(); - let fetch_backlinks = fetch_backlinks.clone(); - spawn(async move { - reindexing.set(true); - reindex_message.set(None); - match client.reindex_links(&id).await { - Ok(resp) => { - reindex_message.set(Some(( - format!("Reindexed: {} links extracted", resp.links_extracted), - false, - ))); - // Refresh backlinks after reindex - fetch_backlinks(); - } - Err(e) => { - reindex_message.set(Some((format!("Reindex failed: {e}"), true))); - } - } - reindexing.set(false); - }); - } - }; - - let is_loading = *loading.read(); - let is_collapsed = *collapsed.read(); - let is_reindexing = *reindexing.read(); - let backlink_data = backlinks.read(); - let count = backlink_data.as_ref().map(|b| b.count).unwrap_or(0); - - rsx! { - div { class: "backlinks-panel", - // Header with toggle - div { - class: "backlinks-header", - onclick: move |_| { - let current = *collapsed.read(); - collapsed.set(!current); - }, - span { class: "backlinks-toggle", - if is_collapsed { - "\u{25b6}" - } else { - "\u{25bc}" - } - } - span { class: "backlinks-title", "Backlinks" } - span { class: "backlinks-count", "({count})" } - // Reindex button - button { - class: "backlinks-reindex-btn", - title: "Re-extract links from this note", - disabled: is_reindexing, - onclick: on_reindex, - if is_reindexing { - span { class: "spinner-tiny" } - } else { - "\u{21bb}" // Refresh symbol - } - } - } - - if !is_collapsed { - div { class: "backlinks-content", - // Show reindex message if present - if let Some((ref msg, is_err)) = *reindex_message.read() { - div { class: if is_err { "backlinks-message error" } else { "backlinks-message success" }, - "{msg}" - } - } - - if is_loading { - div { class: "backlinks-loading", - div { class: "spinner-small" } - "Loading backlinks..." - } - } - - if let Some(ref err) = *error.read() { - div { class: "backlinks-error", "{err}" } - } - - if !is_loading && error.read().is_none() { - if let Some(ref data) = *backlink_data { - if data.backlinks.is_empty() { - div { class: "backlinks-empty", "No other notes link to this one." } - } else { - ul { class: "backlinks-list", - for backlink in &data.backlinks { - BacklinkItemView { - backlink: backlink.clone(), - on_navigate: on_navigate.clone(), - } - } - } - } - } - } - } - } - } + // Update local state based on resource state + use_effect(move || { + match &*backlinks_resource.read_unchecked() { + Some(Ok(resp)) => { + backlinks.set(Some(resp.clone())); + loading.set(false); + error.set(None); + }, + Some(Err(e)) => { + error.set(Some(format!("Failed to load backlinks: {e}"))); + loading.set(false); + }, + None => { + loading.set(true); + }, } + }); + + // Fetch backlinks function for manual refresh (like after reindex) + let fetch_backlinks = { + let client = fetch_client; + let id = fetch_media_id; + move || { + let client = client.clone(); + let id = id.clone(); + spawn(async move { + loading.set(true); + error.set(None); + match client.get_backlinks(&id).await { + Ok(resp) => { + backlinks.set(Some(resp)); + }, + Err(e) => { + error.set(Some(format!("Failed to load backlinks: {e}"))); + }, + } + loading.set(false); + }); + } + }; + + // Reindex links handler + let on_reindex = { + let client = reindex_client; + let id = reindex_media_id; + let fetch_backlinks = fetch_backlinks.clone(); + move |evt: MouseEvent| { + evt.stop_propagation(); // Don't toggle collapse + let client = client.clone(); + let id = id.clone(); + let fetch_backlinks = fetch_backlinks.clone(); + spawn(async move { + reindexing.set(true); + reindex_message.set(None); + match client.reindex_links(&id).await { + Ok(resp) => { + reindex_message.set(Some(( + format!("Reindexed: {} links extracted", resp.links_extracted), + false, + ))); + // Refresh backlinks after reindex + fetch_backlinks(); + }, + Err(e) => { + reindex_message.set(Some((format!("Reindex failed: {e}"), true))); + }, + } + reindexing.set(false); + }); + } + }; + + let is_loading = *loading.read(); + let is_collapsed = *collapsed.read(); + let is_reindexing = *reindexing.read(); + let backlink_data = backlinks.read(); + let count = backlink_data.as_ref().map(|b| b.count).unwrap_or(0); + + rsx! { + div { class: "backlinks-panel", + // Header with toggle + div { + class: "backlinks-header", + onclick: move |_| { + let current = *collapsed.read(); + collapsed.set(!current); + }, + span { class: "backlinks-toggle", + if is_collapsed { + "\u{25b6}" + } else { + "\u{25bc}" + } + } + span { class: "backlinks-title", "Backlinks" } + span { class: "backlinks-count", "({count})" } + // Reindex button + button { + class: "backlinks-reindex-btn", + title: "Re-extract links from this note", + disabled: is_reindexing, + onclick: on_reindex, + if is_reindexing { + span { class: "spinner-tiny" } + } else { + "\u{21bb}" // Refresh symbol + } + } + } + + if !is_collapsed { + div { class: "backlinks-content", + // Show reindex message if present + if let Some((ref msg, is_err)) = *reindex_message.read() { + div { class: if is_err { "backlinks-message error" } else { "backlinks-message success" }, + "{msg}" + } + } + + if is_loading { + div { class: "backlinks-loading", + div { class: "spinner-small" } + "Loading backlinks..." + } + } + + if let Some(ref err) = *error.read() { + div { class: "backlinks-error", "{err}" } + } + + if !is_loading && error.read().is_none() { + if let Some(ref data) = *backlink_data { + if data.backlinks.is_empty() { + div { class: "backlinks-empty", "No other notes link to this one." } + } else { + ul { class: "backlinks-list", + for backlink in &data.backlinks { + BacklinkItemView { + backlink: backlink.clone(), + on_navigate: on_navigate.clone(), + } + } + } + } + } + } + } + } + } + } } /// Individual backlink item view. #[component] -fn BacklinkItemView(backlink: BacklinkItem, on_navigate: EventHandler) -> Element { - let source_id = backlink.source_id.clone(); - let title = backlink - .source_title - .clone() - .unwrap_or_else(|| backlink.source_path.clone()); - let context = backlink.context.clone(); - let line_number = backlink.line_number; - let link_type = backlink.link_type.clone(); +fn BacklinkItemView( + backlink: BacklinkItem, + on_navigate: EventHandler, +) -> Element { + let source_id = backlink.source_id.clone(); + let title = backlink + .source_title + .clone() + .unwrap_or_else(|| backlink.source_path.clone()); + let context = backlink.context.clone(); + let line_number = backlink.line_number; + let link_type = backlink.link_type.clone(); - rsx! { - li { - class: "backlink-item", - onclick: move |_| on_navigate.call(source_id.clone()), - div { class: "backlink-source", - span { class: "backlink-title", "{title}" } - span { class: "backlink-type-badge backlink-type-{link_type}", "{link_type}" } - } - if let Some(ref ctx) = context { - div { class: "backlink-context", - if let Some(ln) = line_number { - span { class: "backlink-line", "L{ln}: " } - } - "\"{ctx}\"" - } - } - } - } + rsx! { + li { + class: "backlink-item", + onclick: move |_| on_navigate.call(source_id.clone()), + div { class: "backlink-source", + span { class: "backlink-title", "{title}" } + span { class: "backlink-type-badge backlink-type-{link_type}", "{link_type}" } + } + if let Some(ref ctx) = context { + div { class: "backlink-context", + if let Some(ln) = line_number { + span { class: "backlink-line", "L{ln}: " } + } + "\"{ctx}\"" + } + } + } + } } /// Outgoing links panel showing what this note links to. #[component] pub fn OutgoingLinksPanel( - media_id: String, - client: ApiClient, - on_navigate: EventHandler, + media_id: String, + client: ApiClient, + on_navigate: EventHandler, ) -> Element { - let mut links = use_signal(|| Option::::None); - let mut loading = use_signal(|| true); - let mut error = use_signal(|| Option::::None); - let mut collapsed = use_signal(|| true); // Collapsed by default - let mut global_unresolved = use_signal(|| Option::::None); + let mut links = + use_signal(|| Option::::None); + let mut loading = use_signal(|| true); + let mut error = use_signal(|| Option::::None); + let mut collapsed = use_signal(|| true); // Collapsed by default + let mut global_unresolved = use_signal(|| Option::::None); - // Fetch outgoing links using use_resource to automatically track media_id changes - // This ensures the links are reloaded whenever we navigate to a different note - let links_resource = use_resource(move || { - let client = client.clone(); - let id = media_id.clone(); - async move { - let links_result = client.get_outgoing_links(&id).await; - let unresolved_count = client.get_unresolved_links_count().await.ok(); - (links_result, unresolved_count) - } - }); - - // Update local state based on resource state - use_effect(move || match &*links_resource.read_unchecked() { - Some((Ok(resp), unresolved_count)) => { - links.set(Some(resp.clone())); - loading.set(false); - error.set(None); - if let Some(count) = unresolved_count { - global_unresolved.set(Some(*count)); - } - } - Some((Err(e), _)) => { - error.set(Some(format!("Failed to load links: {e}"))); - loading.set(false); - } - None => { - loading.set(true); - } - }); - - let is_loading = *loading.read(); - let is_collapsed = *collapsed.read(); - let link_data = links.read(); - let count = link_data.as_ref().map(|l| l.count).unwrap_or(0); - let unresolved_in_note = link_data - .as_ref() - .map(|l| l.links.iter().filter(|link| !link.is_resolved).count()) - .unwrap_or(0); - - rsx! { - div { class: "outgoing-links-panel", - // Header with toggle - div { - class: "outgoing-links-header", - onclick: move |_| { - let current = *collapsed.read(); - collapsed.set(!current); - }, - span { class: "outgoing-links-toggle", - if is_collapsed { - "\u{25b6}" - } else { - "\u{25bc}" - } - } - span { class: "outgoing-links-title", "Outgoing Links" } - span { class: "outgoing-links-count", "({count})" } - if unresolved_in_note > 0 { - span { - class: "outgoing-links-unresolved-badge", - title: "Unresolved links in this note", - "{unresolved_in_note} unresolved" - } - } - } - - if !is_collapsed { - div { class: "outgoing-links-content", - if is_loading { - div { class: "outgoing-links-loading", - div { class: "spinner-small" } - "Loading links..." - } - } - - if let Some(ref err) = *error.read() { - div { class: "outgoing-links-error", "{err}" } - } - - if !is_loading && error.read().is_none() { - if let Some(ref data) = *link_data { - if data.links.is_empty() { - div { class: "outgoing-links-empty", "This note has no outgoing links." } - } else { - ul { class: "outgoing-links-list", - for link in &data.links { - OutgoingLinkItemView { - link: link.clone(), - on_navigate: on_navigate.clone(), - } - } - } - } - } - - // Show global unresolved count if any - if let Some(global_count) = *global_unresolved.read() { - if global_count > 0 { - div { class: "outgoing-links-global-unresolved", - span { class: "unresolved-icon", "\u{26a0}" } - " {global_count} unresolved links across all notes" - } - } - } - } - } - } - } + // Fetch outgoing links using use_resource to automatically track media_id + // changes This ensures the links are reloaded whenever we navigate to a + // different note + let links_resource = use_resource(move || { + let client = client.clone(); + let id = media_id.clone(); + async move { + let links_result = client.get_outgoing_links(&id).await; + let unresolved_count = client.get_unresolved_links_count().await.ok(); + (links_result, unresolved_count) } + }); + + // Update local state based on resource state + use_effect(move || { + match &*links_resource.read_unchecked() { + Some((Ok(resp), unresolved_count)) => { + links.set(Some(resp.clone())); + loading.set(false); + error.set(None); + if let Some(count) = unresolved_count { + global_unresolved.set(Some(*count)); + } + }, + Some((Err(e), _)) => { + error.set(Some(format!("Failed to load links: {e}"))); + loading.set(false); + }, + None => { + loading.set(true); + }, + } + }); + + let is_loading = *loading.read(); + let is_collapsed = *collapsed.read(); + let link_data = links.read(); + let count = link_data.as_ref().map(|l| l.count).unwrap_or(0); + let unresolved_in_note = link_data + .as_ref() + .map(|l| l.links.iter().filter(|link| !link.is_resolved).count()) + .unwrap_or(0); + + rsx! { + div { class: "outgoing-links-panel", + // Header with toggle + div { + class: "outgoing-links-header", + onclick: move |_| { + let current = *collapsed.read(); + collapsed.set(!current); + }, + span { class: "outgoing-links-toggle", + if is_collapsed { + "\u{25b6}" + } else { + "\u{25bc}" + } + } + span { class: "outgoing-links-title", "Outgoing Links" } + span { class: "outgoing-links-count", "({count})" } + if unresolved_in_note > 0 { + span { + class: "outgoing-links-unresolved-badge", + title: "Unresolved links in this note", + "{unresolved_in_note} unresolved" + } + } + } + + if !is_collapsed { + div { class: "outgoing-links-content", + if is_loading { + div { class: "outgoing-links-loading", + div { class: "spinner-small" } + "Loading links..." + } + } + + if let Some(ref err) = *error.read() { + div { class: "outgoing-links-error", "{err}" } + } + + if !is_loading && error.read().is_none() { + if let Some(ref data) = *link_data { + if data.links.is_empty() { + div { class: "outgoing-links-empty", "This note has no outgoing links." } + } else { + ul { class: "outgoing-links-list", + for link in &data.links { + OutgoingLinkItemView { + link: link.clone(), + on_navigate: on_navigate.clone(), + } + } + } + } + } + + // Show global unresolved count if any + if let Some(global_count) = *global_unresolved.read() { + if global_count > 0 { + div { class: "outgoing-links-global-unresolved", + span { class: "unresolved-icon", "\u{26a0}" } + " {global_count} unresolved links across all notes" + } + } + } + } + } + } + } + } } /// Individual outgoing link item view. #[component] fn OutgoingLinkItemView( - link: crate::client::OutgoingLinkItem, - on_navigate: EventHandler, + link: crate::client::OutgoingLinkItem, + on_navigate: EventHandler, ) -> Element { - let target_id = link.target_id.clone(); - let target_path = link.target_path.clone(); - let link_text = link.link_text.clone(); - let is_resolved = link.is_resolved; - let link_type = link.link_type.clone(); + let target_id = link.target_id.clone(); + let target_path = link.target_path.clone(); + let link_text = link.link_text.clone(); + let is_resolved = link.is_resolved; + let link_type = link.link_type.clone(); - let display_text = link_text.unwrap_or_else(|| target_path.clone()); - let resolved_class = if is_resolved { - "resolved" - } else { - "unresolved" - }; + let display_text = link_text.unwrap_or_else(|| target_path.clone()); + let resolved_class = if is_resolved { + "resolved" + } else { + "unresolved" + }; - rsx! { - li { - class: "outgoing-link-item {resolved_class}", - onclick: move |_| { - if let Some(ref id) = target_id { - on_navigate.call(id.clone()); - } - }, - div { class: "outgoing-link-target", - span { class: "outgoing-link-text", "{display_text}" } - span { class: "outgoing-link-type-badge link-type-{link_type}", "{link_type}" } - if !is_resolved { - span { class: "unresolved-badge", "unresolved" } - } - } - } - } + rsx! { + li { + class: "outgoing-link-item {resolved_class}", + onclick: move |_| { + if let Some(ref id) = target_id { + on_navigate.call(id.clone()); + } + }, + div { class: "outgoing-link-target", + span { class: "outgoing-link-text", "{display_text}" } + span { class: "outgoing-link-type-badge link-type-{link_type}", "{link_type}" } + if !is_resolved { + span { class: "unresolved-badge", "unresolved" } + } + } + } + } } diff --git a/crates/pinakes-ui/src/components/breadcrumb.rs b/crates/pinakes-ui/src/components/breadcrumb.rs index 1f22f1b..68a90c2 100644 --- a/crates/pinakes-ui/src/components/breadcrumb.rs +++ b/crates/pinakes-ui/src/components/breadcrumb.rs @@ -2,41 +2,41 @@ use dioxus::prelude::*; #[derive(Debug, Clone, PartialEq)] pub struct BreadcrumbItem { - pub label: String, - pub view: Option, + pub label: String, + pub view: Option, } #[component] pub fn Breadcrumb( - items: Vec, - on_navigate: EventHandler>, + items: Vec, + on_navigate: EventHandler>, ) -> Element { - rsx! { - nav { class: "breadcrumb", - for (i , item) in items.iter().enumerate() { - if i > 0 { - span { class: "breadcrumb-sep", " > " } - } - if i < items.len() - 1 { - { - let view = item.view.clone(); - let label = item.label.clone(); - rsx! { - a { - class: "breadcrumb-link", - href: "#", - onclick: move |e: Event| { - e.prevent_default(); - on_navigate.call(view.clone()); - }, - "{label}" - } - } - } - } else { - span { class: "breadcrumb-current", "{item.label}" } - } - } - } - } + rsx! { + nav { class: "breadcrumb", + for (i , item) in items.iter().enumerate() { + if i > 0 { + span { class: "breadcrumb-sep", " > " } + } + if i < items.len() - 1 { + { + let view = item.view.clone(); + let label = item.label.clone(); + rsx! { + a { + class: "breadcrumb-link", + href: "#", + onclick: move |e: Event| { + e.prevent_default(); + on_navigate.call(view.clone()); + }, + "{label}" + } + } + } + } else { + span { class: "breadcrumb-current", "{item.label}" } + } + } + } + } } diff --git a/crates/pinakes-ui/src/components/collections.rs b/crates/pinakes-ui/src/components/collections.rs index 0b7267f..213485d 100644 --- a/crates/pinakes-ui/src/components/collections.rs +++ b/crates/pinakes-ui/src/components/collections.rs @@ -5,101 +5,101 @@ use crate::client::{CollectionResponse, MediaResponse}; #[component] pub fn Collections( - collections: Vec, - collection_members: Vec, - viewing_collection: Option, - on_create: EventHandler<(String, String, Option, Option)>, - on_delete: EventHandler, - on_view_members: EventHandler, - on_back_to_list: EventHandler<()>, - on_remove_member: EventHandler<(String, String)>, - on_select: EventHandler, - on_add_member: EventHandler<(String, String)>, - all_media: Vec, + collections: Vec, + collection_members: Vec, + viewing_collection: Option, + on_create: EventHandler<(String, String, Option, Option)>, + on_delete: EventHandler, + on_view_members: EventHandler, + on_back_to_list: EventHandler<()>, + on_remove_member: EventHandler<(String, String)>, + on_select: EventHandler, + on_add_member: EventHandler<(String, String)>, + all_media: Vec, ) -> Element { - let mut new_name = use_signal(String::new); - let mut new_kind = use_signal(|| String::from("manual")); - let mut new_description = use_signal(String::new); - let mut new_filter_query = use_signal(String::new); - let mut confirm_delete: Signal> = use_signal(|| None); - let mut show_add_modal = use_signal(|| false); + let mut new_name = use_signal(String::new); + let mut new_kind = use_signal(|| String::from("manual")); + let mut new_description = use_signal(String::new); + let mut new_filter_query = use_signal(String::new); + let mut confirm_delete: Signal> = use_signal(|| None); + let mut show_add_modal = use_signal(|| false); - // Detail view: viewing a specific collection's members - if let Some(ref col_id) = viewing_collection { - let col_name = collections - .iter() - .find(|c| &c.id == col_id) - .map(|c| c.name.clone()) - .unwrap_or_else(|| col_id.clone()); + // Detail view: viewing a specific collection's members + if let Some(ref col_id) = viewing_collection { + let col_name = collections + .iter() + .find(|c| &c.id == col_id) + .map(|c| c.name.clone()) + .unwrap_or_else(|| col_id.clone()); - let back_click = move |_| on_back_to_list.call(()); + let back_click = move |_| on_back_to_list.call(()); - // Collect IDs of current members to filter available media - let member_ids: Vec = collection_members.iter().map(|m| m.id.clone()).collect(); - let available_media: Vec<&MediaResponse> = all_media - .iter() - .filter(|m| !member_ids.contains(&m.id)) - .collect(); + // Collect IDs of current members to filter available media + let member_ids: Vec = + collection_members.iter().map(|m| m.id.clone()).collect(); + let available_media: Vec<&MediaResponse> = all_media + .iter() + .filter(|m| !member_ids.contains(&m.id)) + .collect(); - let modal_col_id = col_id.clone(); + let modal_col_id = col_id.clone(); - return rsx! { - button { class: "btn btn-ghost mb-16", onclick: back_click, "\u{2190} Back to Collections" } + return rsx! { + button { class: "btn btn-ghost mb-16", onclick: back_click, "\u{2190} Back to Collections" } - h3 { class: "mb-16", "{col_name}" } + h3 { class: "mb-16", "{col_name}" } - div { class: "form-row mb-16", - button { - class: "btn btn-primary", - onclick: move |_| show_add_modal.set(true), - "Add Media" - } + div { class: "form-row mb-16", + button { + class: "btn btn-primary", + onclick: move |_| show_add_modal.set(true), + "Add Media" } + } - if collection_members.is_empty() { - div { class: "empty-state", - p { class: "empty-subtitle", "This collection has no members." } - } - } else { - table { class: "data-table", - thead { - tr { - th { "Name" } - th { "Type" } - th { "Artist" } - th { "Size" } - th { "" } - } + if collection_members.is_empty() { + div { class: "empty-state", + p { class: "empty-subtitle", "This collection has no members." } + } + } else { + table { class: "data-table", + thead { + tr { + th { "Name" } + th { "Type" } + th { "Artist" } + th { "Size" } + th { "" } } - tbody { - for item in collection_members.iter() { - { - let artist = item.artist.clone().unwrap_or_default(); - let size = format_size(item.file_size); - let badge_class = type_badge_class(&item.media_type); - let remove_cid = col_id.clone(); - let remove_mid = item.id.clone(); - let row_click = { - let mid = item.id.clone(); - move |_| on_select.call(mid.clone()) - }; - rsx! { - tr { key: "{item.id}", class: "clickable-row", onclick: row_click, - td { "{item.file_name}" } - td { - span { class: "type-badge {badge_class}", "{item.media_type}" } - } - td { "{artist}" } - td { "{size}" } - td { - button { - class: "btn btn-danger btn-sm", - onclick: move |e: Event| { - e.stop_propagation(); - on_remove_member.call((remove_cid.clone(), remove_mid.clone())); - }, - "Remove" - } + } + tbody { + for item in collection_members.iter() { + { + let artist = item.artist.clone().unwrap_or_default(); + let size = format_size(item.file_size); + let badge_class = type_badge_class(&item.media_type); + let remove_cid = col_id.clone(); + let remove_mid = item.id.clone(); + let row_click = { + let mid = item.id.clone(); + move |_| on_select.call(mid.clone()) + }; + rsx! { + tr { key: "{item.id}", class: "clickable-row", onclick: row_click, + td { "{item.file_name}" } + td { + span { class: "type-badge {badge_class}", "{item.media_type}" } + } + td { "{artist}" } + td { "{size}" } + td { + button { + class: "btn btn-danger btn-sm", + onclick: move |e: Event| { + e.stop_propagation(); + on_remove_member.call((remove_cid.clone(), remove_mid.clone())); + }, + "Remove" } } } @@ -108,56 +108,56 @@ pub fn Collections( } } } + } - // Add Media modal - if *show_add_modal.read() { + // Add Media modal + if *show_add_modal.read() { + div { + class: "modal-overlay", + onclick: move |_| show_add_modal.set(false), div { - class: "modal-overlay", - onclick: move |_| show_add_modal.set(false), - div { - class: "modal", - onclick: move |e: Event| e.stop_propagation(), - div { class: "modal-header", - h3 { "Add Media to Collection" } - button { - class: "btn btn-ghost", - onclick: move |_| show_add_modal.set(false), - "\u{2715}" - } + class: "modal", + onclick: move |e: Event| e.stop_propagation(), + div { class: "modal-header", + h3 { "Add Media to Collection" } + button { + class: "btn btn-ghost", + onclick: move |_| show_add_modal.set(false), + "\u{2715}" } - div { class: "modal-body", - if available_media.is_empty() { - p { "No media available to add." } - } else { - table { class: "data-table", - thead { - tr { - th { "Name" } - th { "Type" } - th { "Artist" } - } + } + div { class: "modal-body", + if available_media.is_empty() { + p { "No media available to add." } + } else { + table { class: "data-table", + thead { + tr { + th { "Name" } + th { "Type" } + th { "Artist" } } - tbody { - for media in available_media.iter() { - { - let artist = media.artist.clone().unwrap_or_default(); - let badge_class = type_badge_class(&media.media_type); - let add_click = { - let cid = modal_col_id.clone(); - let mid = media.id.clone(); - move |_| { - on_add_member.call((cid.clone(), mid.clone())); - show_add_modal.set(false); - } - }; - rsx! { - tr { key: "{media.id}", class: "clickable-row", onclick: add_click, - td { "{media.file_name}" } - td { - span { class: "type-badge {badge_class}", "{media.media_type}" } - } - td { "{artist}" } + } + tbody { + for media in available_media.iter() { + { + let artist = media.artist.clone().unwrap_or_default(); + let badge_class = type_badge_class(&media.media_type); + let add_click = { + let cid = modal_col_id.clone(); + let mid = media.id.clone(); + move |_| { + on_add_member.call((cid.clone(), mid.clone())); + show_add_modal.set(false); + } + }; + rsx! { + tr { key: "{media.id}", class: "clickable-row", onclick: add_click, + td { "{media.file_name}" } + td { + span { class: "type-badge {badge_class}", "{media.media_type}" } } + td { "{artist}" } } } } @@ -168,155 +168,156 @@ pub fn Collections( } } } - }; - } - - // List view: show all collections with create form - let is_virtual = *new_kind.read() == "virtual"; - - let create_click = move |_| { - let name = new_name.read().clone(); - if name.is_empty() { - return; } - let kind = new_kind.read().clone(); - let desc = { - let d = new_description.read().clone(); - if d.is_empty() { None } else { Some(d) } - }; - let filter = { - let f = new_filter_query.read().clone(); - if f.is_empty() { None } else { Some(f) } - }; - on_create.call((name, kind, desc, filter)); - new_name.set(String::new()); - new_kind.set(String::from("manual")); - new_description.set(String::new()); - new_filter_query.set(String::new()); }; + } - rsx! { - div { class: "card", - div { class: "card-header", - h3 { class: "card-title", "Collections" } - } + // List view: show all collections with create form + let is_virtual = *new_kind.read() == "virtual"; - div { class: "form-row mb-16", - input { - r#type: "text", - placeholder: "Collection name...", - value: "{new_name}", - oninput: move |e| new_name.set(e.value()), - } - select { - value: "{new_kind}", - onchange: move |e| new_kind.set(e.value()), - option { value: "manual", "Manual" } - option { value: "virtual", "Virtual" } - } - input { - r#type: "text", - placeholder: "Description (optional)...", - value: "{new_description}", - oninput: move |e| new_description.set(e.value()), - } - } - - if is_virtual { - div { class: "form-row mb-16", - input { - r#type: "text", - placeholder: "Filter query for virtual collection...", - value: "{new_filter_query}", - oninput: move |e| new_filter_query.set(e.value()), - } - } - } - - div { class: "form-row mb-16", - button { class: "btn btn-primary", onclick: create_click, "Create" } - } - - if collections.is_empty() { - div { class: "empty-state", - p { class: "empty-subtitle", "No collections yet. Create one above." } - } - } else { - table { class: "data-table", - thead { - tr { - th { "Name" } - th { "Kind" } - th { "Description" } - th { "" } - th { "" } - } - } - tbody { - for col in collections.iter() { - { - let desc = col.description.clone().unwrap_or_default(); - let kind_class = if col.kind == "virtual" { - "type-document" - } else { - "type-other" - }; - let view_click = { - let id = col.id.clone(); - move |_| on_view_members.call(id.clone()) - }; - let col_id_for_delete = col.id.clone(); - let is_confirming = confirm_delete - .read() - .as_ref() - .map(|id| id == &col.id) - .unwrap_or(false); - rsx! { - tr { key: "{col.id}", - td { "{col.name}" } - td { - span { class: "type-badge {kind_class}", "{col.kind}" } - } - td { "{desc}" } - td { - button { class: "btn btn-sm btn-secondary", onclick: view_click, "View" } - } - td { - if is_confirming { - button { - class: "btn btn-danger btn-sm", - onclick: { - let id = col_id_for_delete.clone(); - move |_| { - on_delete.call(id.clone()); - confirm_delete.set(None); - } - }, - "Confirm" - } - button { - class: "btn btn-ghost btn-sm", - onclick: move |_| confirm_delete.set(None), - "Cancel" - } - } else { - button { - class: "btn btn-danger btn-sm", - onclick: { - let id = col_id_for_delete.clone(); - move |_| confirm_delete.set(Some(id.clone())) - }, - "Delete" - } - } - } - } - } - } - } - } - } - } - } + let create_click = move |_| { + let name = new_name.read().clone(); + if name.is_empty() { + return; } + let kind = new_kind.read().clone(); + let desc = { + let d = new_description.read().clone(); + if d.is_empty() { None } else { Some(d) } + }; + let filter = { + let f = new_filter_query.read().clone(); + if f.is_empty() { None } else { Some(f) } + }; + on_create.call((name, kind, desc, filter)); + new_name.set(String::new()); + new_kind.set(String::from("manual")); + new_description.set(String::new()); + new_filter_query.set(String::new()); + }; + + rsx! { + div { class: "card", + div { class: "card-header", + h3 { class: "card-title", "Collections" } + } + + div { class: "form-row mb-16", + input { + r#type: "text", + placeholder: "Collection name...", + value: "{new_name}", + oninput: move |e| new_name.set(e.value()), + } + select { + value: "{new_kind}", + onchange: move |e| new_kind.set(e.value()), + option { value: "manual", "Manual" } + option { value: "virtual", "Virtual" } + } + input { + r#type: "text", + placeholder: "Description (optional)...", + value: "{new_description}", + oninput: move |e| new_description.set(e.value()), + } + } + + if is_virtual { + div { class: "form-row mb-16", + input { + r#type: "text", + placeholder: "Filter query for virtual collection...", + value: "{new_filter_query}", + oninput: move |e| new_filter_query.set(e.value()), + } + } + } + + div { class: "form-row mb-16", + button { class: "btn btn-primary", onclick: create_click, "Create" } + } + + if collections.is_empty() { + div { class: "empty-state", + p { class: "empty-subtitle", "No collections yet. Create one above." } + } + } else { + table { class: "data-table", + thead { + tr { + th { "Name" } + th { "Kind" } + th { "Description" } + th { "" } + th { "" } + } + } + tbody { + for col in collections.iter() { + { + let desc = col.description.clone().unwrap_or_default(); + let kind_class = if col.kind == "virtual" { + "type-document" + } else { + "type-other" + }; + let view_click = { + let id = col.id.clone(); + move |_| on_view_members.call(id.clone()) + }; + let col_id_for_delete = col.id.clone(); + let is_confirming = confirm_delete + .read() + .as_ref() + .map(|id| id == &col.id) + .unwrap_or(false); + rsx! { + tr { key: "{col.id}", + td { "{col.name}" } + td { + span { class: "type-badge {kind_class}", "{col.kind}" } + } + td { "{desc}" } + td { + button { class: "btn btn-sm btn-secondary", onclick: view_click, "View" } + } + td { + if is_confirming { + button { + class: "btn btn-danger btn-sm", + onclick: { + let id = col_id_for_delete.clone(); + move |_| { + on_delete.call(id.clone()); + confirm_delete.set(None); + } + }, + "Confirm" + } + button { + class: "btn btn-ghost btn-sm", + onclick: move |_| confirm_delete.set(None), + "Cancel" + } + } else { + button { + class: "btn btn-danger btn-sm", + onclick: { + let id = col_id_for_delete.clone(); + move |_| confirm_delete.set(Some(id.clone())) + }, + "Delete" + } + } + } + } + } + } + } + } + } + } + } + } } diff --git a/crates/pinakes-ui/src/components/database.rs b/crates/pinakes-ui/src/components/database.rs index 4a71e33..05c32a4 100644 --- a/crates/pinakes-ui/src/components/database.rs +++ b/crates/pinakes-ui/src/components/database.rs @@ -5,191 +5,191 @@ use crate::client::DatabaseStatsResponse; #[component] pub fn Database( - stats: Option, - on_refresh: EventHandler<()>, - on_vacuum: EventHandler<()>, - on_clear: EventHandler<()>, - on_backup: EventHandler, + stats: Option, + on_refresh: EventHandler<()>, + on_vacuum: EventHandler<()>, + on_clear: EventHandler<()>, + on_backup: EventHandler, ) -> Element { - let mut confirm_clear = use_signal(|| false); - let mut confirm_vacuum = use_signal(|| false); - let mut backup_path = use_signal(String::new); + let mut confirm_clear = use_signal(|| false); + let mut confirm_vacuum = use_signal(|| false); + let mut backup_path = use_signal(String::new); - rsx! { - div { class: "card mb-16", - div { class: "card-header", - h3 { class: "card-title", "Database Overview" } - button { - class: "btn btn-sm btn-secondary", - onclick: move |_| on_refresh.call(()), - "\u{21bb} Refresh" - } - } + rsx! { + div { class: "card mb-16", + div { class: "card-header", + h3 { class: "card-title", "Database Overview" } + button { + class: "btn btn-sm btn-secondary", + onclick: move |_| on_refresh.call(()), + "\u{21bb} Refresh" + } + } - match stats.as_ref() { - Some(s) => { - let size_str = format_size(s.database_size_bytes); - rsx! { - div { class: "stats-grid", - div { class: "stat-card", - div { class: "stat-value", "{s.media_count}" } - div { class: "stat-label", "Media Items" } - } - div { class: "stat-card", - div { class: "stat-value", "{s.tag_count}" } - div { class: "stat-label", "Tags" } - } - div { class: "stat-card", - div { class: "stat-value", "{s.collection_count}" } - div { class: "stat-label", "Collections" } - } - div { class: "stat-card", - div { class: "stat-value", "{s.audit_count}" } - div { class: "stat-label", "Audit Entries" } - } - div { class: "stat-card", - div { class: "stat-value", "{size_str}" } - div { class: "stat-label", "Database Size" } - } - div { class: "stat-card", - div { class: "stat-value", "{s.backend_name}" } - div { class: "stat-label", "Backend" } - } - } - } - } - None => rsx! { - div { class: "empty-state", - p { class: "text-muted", "Loading database stats..." } - } - }, - } - } + match stats.as_ref() { + Some(s) => { + let size_str = format_size(s.database_size_bytes); + rsx! { + div { class: "stats-grid", + div { class: "stat-card", + div { class: "stat-value", "{s.media_count}" } + div { class: "stat-label", "Media Items" } + } + div { class: "stat-card", + div { class: "stat-value", "{s.tag_count}" } + div { class: "stat-label", "Tags" } + } + div { class: "stat-card", + div { class: "stat-value", "{s.collection_count}" } + div { class: "stat-label", "Collections" } + } + div { class: "stat-card", + div { class: "stat-value", "{s.audit_count}" } + div { class: "stat-label", "Audit Entries" } + } + div { class: "stat-card", + div { class: "stat-value", "{size_str}" } + div { class: "stat-label", "Database Size" } + } + div { class: "stat-card", + div { class: "stat-value", "{s.backend_name}" } + div { class: "stat-label", "Backend" } + } + } + } + } + None => rsx! { + div { class: "empty-state", + p { class: "text-muted", "Loading database stats..." } + } + }, + } + } - // Maintenance actions - div { class: "card mb-16", - div { class: "card-header", - h3 { class: "card-title", "Maintenance" } - } + // Maintenance actions + div { class: "card mb-16", + div { class: "card-header", + h3 { class: "card-title", "Maintenance" } + } - div { class: "db-actions", - // Vacuum - div { class: "db-action-row", - div { class: "db-action-info", - h4 { "Vacuum Database" } - p { class: "text-muted text-sm", - "Reclaim unused disk space and optimize the database. " - "This is safe to run at any time but may briefly lock the database." - } - } - if *confirm_vacuum.read() { - div { class: "db-action-confirm", - span { class: "text-sm", "Run vacuum?" } - button { - class: "btn btn-sm btn-primary", - onclick: move |_| { - confirm_vacuum.set(false); - on_vacuum.call(()); - }, - "Confirm" - } - button { - class: "btn btn-sm btn-ghost", - onclick: move |_| confirm_vacuum.set(false), - "Cancel" - } - } - } else { - button { - class: "btn btn-secondary", - onclick: move |_| confirm_vacuum.set(true), - "Vacuum" - } - } - } + div { class: "db-actions", + // Vacuum + div { class: "db-action-row", + div { class: "db-action-info", + h4 { "Vacuum Database" } + p { class: "text-muted text-sm", + "Reclaim unused disk space and optimize the database. " + "This is safe to run at any time but may briefly lock the database." + } + } + if *confirm_vacuum.read() { + div { class: "db-action-confirm", + span { class: "text-sm", "Run vacuum?" } + button { + class: "btn btn-sm btn-primary", + onclick: move |_| { + confirm_vacuum.set(false); + on_vacuum.call(()); + }, + "Confirm" + } + button { + class: "btn btn-sm btn-ghost", + onclick: move |_| confirm_vacuum.set(false), + "Cancel" + } + } + } else { + button { + class: "btn btn-secondary", + onclick: move |_| confirm_vacuum.set(true), + "Vacuum" + } + } + } - // Backup - div { class: "db-action-row", - div { class: "db-action-info", - h4 { "Backup Database" } - p { class: "text-muted text-sm", - "Create a copy of the database at the specified path. " - "The backup is a full snapshot of the current state." - } - } - div { class: "form-row", - input { - r#type: "text", - placeholder: "/path/to/backup.db", - value: "{backup_path}", - oninput: move |e| backup_path.set(e.value()), - style: "max-width: 300px;", - } - button { - class: "btn btn-secondary", - disabled: backup_path.read().is_empty(), - onclick: { - let mut backup_path = backup_path; - move |_| { - let path = backup_path.read().clone(); - if !path.is_empty() { - on_backup.call(path); - backup_path.set(String::new()); - } - } - }, - "Backup" - } - } - } - } - } + // Backup + div { class: "db-action-row", + div { class: "db-action-info", + h4 { "Backup Database" } + p { class: "text-muted text-sm", + "Create a copy of the database at the specified path. " + "The backup is a full snapshot of the current state." + } + } + div { class: "form-row", + input { + r#type: "text", + placeholder: "/path/to/backup.db", + value: "{backup_path}", + oninput: move |e| backup_path.set(e.value()), + style: "max-width: 300px;", + } + button { + class: "btn btn-secondary", + disabled: backup_path.read().is_empty(), + onclick: { + let mut backup_path = backup_path; + move |_| { + let path = backup_path.read().clone(); + if !path.is_empty() { + on_backup.call(path); + backup_path.set(String::new()); + } + } + }, + "Backup" + } + } + } + } + } - // Danger zone - div { class: "card mb-16 danger-card", - div { class: "card-header", - h3 { class: "card-title", style: "color: var(--danger);", "Danger Zone" } - } + // Danger zone + div { class: "card mb-16 danger-card", + div { class: "card-header", + h3 { class: "card-title", style: "color: var(--danger);", "Danger Zone" } + } - div { class: "db-actions", - div { class: "db-action-row", - div { class: "db-action-info", - h4 { "Clear All Data" } - p { class: "text-muted text-sm", - "Permanently delete all media records, tags, collections, and audit entries. " - "This cannot be undone. Files on disk are not affected." - } - } - if *confirm_clear.read() { - div { class: "db-action-confirm", - span { - class: "text-sm", - style: "color: var(--danger);", - "This will delete everything. Are you sure?" - } - button { - class: "btn btn-sm btn-danger", - onclick: move |_| { - confirm_clear.set(false); - on_clear.call(()); - }, - "Yes, Delete Everything" - } - button { - class: "btn btn-sm btn-ghost", - onclick: move |_| confirm_clear.set(false), - "Cancel" - } - } - } else { - button { - class: "btn btn-danger", - onclick: move |_| confirm_clear.set(true), - "Clear All Data" - } - } - } - } - } - } + div { class: "db-actions", + div { class: "db-action-row", + div { class: "db-action-info", + h4 { "Clear All Data" } + p { class: "text-muted text-sm", + "Permanently delete all media records, tags, collections, and audit entries. " + "This cannot be undone. Files on disk are not affected." + } + } + if *confirm_clear.read() { + div { class: "db-action-confirm", + span { + class: "text-sm", + style: "color: var(--danger);", + "This will delete everything. Are you sure?" + } + button { + class: "btn btn-sm btn-danger", + onclick: move |_| { + confirm_clear.set(false); + on_clear.call(()); + }, + "Yes, Delete Everything" + } + button { + class: "btn btn-sm btn-ghost", + onclick: move |_| confirm_clear.set(false), + "Cancel" + } + } + } else { + button { + class: "btn btn-danger", + onclick: move |_| confirm_clear.set(true), + "Clear All Data" + } + } + } + } + } + } } diff --git a/crates/pinakes-ui/src/components/detail.rs b/crates/pinakes-ui/src/components/detail.rs index 3d38117..afc4778 100644 --- a/crates/pinakes-ui/src/components/detail.rs +++ b/crates/pinakes-ui/src/components/detail.rs @@ -1,823 +1,827 @@ use dioxus::prelude::*; -use super::backlinks_panel::{BacklinksPanel, OutgoingLinksPanel}; -use super::image_viewer::ImageViewer; -use super::markdown_viewer::MarkdownViewer; -use super::media_player::{MediaPlayer, PlayQueue, QueueItem, QueuePanel}; -use super::pdf_viewer::PdfViewer; -use super::utils::{format_duration, format_size, media_category, type_badge_class}; +use super::{ + backlinks_panel::{BacklinksPanel, OutgoingLinksPanel}, + image_viewer::ImageViewer, + markdown_viewer::MarkdownViewer, + media_player::{MediaPlayer, PlayQueue, QueueItem, QueuePanel}, + pdf_viewer::PdfViewer, + utils::{format_duration, format_size, media_category, type_badge_class}, +}; use crate::client::{ApiClient, MediaResponse, MediaUpdateEvent, TagResponse}; #[component] pub fn Detail( - media: MediaResponse, - media_tags: Vec, - all_tags: Vec, - server_url: String, - #[props(default = false)] autoplay: bool, - #[props(default)] play_queue: Option, - on_back: EventHandler<()>, - on_open: EventHandler, - on_update: EventHandler, - on_tag: EventHandler<(String, String)>, - on_untag: EventHandler<(String, String)>, - on_set_custom_field: EventHandler<(String, String, String, String)>, - on_delete_custom_field: EventHandler<(String, String)>, - on_delete: EventHandler, - #[props(default)] on_navigate_to_media: Option>, - #[props(default)] on_queue_select: Option>, - #[props(default)] on_queue_remove: Option>, - #[props(default)] on_queue_clear: Option>, - #[props(default)] on_queue_toggle_repeat: Option>, - #[props(default)] on_queue_toggle_shuffle: Option>, - #[props(default)] on_queue_next: Option>, - #[props(default)] on_queue_previous: Option>, - #[props(default)] on_track_ended: Option>, - #[props(default)] on_add_to_queue: Option>, + media: MediaResponse, + media_tags: Vec, + all_tags: Vec, + server_url: String, + #[props(default = false)] autoplay: bool, + #[props(default)] play_queue: Option, + on_back: EventHandler<()>, + on_open: EventHandler, + on_update: EventHandler, + on_tag: EventHandler<(String, String)>, + on_untag: EventHandler<(String, String)>, + on_set_custom_field: EventHandler<(String, String, String, String)>, + on_delete_custom_field: EventHandler<(String, String)>, + on_delete: EventHandler, + #[props(default)] on_navigate_to_media: Option>, + #[props(default)] on_queue_select: Option>, + #[props(default)] on_queue_remove: Option>, + #[props(default)] on_queue_clear: Option>, + #[props(default)] on_queue_toggle_repeat: Option>, + #[props(default)] on_queue_toggle_shuffle: Option>, + #[props(default)] on_queue_next: Option>, + #[props(default)] on_queue_previous: Option>, + #[props(default)] on_track_ended: Option>, + #[props(default)] on_add_to_queue: Option>, ) -> Element { - let mut editing = use_signal(|| false); - let mut show_image_viewer = use_signal(|| false); - let mut edit_title = use_signal(String::new); - let mut edit_artist = use_signal(String::new); - let mut edit_album = use_signal(String::new); - let mut edit_genre = use_signal(String::new); - let mut edit_year = use_signal(String::new); - let mut edit_description = use_signal(String::new); + let mut editing = use_signal(|| false); + let mut show_image_viewer = use_signal(|| false); + let mut edit_title = use_signal(String::new); + let mut edit_artist = use_signal(String::new); + let mut edit_album = use_signal(String::new); + let mut edit_genre = use_signal(String::new); + let mut edit_year = use_signal(String::new); + let mut edit_description = use_signal(String::new); - let mut add_tag_id = use_signal(String::new); + let mut add_tag_id = use_signal(String::new); - let mut new_field_name = use_signal(String::new); - let mut new_field_type = use_signal(|| "text".to_string()); - let mut new_field_value = use_signal(String::new); + let mut new_field_name = use_signal(String::new); + let mut new_field_type = use_signal(|| "text".to_string()); + let mut new_field_value = use_signal(String::new); - let mut confirm_delete = use_signal(|| false); + let mut confirm_delete = use_signal(|| false); - let id = media.id.clone(); - let title = media.title.clone().unwrap_or_default(); - let artist = media.artist.clone().unwrap_or_default(); - let album = media.album.clone().unwrap_or_default(); - let genre = media.genre.clone().unwrap_or_default(); - let year_str = media.year.map(|y| y.to_string()).unwrap_or_default(); - let duration_str = media.duration_secs.map(format_duration).unwrap_or_default(); - let description = media.description.clone().unwrap_or_default(); - let size = format_size(media.file_size); - let badge_class = type_badge_class(&media.media_type); - let custom_fields: Vec<(String, String, String)> = media - .custom_fields - .iter() - .map(|(k, v)| (k.clone(), v.field_type.clone(), v.value.clone())) - .collect(); + let id = media.id.clone(); + let title = media.title.clone().unwrap_or_default(); + let artist = media.artist.clone().unwrap_or_default(); + let album = media.album.clone().unwrap_or_default(); + let genre = media.genre.clone().unwrap_or_default(); + let year_str = media.year.map(|y| y.to_string()).unwrap_or_default(); + let duration_str = + media.duration_secs.map(format_duration).unwrap_or_default(); + let description = media.description.clone().unwrap_or_default(); + let size = format_size(media.file_size); + let badge_class = type_badge_class(&media.media_type); + let custom_fields: Vec<(String, String, String)> = media + .custom_fields + .iter() + .map(|(k, v)| (k.clone(), v.field_type.clone(), v.value.clone())) + .collect(); - let is_editing = editing(); + let is_editing = editing(); - // Separate system-extracted metadata from user-defined custom fields. - // System fields are those set by extractors (camera info, dimensions, etc.) - let system_field_names: &[&str] = &[ - "width", - "height", - "camera_make", - "camera_model", - "date_taken", - "gps_latitude", - "gps_longitude", - "iso", - "exposure_time", - "f_number", - "focal_length", - "software", - "lens_model", - "flash", - "orientation", - "track_number", - "disc_number", - "comment", - "bitrate", - "sample_rate", - "channels", - "resolution", - "video_codec", - "audio_codec", - "audio_bitrate", - ]; - let system_fields: Vec<(String, String, String)> = custom_fields - .iter() - .filter(|(k, _, _)| system_field_names.contains(&k.as_str())) - .cloned() - .collect(); - let user_fields: Vec<(String, String, String)> = custom_fields - .iter() - .filter(|(k, _, _)| !system_field_names.contains(&k.as_str())) - .cloned() - .collect(); - let has_system_fields = !system_fields.is_empty(); - let has_user_fields = !user_fields.is_empty(); + // Separate system-extracted metadata from user-defined custom fields. + // System fields are those set by extractors (camera info, dimensions, etc.) + let system_field_names: &[&str] = &[ + "width", + "height", + "camera_make", + "camera_model", + "date_taken", + "gps_latitude", + "gps_longitude", + "iso", + "exposure_time", + "f_number", + "focal_length", + "software", + "lens_model", + "flash", + "orientation", + "track_number", + "disc_number", + "comment", + "bitrate", + "sample_rate", + "channels", + "resolution", + "video_codec", + "audio_codec", + "audio_bitrate", + ]; + let system_fields: Vec<(String, String, String)> = custom_fields + .iter() + .filter(|(k, ..)| system_field_names.contains(&k.as_str())) + .cloned() + .collect(); + let user_fields: Vec<(String, String, String)> = custom_fields + .iter() + .filter(|(k, ..)| !system_field_names.contains(&k.as_str())) + .cloned() + .collect(); + let has_system_fields = !system_fields.is_empty(); + let has_user_fields = !user_fields.is_empty(); - // Media preview URLs - use ApiClient methods for consistent URL building - let client = ApiClient::new(&server_url, None); - tracing::trace!("Using API base URL: {}", client.base_url()); - let stream_url = client.stream_url(&media.id); - let thumbnail_url = client.thumbnail_url(&media.id); - let category = media_category(&media.media_type); - let has_thumbnail = media.has_thumbnail; + // Media preview URLs - use ApiClient methods for consistent URL building + let client = ApiClient::new(&server_url, None); + tracing::trace!("Using API base URL: {}", client.base_url()); + let stream_url = client.stream_url(&media.id); + let thumbnail_url = client.thumbnail_url(&media.id); + let category = media_category(&media.media_type); + let has_thumbnail = media.has_thumbnail; - // Compute available tags (all_tags minus media_tags) - let media_tag_ids: Vec = media_tags.iter().map(|t| t.id.clone()).collect(); - let available_tags: Vec = all_tags - .iter() - .filter(|t| !media_tag_ids.contains(&t.id)) - .cloned() - .collect(); + // Compute available tags (all_tags minus media_tags) + let media_tag_ids: Vec = + media_tags.iter().map(|t| t.id.clone()).collect(); + let available_tags: Vec = all_tags + .iter() + .filter(|t| !media_tag_ids.contains(&t.id)) + .cloned() + .collect(); - // Clone values needed for closures - let id_for_open = id.clone(); - let id_for_save = id.clone(); - let id_for_tag = id.clone(); - let id_for_field = id.clone(); - let id_for_delete = id.clone(); + // Clone values needed for closures + let id_for_open = id.clone(); + let id_for_save = id.clone(); + let id_for_tag = id.clone(); + let id_for_field = id.clone(); + let id_for_delete = id.clone(); - // Clone media field values for the edit button - let title_for_edit = media.title.clone().unwrap_or_default(); - let artist_for_edit = media.artist.clone().unwrap_or_default(); - let album_for_edit = media.album.clone().unwrap_or_default(); - let genre_for_edit = media.genre.clone().unwrap_or_default(); - let year_for_edit = media.year.map(|y| y.to_string()).unwrap_or_default(); - let description_for_edit = media.description.clone().unwrap_or_default(); + // Clone media field values for the edit button + let title_for_edit = media.title.clone().unwrap_or_default(); + let artist_for_edit = media.artist.clone().unwrap_or_default(); + let album_for_edit = media.album.clone().unwrap_or_default(); + let genre_for_edit = media.genre.clone().unwrap_or_default(); + let year_for_edit = media.year.map(|y| y.to_string()).unwrap_or_default(); + let description_for_edit = media.description.clone().unwrap_or_default(); - let on_edit_click = move |_| { - edit_title.set(title_for_edit.clone()); - edit_artist.set(artist_for_edit.clone()); - edit_album.set(album_for_edit.clone()); - edit_genre.set(genre_for_edit.clone()); - edit_year.set(year_for_edit.clone()); - edit_description.set(description_for_edit.clone()); - editing.set(true); - }; + let on_edit_click = move |_| { + edit_title.set(title_for_edit.clone()); + edit_artist.set(artist_for_edit.clone()); + edit_album.set(album_for_edit.clone()); + edit_genre.set(genre_for_edit.clone()); + edit_year.set(year_for_edit.clone()); + edit_description.set(description_for_edit.clone()); + editing.set(true); + }; - let on_save_click = { - let id_save = id_for_save.clone(); - move |_| { - let t = edit_title(); - let ar = edit_artist(); - let al = edit_album(); - let g = edit_genre(); - let y_str = edit_year(); - let d = edit_description(); + let on_save_click = { + let id_save = id_for_save.clone(); + move |_| { + let t = edit_title(); + let ar = edit_artist(); + let al = edit_album(); + let g = edit_genre(); + let y_str = edit_year(); + let d = edit_description(); - let title_opt = if t.is_empty() { None } else { Some(t) }; - let artist_opt = if ar.is_empty() { None } else { Some(ar) }; - let album_opt = if al.is_empty() { None } else { Some(al) }; - let genre_opt = if g.is_empty() { None } else { Some(g) }; - let year_opt = if y_str.is_empty() { - None - } else { - y_str.parse::().ok() - }; - let desc_opt = if d.is_empty() { None } else { Some(d) }; + let title_opt = if t.is_empty() { None } else { Some(t) }; + let artist_opt = if ar.is_empty() { None } else { Some(ar) }; + let album_opt = if al.is_empty() { None } else { Some(al) }; + let genre_opt = if g.is_empty() { None } else { Some(g) }; + let year_opt = if y_str.is_empty() { + None + } else { + y_str.parse::().ok() + }; + let desc_opt = if d.is_empty() { None } else { Some(d) }; - on_update.call(MediaUpdateEvent { - id: id_save.clone(), - title: title_opt, - artist: artist_opt, - album: album_opt, - genre: genre_opt, - year: year_opt, - description: desc_opt, - }); - editing.set(false); - } - }; - - let on_cancel_click = move |_| { - editing.set(false); - }; - - let on_tag_add_click = { - let id_tag = id_for_tag.clone(); - move |_| { - let tag_id = add_tag_id(); - if !tag_id.is_empty() { - on_tag.call((id_tag.clone(), tag_id)); - add_tag_id.set(String::new()); - } - } - }; - - let on_add_field_click = { - let id_field = id_for_field.clone(); - move |_| { - let name = new_field_name(); - let ft = new_field_type(); - let val = new_field_value(); - if !name.is_empty() && !val.is_empty() { - on_set_custom_field.call((id_field.clone(), name, ft, val)); - new_field_name.set(String::new()); - new_field_type.set("text".to_string()); - new_field_value.set(String::new()); - } - } - }; - - let on_delete_click = move |_| { - confirm_delete.set(true); - }; - - let on_confirm_delete = { - let id_del = id_for_delete.clone(); - move |_| { - on_delete.call(id_del.clone()); - confirm_delete.set(false); - } - }; - - let on_cancel_delete = move |_| { - confirm_delete.set(false); - }; - - let stream_url_for_viewer = stream_url.clone(); - let thumb_for_player = thumbnail_url.clone(); - let file_name_for_viewer = media.file_name.clone(); - - // Clone queue handlers for use in the component - let has_queue = play_queue.is_some(); - - rsx! { - // Media preview - div { class: "detail-preview", - if category == "audio" { - MediaPlayer { - src: stream_url.clone(), - media_type: "audio".to_string(), - title: media.title.clone(), - thumbnail_url: if has_thumbnail { Some(thumb_for_player.clone()) } else { None }, - autoplay, - on_track_ended, - } - } else if category == "video" { - MediaPlayer { - src: stream_url.clone(), - media_type: "video".to_string(), - title: media.title.clone(), - autoplay, - on_track_ended, - } - } else if category == "image" { - if has_thumbnail { - img { - src: "{thumbnail_url}", - alt: "{media.file_name}", - class: "detail-preview-image clickable", - onclick: move |_| show_image_viewer.set(true), - } - } else { - img { - src: "{stream_url}", - alt: "{media.file_name}", - class: "detail-preview-image clickable", - onclick: move |_| show_image_viewer.set(true), - } - } - } else if category == "text" { - MarkdownViewer { - content_url: stream_url.clone(), - media_type: media.media_type.clone(), - } - } else if category == "document" { - if media.media_type == "pdf" { - PdfViewer { src: stream_url.clone() } - } else { - // EPUB and other document types - div { class: "detail-no-preview", - p { class: "text-muted", "Preview not available for this document type." } - button { - class: "btn btn-primary", - onclick: { - let id_open = id.clone(); - move |_| on_open.call(id_open.clone()) - }, - "Open Externally" - } - } - } - } else if has_thumbnail { - img { - src: "{thumbnail_url}", - alt: "Thumbnail", - class: "detail-thumbnail", - } - } - } - - // Play queue panel (only for audio/video with a queue) - if has_queue && (category == "audio" || category == "video") { - if let Some(ref queue) = play_queue { - QueuePanel { - queue: queue.clone(), - on_select: { - let handler = on_queue_select; - move |idx| { - if let Some(ref h) = handler { - h.call(idx); - } - } - }, - on_remove: { - let handler = on_queue_remove; - move |idx| { - if let Some(ref h) = handler { - h.call(idx); - } - } - }, - on_clear: { - let handler = on_queue_clear; - move |_| { - if let Some(ref h) = handler { - h.call(()); - } - } - }, - on_toggle_repeat: { - let handler = on_queue_toggle_repeat; - move |_| { - if let Some(ref h) = handler { - h.call(()); - } - } - }, - on_toggle_shuffle: { - let handler = on_queue_toggle_shuffle; - move |_| { - if let Some(ref h) = handler { - h.call(()); - } - } - }, - on_next: { - let handler = on_queue_next; - move |_| { - if let Some(ref h) = handler { - h.call(()); - } - } - }, - on_previous: { - let handler = on_queue_previous; - move |_| { - if let Some(ref h) = handler { - h.call(()); - } - } - }, - } - } - } - - // Action bar - div { class: "detail-actions", - button { - class: "btn btn-secondary", - onclick: move |_| on_back.call(()), - "Back" - } - button { - class: "btn btn-primary", - onclick: { - let id_open = id_for_open.clone(); - move |_| on_open.call(id_open.clone()) - }, - "Open" - } - // Add to Queue button for audio/video content - if (category == "audio" || category == "video") && on_add_to_queue.is_some() { - { - // Check if this item is currently playing - let is_current = play_queue - .as_ref() - .and_then(|q| q.current()) - .map(|item| item.media_id == id) - .unwrap_or(false); - let media_id_for_queue = id.clone(); - let title_for_queue = media - .title - .clone() - .unwrap_or_else(|| media.file_name.clone()); - let artist_for_queue = media.artist.clone(); - let duration_for_queue = media.duration_secs; - let media_type_for_queue = category.to_string(); - let stream_url_for_queue = stream_url.clone(); - let thumbnail_for_queue = if has_thumbnail { - Some(thumbnail_url.clone()) - } else { - None - }; - let on_add = on_add_to_queue; - rsx! { - button { - class: if is_current { "btn btn-secondary disabled" } else { "btn btn-secondary" }, - disabled: is_current, - title: if is_current { "Currently playing" } else { "Add to play queue" }, - onclick: move |_| { - if let Some(ref handler) = on_add { - let item = QueueItem { - media_id: media_id_for_queue.clone(), - title: title_for_queue.clone(), - artist: artist_for_queue.clone(), - duration_secs: duration_for_queue, - media_type: media_type_for_queue.clone(), - stream_url: stream_url_for_queue.clone(), - thumbnail_url: thumbnail_for_queue.clone(), - }; - handler.call(item); - } - }, - if is_current { - "\u{266b} Playing" - } else { - "\u{2795} Queue" - } - } - } - } - } - if is_editing { - button { class: "btn btn-primary", onclick: on_save_click, "Save" } - button { class: "btn btn-ghost", onclick: on_cancel_click, "Cancel" } - } else { - button { class: "btn btn-secondary", onclick: on_edit_click, "Edit" } - } - if confirm_delete() { - button { class: "btn btn-danger", onclick: on_confirm_delete, "Confirm Delete" } - button { class: "btn btn-ghost", onclick: on_cancel_delete, "Cancel" } - } else { - button { class: "btn btn-danger", onclick: on_delete_click, "Delete" } - } - } - - // Info / Edit section - if is_editing { - div { class: "detail-grid", - // Read-only file info - div { class: "detail-field", - span { class: "detail-label", "File Name" } - span { class: "detail-value", "{media.file_name}" } - } - div { class: "detail-field", - span { class: "detail-label", "Path" } - span { class: "detail-value mono", "{media.path}" } - } - div { class: "detail-field", - span { class: "detail-label", "Type" } - span { class: "detail-value", - span { class: "type-badge {badge_class}", "{media.media_type}" } - } - } - div { class: "detail-field", - span { class: "detail-label", "Size" } - span { class: "detail-value", "{size}" } - } - div { class: "detail-field", - span { class: "detail-label", "Hash" } - span { class: "detail-value mono", "{media.content_hash}" } - } - - // Editable fields — conditional by media category - div { class: "detail-field", - label { class: "detail-label", "Title" } - input { - r#type: "text", - value: "{edit_title}", - oninput: move |e: Event| edit_title.set(e.value()), - } - } - div { class: "detail-field", - label { class: "detail-label", - { - match category { - "image" => "Photographer", - "document" | "text" => "Author", - _ => "Artist", - } - } - } - input { - r#type: "text", - value: "{edit_artist}", - oninput: move |e: Event| edit_artist.set(e.value()), - } - } - if category == "audio" { - div { class: "detail-field", - label { class: "detail-label", "Album" } - input { - r#type: "text", - value: "{edit_album}", - oninput: move |e: Event| edit_album.set(e.value()), - } - } - } - if category == "audio" || category == "video" { - div { class: "detail-field", - label { class: "detail-label", "Genre" } - input { - r#type: "text", - value: "{edit_genre}", - oninput: move |e: Event| edit_genre.set(e.value()), - } - } - } - if category == "audio" || category == "video" || category == "document" { - div { class: "detail-field", - label { class: "detail-label", "Year" } - input { - r#type: "text", - value: "{edit_year}", - oninput: move |e: Event| edit_year.set(e.value()), - } - } - } - div { class: "detail-field full-width", - label { class: "detail-label", "Description" } - textarea { - value: "{edit_description}", - oninput: move |e: Event| edit_description.set(e.value()), - } - } - } - } else { - div { class: "detail-grid", - div { class: "detail-field", - span { class: "detail-label", "File Name" } - span { class: "detail-value", "{media.file_name}" } - } - div { class: "detail-field", - span { class: "detail-label", "Path" } - span { class: "detail-value mono", "{media.path}" } - } - div { class: "detail-field", - span { class: "detail-label", "Type" } - span { class: "detail-value", - span { class: "type-badge {badge_class}", "{media.media_type}" } - } - } - div { class: "detail-field", - span { class: "detail-label", "Size" } - span { class: "detail-value", "{size}" } - } - div { class: "detail-field", - span { class: "detail-label", "Hash" } - span { class: "detail-value mono", "{media.content_hash}" } - } - // Title: only shown when non-empty - if !title.is_empty() { - div { class: "detail-field", - span { class: "detail-label", "Title" } - span { class: "detail-value", "{title}" } - } - } - // Artist/Author/Photographer: only shown when non-empty - if !artist.is_empty() { - div { class: "detail-field", - span { class: "detail-label", - { - match category { - "image" => "Photographer", - "document" | "text" => "Author", - _ => "Artist", - } - } - } - span { class: "detail-value", "{artist}" } - } - } - // Album: audio only, when non-empty - if category == "audio" && !album.is_empty() { - div { class: "detail-field", - span { class: "detail-label", "Album" } - span { class: "detail-value", "{album}" } - } - } - // Genre: audio and video, when non-empty - if (category == "audio" || category == "video") && !genre.is_empty() { - div { class: "detail-field", - span { class: "detail-label", "Genre" } - span { class: "detail-value", "{genre}" } - } - } - // Year: audio, video, document, when non-empty - if (category == "audio" || category == "video" || category == "document") - && !year_str.is_empty() - { - div { class: "detail-field", - span { class: "detail-label", "Year" } - span { class: "detail-value", "{year_str}" } - } - } - // Duration: audio and video - if (category == "audio" || category == "video") && media.duration_secs.is_some() { - div { class: "detail-field", - span { class: "detail-label", "Duration" } - span { class: "detail-value", "{duration_str}" } - } - } - // Description: only shown when non-empty - if !description.is_empty() { - div { class: "detail-field full-width", - span { class: "detail-label", "Description" } - span { class: "detail-value", "{description}" } - } - } - div { class: "detail-field", - span { class: "detail-label", "Created" } - span { class: "detail-value", "{media.created_at}" } - } - div { class: "detail-field", - span { class: "detail-label", "Updated" } - span { class: "detail-value", "{media.updated_at}" } - } - // Links extracted timestamp (only for markdown files) - if media.media_type == "md" || media.media_type == "markdown" { - if let Some(links_time) = &media.links_extracted_at { - div { class: "detail-field", - span { class: "detail-label", "Links Extracted" } - span { class: "detail-value", "{links_time}" } - } - } - } - } - } - - // Tags section - div { class: "card mb-16", - div { class: "card-header", - h4 { class: "card-title", "Tags" } - } - div { class: "tag-list mb-8", - for tag in media_tags.iter() { - { - let tag_id = tag.id.clone(); - let media_id_untag = id.clone(); - rsx! { - span { class: "tag-badge", key: "{tag_id}", - "{tag.name}" - span { - class: "tag-remove", - onclick: { - let tid = tag_id.clone(); - let mid = media_id_untag.clone(); - move |_| on_untag.call((mid.clone(), tid.clone())) - }, - "x" - } - } - } - } - } - } - div { class: "form-row", - select { - value: "{add_tag_id}", - onchange: move |e: Event| add_tag_id.set(e.value()), - option { value: "", "Add tag..." } - for tag in available_tags.iter() { - { - let tid = tag.id.clone(); - let tname = tag.name.clone(); - rsx! { - option { key: "{tid}", value: "{tid}", "{tname}" } - } - } - } - } - button { - class: "btn btn-sm btn-primary", - onclick: on_tag_add_click, - "Add" - } - } - } - - // Technical Metadata section (system-extracted fields) - if has_system_fields { - div { class: "card mb-16", - div { class: "card-header", - h4 { class: "card-title", "Technical Metadata" } - } - div { class: "detail-grid", - for (key , _field_type , value) in system_fields.iter() { - div { class: "detail-field", key: "{key}", - span { class: "detail-label", "{key}" } - span { class: "detail-value", "{value}" } - } - } - } - } - } - - // Custom Fields section (user-defined) - div { class: "card", - div { class: "card-header", - h4 { class: "card-title", "Custom Fields" } - } - if has_user_fields { - div { class: "detail-grid", - for (key , field_type , value) in user_fields.iter() { - { - let field_name = key.clone(); - let media_id_del = id.clone(); - rsx! { - div { class: "detail-field", key: "{field_name}", - span { class: "detail-label", "{key} ({field_type})" } - div { class: "flex-row", - span { class: "detail-value", "{value}" } - button { - class: "btn-icon", - onclick: { - let fname = field_name.clone(); - let mid = media_id_del.clone(); - move |_| on_delete_custom_field.call((mid.clone(), fname.clone())) - }, - "x" - } - } - } - } - } - } - } - } - div { class: "form-row", - input { - r#type: "text", - placeholder: "Field name", - value: "{new_field_name}", - oninput: move |e: Event| new_field_name.set(e.value()), - } - select { - value: "{new_field_type}", - onchange: move |e: Event| new_field_type.set(e.value()), - option { value: "text", "text" } - option { value: "number", "number" } - option { value: "date", "date" } - option { value: "boolean", "boolean" } - } - input { - r#type: "text", - placeholder: "Value", - value: "{new_field_value}", - oninput: move |e: Event| new_field_value.set(e.value()), - } - button { - class: "btn btn-sm btn-primary", - onclick: on_add_field_click, - "Add" - } - } - } - - // Backlinks and outgoing links panels for markdown/text files - if category == "text" { - { - let client_for_backlinks = client.clone(); - let client_for_outgoing = client.clone(); - let media_id_for_backlinks = id.clone(); - let media_id_for_outgoing = id.clone(); - let nav_handler = on_navigate_to_media; - rsx! { - BacklinksPanel { - media_id: media_id_for_backlinks, - client: client_for_backlinks, - on_navigate: { - let handler = nav_handler; - move |target_id: String| { - if let Some(ref h) = handler { - h.call(target_id); - } - } - }, - } - OutgoingLinksPanel { - media_id: media_id_for_outgoing, - client: client_for_outgoing, - on_navigate: { - let handler = nav_handler; - move |target_id: String| { - if let Some(ref h) = handler { - h.call(target_id); - } - } - }, - } - } - } - } - - // Image viewer overlay - if *show_image_viewer.read() { - ImageViewer { - src: stream_url_for_viewer.clone(), - alt: file_name_for_viewer.clone(), - on_close: move |_| show_image_viewer.set(false), - } - } + on_update.call(MediaUpdateEvent { + id: id_save.clone(), + title: title_opt, + artist: artist_opt, + album: album_opt, + genre: genre_opt, + year: year_opt, + description: desc_opt, + }); + editing.set(false); } + }; + + let on_cancel_click = move |_| { + editing.set(false); + }; + + let on_tag_add_click = { + let id_tag = id_for_tag.clone(); + move |_| { + let tag_id = add_tag_id(); + if !tag_id.is_empty() { + on_tag.call((id_tag.clone(), tag_id)); + add_tag_id.set(String::new()); + } + } + }; + + let on_add_field_click = { + let id_field = id_for_field.clone(); + move |_| { + let name = new_field_name(); + let ft = new_field_type(); + let val = new_field_value(); + if !name.is_empty() && !val.is_empty() { + on_set_custom_field.call((id_field.clone(), name, ft, val)); + new_field_name.set(String::new()); + new_field_type.set("text".to_string()); + new_field_value.set(String::new()); + } + } + }; + + let on_delete_click = move |_| { + confirm_delete.set(true); + }; + + let on_confirm_delete = { + let id_del = id_for_delete.clone(); + move |_| { + on_delete.call(id_del.clone()); + confirm_delete.set(false); + } + }; + + let on_cancel_delete = move |_| { + confirm_delete.set(false); + }; + + let stream_url_for_viewer = stream_url.clone(); + let thumb_for_player = thumbnail_url.clone(); + let file_name_for_viewer = media.file_name.clone(); + + // Clone queue handlers for use in the component + let has_queue = play_queue.is_some(); + + rsx! { + // Media preview + div { class: "detail-preview", + if category == "audio" { + MediaPlayer { + src: stream_url.clone(), + media_type: "audio".to_string(), + title: media.title.clone(), + thumbnail_url: if has_thumbnail { Some(thumb_for_player.clone()) } else { None }, + autoplay, + on_track_ended, + } + } else if category == "video" { + MediaPlayer { + src: stream_url.clone(), + media_type: "video".to_string(), + title: media.title.clone(), + autoplay, + on_track_ended, + } + } else if category == "image" { + if has_thumbnail { + img { + src: "{thumbnail_url}", + alt: "{media.file_name}", + class: "detail-preview-image clickable", + onclick: move |_| show_image_viewer.set(true), + } + } else { + img { + src: "{stream_url}", + alt: "{media.file_name}", + class: "detail-preview-image clickable", + onclick: move |_| show_image_viewer.set(true), + } + } + } else if category == "text" { + MarkdownViewer { + content_url: stream_url.clone(), + media_type: media.media_type.clone(), + } + } else if category == "document" { + if media.media_type == "pdf" { + PdfViewer { src: stream_url.clone() } + } else { + // EPUB and other document types + div { class: "detail-no-preview", + p { class: "text-muted", "Preview not available for this document type." } + button { + class: "btn btn-primary", + onclick: { + let id_open = id.clone(); + move |_| on_open.call(id_open.clone()) + }, + "Open Externally" + } + } + } + } else if has_thumbnail { + img { + src: "{thumbnail_url}", + alt: "Thumbnail", + class: "detail-thumbnail", + } + } + } + + // Play queue panel (only for audio/video with a queue) + if has_queue && (category == "audio" || category == "video") { + if let Some(ref queue) = play_queue { + QueuePanel { + queue: queue.clone(), + on_select: { + let handler = on_queue_select; + move |idx| { + if let Some(ref h) = handler { + h.call(idx); + } + } + }, + on_remove: { + let handler = on_queue_remove; + move |idx| { + if let Some(ref h) = handler { + h.call(idx); + } + } + }, + on_clear: { + let handler = on_queue_clear; + move |_| { + if let Some(ref h) = handler { + h.call(()); + } + } + }, + on_toggle_repeat: { + let handler = on_queue_toggle_repeat; + move |_| { + if let Some(ref h) = handler { + h.call(()); + } + } + }, + on_toggle_shuffle: { + let handler = on_queue_toggle_shuffle; + move |_| { + if let Some(ref h) = handler { + h.call(()); + } + } + }, + on_next: { + let handler = on_queue_next; + move |_| { + if let Some(ref h) = handler { + h.call(()); + } + } + }, + on_previous: { + let handler = on_queue_previous; + move |_| { + if let Some(ref h) = handler { + h.call(()); + } + } + }, + } + } + } + + // Action bar + div { class: "detail-actions", + button { + class: "btn btn-secondary", + onclick: move |_| on_back.call(()), + "Back" + } + button { + class: "btn btn-primary", + onclick: { + let id_open = id_for_open.clone(); + move |_| on_open.call(id_open.clone()) + }, + "Open" + } + // Add to Queue button for audio/video content + if (category == "audio" || category == "video") && on_add_to_queue.is_some() { + { + // Check if this item is currently playing + let is_current = play_queue + .as_ref() + .and_then(|q| q.current()) + .map(|item| item.media_id == id) + .unwrap_or(false); + let media_id_for_queue = id.clone(); + let title_for_queue = media + .title + .clone() + .unwrap_or_else(|| media.file_name.clone()); + let artist_for_queue = media.artist.clone(); + let duration_for_queue = media.duration_secs; + let media_type_for_queue = category.to_string(); + let stream_url_for_queue = stream_url.clone(); + let thumbnail_for_queue = if has_thumbnail { + Some(thumbnail_url.clone()) + } else { + None + }; + let on_add = on_add_to_queue; + rsx! { + button { + class: if is_current { "btn btn-secondary disabled" } else { "btn btn-secondary" }, + disabled: is_current, + title: if is_current { "Currently playing" } else { "Add to play queue" }, + onclick: move |_| { + if let Some(ref handler) = on_add { + let item = QueueItem { + media_id: media_id_for_queue.clone(), + title: title_for_queue.clone(), + artist: artist_for_queue.clone(), + duration_secs: duration_for_queue, + media_type: media_type_for_queue.clone(), + stream_url: stream_url_for_queue.clone(), + thumbnail_url: thumbnail_for_queue.clone(), + }; + handler.call(item); + } + }, + if is_current { + "\u{266b} Playing" + } else { + "\u{2795} Queue" + } + } + } + } + } + if is_editing { + button { class: "btn btn-primary", onclick: on_save_click, "Save" } + button { class: "btn btn-ghost", onclick: on_cancel_click, "Cancel" } + } else { + button { class: "btn btn-secondary", onclick: on_edit_click, "Edit" } + } + if confirm_delete() { + button { class: "btn btn-danger", onclick: on_confirm_delete, "Confirm Delete" } + button { class: "btn btn-ghost", onclick: on_cancel_delete, "Cancel" } + } else { + button { class: "btn btn-danger", onclick: on_delete_click, "Delete" } + } + } + + // Info / Edit section + if is_editing { + div { class: "detail-grid", + // Read-only file info + div { class: "detail-field", + span { class: "detail-label", "File Name" } + span { class: "detail-value", "{media.file_name}" } + } + div { class: "detail-field", + span { class: "detail-label", "Path" } + span { class: "detail-value mono", "{media.path}" } + } + div { class: "detail-field", + span { class: "detail-label", "Type" } + span { class: "detail-value", + span { class: "type-badge {badge_class}", "{media.media_type}" } + } + } + div { class: "detail-field", + span { class: "detail-label", "Size" } + span { class: "detail-value", "{size}" } + } + div { class: "detail-field", + span { class: "detail-label", "Hash" } + span { class: "detail-value mono", "{media.content_hash}" } + } + + // Editable fields — conditional by media category + div { class: "detail-field", + label { class: "detail-label", "Title" } + input { + r#type: "text", + value: "{edit_title}", + oninput: move |e: Event| edit_title.set(e.value()), + } + } + div { class: "detail-field", + label { class: "detail-label", + { + match category { + "image" => "Photographer", + "document" | "text" => "Author", + _ => "Artist", + } + } + } + input { + r#type: "text", + value: "{edit_artist}", + oninput: move |e: Event| edit_artist.set(e.value()), + } + } + if category == "audio" { + div { class: "detail-field", + label { class: "detail-label", "Album" } + input { + r#type: "text", + value: "{edit_album}", + oninput: move |e: Event| edit_album.set(e.value()), + } + } + } + if category == "audio" || category == "video" { + div { class: "detail-field", + label { class: "detail-label", "Genre" } + input { + r#type: "text", + value: "{edit_genre}", + oninput: move |e: Event| edit_genre.set(e.value()), + } + } + } + if category == "audio" || category == "video" || category == "document" { + div { class: "detail-field", + label { class: "detail-label", "Year" } + input { + r#type: "text", + value: "{edit_year}", + oninput: move |e: Event| edit_year.set(e.value()), + } + } + } + div { class: "detail-field full-width", + label { class: "detail-label", "Description" } + textarea { + value: "{edit_description}", + oninput: move |e: Event| edit_description.set(e.value()), + } + } + } + } else { + div { class: "detail-grid", + div { class: "detail-field", + span { class: "detail-label", "File Name" } + span { class: "detail-value", "{media.file_name}" } + } + div { class: "detail-field", + span { class: "detail-label", "Path" } + span { class: "detail-value mono", "{media.path}" } + } + div { class: "detail-field", + span { class: "detail-label", "Type" } + span { class: "detail-value", + span { class: "type-badge {badge_class}", "{media.media_type}" } + } + } + div { class: "detail-field", + span { class: "detail-label", "Size" } + span { class: "detail-value", "{size}" } + } + div { class: "detail-field", + span { class: "detail-label", "Hash" } + span { class: "detail-value mono", "{media.content_hash}" } + } + // Title: only shown when non-empty + if !title.is_empty() { + div { class: "detail-field", + span { class: "detail-label", "Title" } + span { class: "detail-value", "{title}" } + } + } + // Artist/Author/Photographer: only shown when non-empty + if !artist.is_empty() { + div { class: "detail-field", + span { class: "detail-label", + { + match category { + "image" => "Photographer", + "document" | "text" => "Author", + _ => "Artist", + } + } + } + span { class: "detail-value", "{artist}" } + } + } + // Album: audio only, when non-empty + if category == "audio" && !album.is_empty() { + div { class: "detail-field", + span { class: "detail-label", "Album" } + span { class: "detail-value", "{album}" } + } + } + // Genre: audio and video, when non-empty + if (category == "audio" || category == "video") && !genre.is_empty() { + div { class: "detail-field", + span { class: "detail-label", "Genre" } + span { class: "detail-value", "{genre}" } + } + } + // Year: audio, video, document, when non-empty + if (category == "audio" || category == "video" || category == "document") + && !year_str.is_empty() + { + div { class: "detail-field", + span { class: "detail-label", "Year" } + span { class: "detail-value", "{year_str}" } + } + } + // Duration: audio and video + if (category == "audio" || category == "video") && media.duration_secs.is_some() { + div { class: "detail-field", + span { class: "detail-label", "Duration" } + span { class: "detail-value", "{duration_str}" } + } + } + // Description: only shown when non-empty + if !description.is_empty() { + div { class: "detail-field full-width", + span { class: "detail-label", "Description" } + span { class: "detail-value", "{description}" } + } + } + div { class: "detail-field", + span { class: "detail-label", "Created" } + span { class: "detail-value", "{media.created_at}" } + } + div { class: "detail-field", + span { class: "detail-label", "Updated" } + span { class: "detail-value", "{media.updated_at}" } + } + // Links extracted timestamp (only for markdown files) + if media.media_type == "md" || media.media_type == "markdown" { + if let Some(links_time) = &media.links_extracted_at { + div { class: "detail-field", + span { class: "detail-label", "Links Extracted" } + span { class: "detail-value", "{links_time}" } + } + } + } + } + } + + // Tags section + div { class: "card mb-16", + div { class: "card-header", + h4 { class: "card-title", "Tags" } + } + div { class: "tag-list mb-8", + for tag in media_tags.iter() { + { + let tag_id = tag.id.clone(); + let media_id_untag = id.clone(); + rsx! { + span { class: "tag-badge", key: "{tag_id}", + "{tag.name}" + span { + class: "tag-remove", + onclick: { + let tid = tag_id.clone(); + let mid = media_id_untag.clone(); + move |_| on_untag.call((mid.clone(), tid.clone())) + }, + "x" + } + } + } + } + } + } + div { class: "form-row", + select { + value: "{add_tag_id}", + onchange: move |e: Event| add_tag_id.set(e.value()), + option { value: "", "Add tag..." } + for tag in available_tags.iter() { + { + let tid = tag.id.clone(); + let tname = tag.name.clone(); + rsx! { + option { key: "{tid}", value: "{tid}", "{tname}" } + } + } + } + } + button { + class: "btn btn-sm btn-primary", + onclick: on_tag_add_click, + "Add" + } + } + } + + // Technical Metadata section (system-extracted fields) + if has_system_fields { + div { class: "card mb-16", + div { class: "card-header", + h4 { class: "card-title", "Technical Metadata" } + } + div { class: "detail-grid", + for (key , _field_type , value) in system_fields.iter() { + div { class: "detail-field", key: "{key}", + span { class: "detail-label", "{key}" } + span { class: "detail-value", "{value}" } + } + } + } + } + } + + // Custom Fields section (user-defined) + div { class: "card", + div { class: "card-header", + h4 { class: "card-title", "Custom Fields" } + } + if has_user_fields { + div { class: "detail-grid", + for (key , field_type , value) in user_fields.iter() { + { + let field_name = key.clone(); + let media_id_del = id.clone(); + rsx! { + div { class: "detail-field", key: "{field_name}", + span { class: "detail-label", "{key} ({field_type})" } + div { class: "flex-row", + span { class: "detail-value", "{value}" } + button { + class: "btn-icon", + onclick: { + let fname = field_name.clone(); + let mid = media_id_del.clone(); + move |_| on_delete_custom_field.call((mid.clone(), fname.clone())) + }, + "x" + } + } + } + } + } + } + } + } + div { class: "form-row", + input { + r#type: "text", + placeholder: "Field name", + value: "{new_field_name}", + oninput: move |e: Event| new_field_name.set(e.value()), + } + select { + value: "{new_field_type}", + onchange: move |e: Event| new_field_type.set(e.value()), + option { value: "text", "text" } + option { value: "number", "number" } + option { value: "date", "date" } + option { value: "boolean", "boolean" } + } + input { + r#type: "text", + placeholder: "Value", + value: "{new_field_value}", + oninput: move |e: Event| new_field_value.set(e.value()), + } + button { + class: "btn btn-sm btn-primary", + onclick: on_add_field_click, + "Add" + } + } + } + + // Backlinks and outgoing links panels for markdown/text files + if category == "text" { + { + let client_for_backlinks = client.clone(); + let client_for_outgoing = client.clone(); + let media_id_for_backlinks = id.clone(); + let media_id_for_outgoing = id.clone(); + let nav_handler = on_navigate_to_media; + rsx! { + BacklinksPanel { + media_id: media_id_for_backlinks, + client: client_for_backlinks, + on_navigate: { + let handler = nav_handler; + move |target_id: String| { + if let Some(ref h) = handler { + h.call(target_id); + } + } + }, + } + OutgoingLinksPanel { + media_id: media_id_for_outgoing, + client: client_for_outgoing, + on_navigate: { + let handler = nav_handler; + move |target_id: String| { + if let Some(ref h) = handler { + h.call(target_id); + } + } + }, + } + } + } + } + + // Image viewer overlay + if *show_image_viewer.read() { + ImageViewer { + src: stream_url_for_viewer.clone(), + alt: file_name_for_viewer.clone(), + on_close: move |_| show_image_viewer.set(false), + } + } + } } diff --git a/crates/pinakes-ui/src/components/duplicates.rs b/crates/pinakes-ui/src/components/duplicates.rs index fe65d71..ee61ac4 100644 --- a/crates/pinakes-ui/src/components/duplicates.rs +++ b/crates/pinakes-ui/src/components/duplicates.rs @@ -5,177 +5,178 @@ use crate::client::DuplicateGroupResponse; #[component] pub fn Duplicates( - groups: Vec, - server_url: String, - on_delete: EventHandler, - on_refresh: EventHandler<()>, + groups: Vec, + server_url: String, + on_delete: EventHandler, + on_refresh: EventHandler<()>, ) -> Element { - let mut expanded_group = use_signal(|| Option::::None); - let mut confirm_delete = use_signal(|| Option::::None); + let mut expanded_group = use_signal(|| Option::::None); + let mut confirm_delete = use_signal(|| Option::::None); - let total_groups = groups.len(); - let total_duplicates: usize = groups.iter().map(|g| g.items.len().saturating_sub(1)).sum(); + let total_groups = groups.len(); + let total_duplicates: usize = + groups.iter().map(|g| g.items.len().saturating_sub(1)).sum(); - rsx! { - div { class: "duplicates-view", - div { class: "duplicates-header", - h3 { "Duplicates" } - div { class: "duplicates-summary", - span { class: "text-muted", - "{total_groups} group(s), {total_duplicates} duplicate(s)" - } - button { - class: "btn btn-sm btn-secondary", - onclick: move |_| on_refresh.call(()), - "Refresh" - } - } - } + rsx! { + div { class: "duplicates-view", + div { class: "duplicates-header", + h3 { "Duplicates" } + div { class: "duplicates-summary", + span { class: "text-muted", + "{total_groups} group(s), {total_duplicates} duplicate(s)" + } + button { + class: "btn btn-sm btn-secondary", + onclick: move |_| on_refresh.call(()), + "Refresh" + } + } + } - if groups.is_empty() { - div { class: "empty-state", - p { class: "text-muted", "No duplicate files found." } - } - } + if groups.is_empty() { + div { class: "empty-state", + p { class: "text-muted", "No duplicate files found." } + } + } - for group in groups.iter() { - { - let hash = group.content_hash.clone(); - let is_expanded = expanded_group.read().as_ref() == Some(&hash); - let hash_for_toggle = hash.clone(); - let item_count = group.items.len(); - let first_name = group - .items + for group in groups.iter() { + { + let hash = group.content_hash.clone(); + let is_expanded = expanded_group.read().as_ref() == Some(&hash); + let hash_for_toggle = hash.clone(); + let item_count = group.items.len(); + let first_name = group + .items - // Group header + // Group header - // Expanded: show items + // Expanded: show items - // Thumbnail + // Thumbnail - // Info + // Info - // Actions + // Actions - .first() - .map(|i| i.file_name.clone()) - .unwrap_or_default(); - let total_size: u64 = group.items.iter().map(|i| i.file_size).sum(); - let short_hash = if hash.len() > 12 { - format!("{}...", &hash[..12]) - } else { - hash.clone() - }; - rsx! { - div { class: "duplicate-group", key: "{hash}", + .first() + .map(|i| i.file_name.clone()) + .unwrap_or_default(); + let total_size: u64 = group.items.iter().map(|i| i.file_size).sum(); + let short_hash = if hash.len() > 12 { + format!("{}...", &hash[..12]) + } else { + hash.clone() + }; + rsx! { + div { class: "duplicate-group", key: "{hash}", - button { - class: "duplicate-group-header", - onclick: move |_| { - let current = expanded_group.read().clone(); - if current.as_ref() == Some(&hash_for_toggle) { - expanded_group.set(None); - } else { - expanded_group.set(Some(hash_for_toggle.clone())); - } - }, - span { class: "expand-icon", - if is_expanded { - "\u{25bc}" - } else { - "\u{25b6}" - } - } - span { class: "group-name", "{first_name}" } - span { class: "group-badge", "{item_count} files" } - span { class: "group-size text-muted", "{format_size(total_size)}" } - span { class: "group-hash mono text-muted", "{short_hash}" } - } + button { + class: "duplicate-group-header", + onclick: move |_| { + let current = expanded_group.read().clone(); + if current.as_ref() == Some(&hash_for_toggle) { + expanded_group.set(None); + } else { + expanded_group.set(Some(hash_for_toggle.clone())); + } + }, + span { class: "expand-icon", + if is_expanded { + "\u{25bc}" + } else { + "\u{25b6}" + } + } + span { class: "group-name", "{first_name}" } + span { class: "group-badge", "{item_count} files" } + span { class: "group-size text-muted", "{format_size(total_size)}" } + span { class: "group-hash mono text-muted", "{short_hash}" } + } - if is_expanded { - div { class: "duplicate-items", - for (idx , item) in group.items.iter().enumerate() { - { - let item_id = item.id.clone(); - let is_first = idx == 0; - let is_confirming = confirm_delete.read().as_ref() == Some(&item_id); - let thumb_url = format!("{}/api/v1/media/{}/thumbnail", server_url, item.id); - let has_thumb = item.has_thumbnail; + if is_expanded { + div { class: "duplicate-items", + for (idx , item) in group.items.iter().enumerate() { + { + let item_id = item.id.clone(); + let is_first = idx == 0; + let is_confirming = confirm_delete.read().as_ref() == Some(&item_id); + let thumb_url = format!("{}/api/v1/media/{}/thumbnail", server_url, item.id); + let has_thumb = item.has_thumbnail; - rsx! { - div { - class: if is_first { "duplicate-item duplicate-item-keep" } else { "duplicate-item" }, - key: "{item_id}", + rsx! { + div { + class: if is_first { "duplicate-item duplicate-item-keep" } else { "duplicate-item" }, + key: "{item_id}", - div { class: "dup-thumb", - if has_thumb { - img { - src: "{thumb_url}", - alt: "{item.file_name}", - class: "dup-thumb-img", - } - } else { - div { class: "dup-thumb-placeholder", "\u{1f5bc}" } - } - } + div { class: "dup-thumb", + if has_thumb { + img { + src: "{thumb_url}", + alt: "{item.file_name}", + class: "dup-thumb-img", + } + } else { + div { class: "dup-thumb-placeholder", "\u{1f5bc}" } + } + } - div { class: "dup-info", - div { class: "dup-filename", "{item.file_name}" } - div { class: "dup-path mono text-muted", "{item.path}" } - div { class: "dup-meta", - span { "{format_size(item.file_size)}" } - span { class: "text-muted", " | " } - span { "{format_timestamp(&item.created_at)}" } - } - } + div { class: "dup-info", + div { class: "dup-filename", "{item.file_name}" } + div { class: "dup-path mono text-muted", "{item.path}" } + div { class: "dup-meta", + span { "{format_size(item.file_size)}" } + span { class: "text-muted", " | " } + span { "{format_timestamp(&item.created_at)}" } + } + } - div { class: "dup-actions", - if is_first { - span { class: "keep-badge", "Keep" } - } + div { class: "dup-actions", + if is_first { + span { class: "keep-badge", "Keep" } + } - if is_confirming { - button { - class: "btn btn-sm btn-danger", - onclick: { - let id = item_id.clone(); - move |_| { - confirm_delete.set(None); - on_delete.call(id.clone()); - } - }, - "Confirm" - } - button { - class: "btn btn-sm btn-ghost", - onclick: move |_| confirm_delete.set(None), - "Cancel" - } - } else if !is_first { - button { - class: "btn btn-sm btn-danger", - onclick: { - let id = item_id.clone(); - move |_| confirm_delete.set(Some(id.clone())) - }, - "Delete" - } - } - } - } - } - } - } - } - } - } - } - } - } - } - } + if is_confirming { + button { + class: "btn btn-sm btn-danger", + onclick: { + let id = item_id.clone(); + move |_| { + confirm_delete.set(None); + on_delete.call(id.clone()); + } + }, + "Confirm" + } + button { + class: "btn btn-sm btn-ghost", + onclick: move |_| confirm_delete.set(None), + "Cancel" + } + } else if !is_first { + button { + class: "btn btn-sm btn-danger", + onclick: { + let id = item_id.clone(); + move |_| confirm_delete.set(Some(id.clone())) + }, + "Delete" + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } } diff --git a/crates/pinakes-ui/src/components/graph_view.rs b/crates/pinakes-ui/src/components/graph_view.rs index 8d6a37f..ddb3766 100644 --- a/crates/pinakes-ui/src/components/graph_view.rs +++ b/crates/pinakes-ui/src/components/graph_view.rs @@ -1,669 +1,675 @@ //! Graph visualization component for markdown note connections. //! //! Renders a force-directed graph showing connections between notes. -use dioxus::prelude::*; use std::collections::HashMap; -use crate::client::{ApiClient, GraphEdgeResponse, GraphNodeResponse, GraphResponse}; +use dioxus::prelude::*; + +use crate::client::{ + ApiClient, + GraphEdgeResponse, + GraphNodeResponse, + GraphResponse, +}; /// Graph view component showing note connections. #[component] pub fn GraphView( - client: ApiClient, - center_id: Option, - on_navigate: EventHandler, + client: ApiClient, + center_id: Option, + on_navigate: EventHandler, ) -> Element { - let mut graph_data = use_signal(|| Option::::None); - let mut loading = use_signal(|| true); - let mut error = use_signal(|| Option::::None); - let mut depth = use_signal(|| 2u32); - let mut selected_node = use_signal(|| Option::::None); + let mut graph_data = use_signal(|| Option::::None); + let mut loading = use_signal(|| true); + let mut error = use_signal(|| Option::::None); + let mut depth = use_signal(|| 2u32); + let mut selected_node = use_signal(|| Option::::None); - // Fetch graph data - let center = center_id.clone(); - let d = *depth.read(); - let client_clone = client.clone(); - use_effect(move || { - let center = center.clone(); - let client = client_clone.clone(); - spawn(async move { - loading.set(true); - error.set(None); - match client.get_graph(center.as_deref(), Some(d)).await { - Ok(resp) => { - graph_data.set(Some(resp)); - } - Err(e) => { - error.set(Some(format!("Failed to load graph: {e}"))); - } - } - loading.set(false); - }); + // Fetch graph data + let center = center_id.clone(); + let d = *depth.read(); + let client_clone = client.clone(); + use_effect(move || { + let center = center.clone(); + let client = client_clone.clone(); + spawn(async move { + loading.set(true); + error.set(None); + match client.get_graph(center.as_deref(), Some(d)).await { + Ok(resp) => { + graph_data.set(Some(resp)); + }, + Err(e) => { + error.set(Some(format!("Failed to load graph: {e}"))); + }, + } + loading.set(false); }); + }); - let is_loading = *loading.read(); - let current_depth = *depth.read(); - let data = graph_data.read(); + let is_loading = *loading.read(); + let current_depth = *depth.read(); + let data = graph_data.read(); - rsx! { - div { class: "graph-view", - // Toolbar - div { class: "graph-toolbar", - span { class: "graph-title", "Note Graph" } - div { class: "graph-controls", - label { "Depth: " } - select { - value: "{current_depth}", - onchange: move |evt| { - if let Ok(d) = evt.value().parse::() { - depth.set(d); - } - }, - option { value: "1", "1" } - option { value: "2", "2" } - option { value: "3", "3" } - option { value: "4", "4" } - option { value: "5", "5" } - } - } - if let Some(ref data) = *data { - div { class: "graph-stats", "{data.node_count} nodes, {data.edge_count} edges" } - } - } + rsx! { + div { class: "graph-view", + // Toolbar + div { class: "graph-toolbar", + span { class: "graph-title", "Note Graph" } + div { class: "graph-controls", + label { "Depth: " } + select { + value: "{current_depth}", + onchange: move |evt| { + if let Ok(d) = evt.value().parse::() { + depth.set(d); + } + }, + option { value: "1", "1" } + option { value: "2", "2" } + option { value: "3", "3" } + option { value: "4", "4" } + option { value: "5", "5" } + } + } + if let Some(ref data) = *data { + div { class: "graph-stats", "{data.node_count} nodes, {data.edge_count} edges" } + } + } - // Graph container - div { class: "graph-container", - if is_loading { - div { class: "graph-loading", - div { class: "spinner" } - "Loading graph..." - } - } + // Graph container + div { class: "graph-container", + if is_loading { + div { class: "graph-loading", + div { class: "spinner" } + "Loading graph..." + } + } - if let Some(ref err) = *error.read() { - div { class: "graph-error", "{err}" } - } + if let Some(ref err) = *error.read() { + div { class: "graph-error", "{err}" } + } - if !is_loading && error.read().is_none() { - if let Some(ref graph) = *data { - if graph.nodes.is_empty() { - div { class: "graph-empty", - "No linked notes found. Start creating links between your notes!" - } - } else { - ForceDirectedGraph { - nodes: graph.nodes.clone(), - edges: graph.edges.clone(), - selected_node: selected_node.clone(), - on_node_click: move |id: String| { - selected_node.set(Some(id.clone())); - }, - on_node_double_click: move |id: String| { - on_navigate.call(id); - }, - } - } - } - } - } + if !is_loading && error.read().is_none() { + if let Some(ref graph) = *data { + if graph.nodes.is_empty() { + div { class: "graph-empty", + "No linked notes found. Start creating links between your notes!" + } + } else { + ForceDirectedGraph { + nodes: graph.nodes.clone(), + edges: graph.edges.clone(), + selected_node: selected_node.clone(), + on_node_click: move |id: String| { + selected_node.set(Some(id.clone())); + }, + on_node_double_click: move |id: String| { + on_navigate.call(id); + }, + } + } + } + } + } - // Node details panel - if let Some(ref node_id) = *selected_node.read() { - if let Some(ref graph) = *data { - if let Some(node) = graph.nodes.iter().find(|n| &n.id == node_id) { - NodeDetailsPanel { - node: node.clone(), - on_close: move |_| selected_node.set(None), - on_navigate: move |id| { - on_navigate.call(id); - }, - } - } - } - } - } - } + // Node details panel + if let Some(ref node_id) = *selected_node.read() { + if let Some(ref graph) = *data { + if let Some(node) = graph.nodes.iter().find(|n| &n.id == node_id) { + NodeDetailsPanel { + node: node.clone(), + on_close: move |_| selected_node.set(None), + on_navigate: move |id| { + on_navigate.call(id); + }, + } + } + } + } + } + } } /// Node with physics simulation state #[derive(Clone, Debug)] struct PhysicsNode { - id: String, - label: String, - title: Option, - link_count: usize, - backlink_count: usize, - x: f64, - y: f64, - vx: f64, - vy: f64, + id: String, + label: String, + title: Option, + link_count: usize, + backlink_count: usize, + x: f64, + y: f64, + vx: f64, + vy: f64, } /// Force-directed graph with physics simulation #[component] fn ForceDirectedGraph( - nodes: Vec, - edges: Vec, - selected_node: Signal>, - on_node_click: EventHandler, - on_node_double_click: EventHandler, + nodes: Vec, + edges: Vec, + selected_node: Signal>, + on_node_click: EventHandler, + on_node_double_click: EventHandler, ) -> Element { - // Physics parameters (adjustable via controls) - let mut repulsion_strength = use_signal(|| 1000.0f64); - let mut link_strength = use_signal(|| 0.5f64); - let mut link_distance = use_signal(|| 100.0f64); - let mut center_strength = use_signal(|| 0.1f64); - let mut damping = use_signal(|| 0.8f64); - let mut show_controls = use_signal(|| false); - let mut simulation_active = use_signal(|| true); + // Physics parameters (adjustable via controls) + let mut repulsion_strength = use_signal(|| 1000.0f64); + let mut link_strength = use_signal(|| 0.5f64); + let mut link_distance = use_signal(|| 100.0f64); + let mut center_strength = use_signal(|| 0.1f64); + let mut damping = use_signal(|| 0.8f64); + let mut show_controls = use_signal(|| false); + let mut simulation_active = use_signal(|| true); - // View state - let mut zoom = use_signal(|| 1.0f64); - let mut pan_x = use_signal(|| 0.0f64); - let mut pan_y = use_signal(|| 0.0f64); - let mut is_dragging_canvas = use_signal(|| false); - let mut drag_start_x = use_signal(|| 0.0f64); - let mut drag_start_y = use_signal(|| 0.0f64); - let mut dragged_node = use_signal(|| Option::::None); + // View state + let mut zoom = use_signal(|| 1.0f64); + let mut pan_x = use_signal(|| 0.0f64); + let mut pan_y = use_signal(|| 0.0f64); + let mut is_dragging_canvas = use_signal(|| false); + let mut drag_start_x = use_signal(|| 0.0f64); + let mut drag_start_y = use_signal(|| 0.0f64); + let mut dragged_node = use_signal(|| Option::::None); - // Initialize physics nodes with random positions - let mut physics_nodes = use_signal(|| { - nodes - .iter() - .map(|n| { - let angle = rand::random::() * 2.0 * std::f64::consts::PI; - let radius = 100.0 + rand::random::() * 200.0; - PhysicsNode { - id: n.id.clone(), - label: n.label.clone(), - title: n.title.clone(), - link_count: n.link_count as usize, - backlink_count: n.backlink_count as usize, - x: radius * angle.cos(), - y: radius * angle.sin(), - vx: 0.0, - vy: 0.0, - } - }) - .collect::>() - }); - - // Animation loop - let edges_for_sim = edges.clone(); - use_future(move || { - let edges_for_sim = edges_for_sim.clone(); - async move { - loop { - // Check simulation state - let is_active = *simulation_active.peek(); - let is_dragging = dragged_node.peek().is_some(); - - if is_active && !is_dragging { - let mut nodes = physics_nodes.write(); - let node_count = nodes.len(); - - if node_count > 0 { - // Read physics parameters each frame - let rep_strength = *repulsion_strength.peek(); - let link_strength_val = *link_strength.peek(); - let link_distance = *link_distance.peek(); - let center_strength_val = *center_strength.peek(); - let damping_val = *damping.peek(); - - // Apply forces - for i in 0..node_count { - let mut fx = 0.0; - let mut fy = 0.0; - - // Repulsion between all nodes - for j in 0..node_count { - if i != j { - let dx = nodes[i].x - nodes[j].x; - let dy = nodes[i].y - nodes[j].y; - let dist_sq = (dx * dx + dy * dy).max(1.0); - let dist = dist_sq.sqrt(); - let force = rep_strength / dist_sq; - fx += (dx / dist) * force; - fy += (dy / dist) * force; - } - } - - // Center force (pull towards origin) - fx -= nodes[i].x * center_strength_val; - fy -= nodes[i].y * center_strength_val; - - // Store force temporarily - nodes[i].vx = fx; - nodes[i].vy = fy; - } - - // Attraction along edges - for edge in &edges_for_sim { - if let (Some(i), Some(j)) = ( - nodes.iter().position(|n| n.id == edge.source), - nodes.iter().position(|n| n.id == edge.target), - ) { - let dx = nodes[j].x - nodes[i].x; - let dy = nodes[j].y - nodes[i].y; - let dist = (dx * dx + dy * dy).sqrt().max(1.0); - let force = (dist - link_distance) * link_strength_val; - - let fx = (dx / dist) * force; - let fy = (dy / dist) * force; - - nodes[i].vx += fx; - nodes[i].vy += fy; - nodes[j].vx -= fx; - nodes[j].vy -= fy; - } - } - - // Update positions with velocity and damping - let mut total_kinetic_energy = 0.0; - for node in nodes.iter_mut() { - node.x += node.vx * 0.01; - node.y += node.vy * 0.01; - node.vx *= damping_val; - node.vy *= damping_val; - - // Calculate kinetic energy (1/2 * m * v^2, assume m=1) - let speed_sq = node.vx * node.vx + node.vy * node.vy; - total_kinetic_energy += speed_sq; - } - - // If total kinetic energy is below threshold, pause simulation - let avg_kinetic_energy = total_kinetic_energy / node_count as f64; - if avg_kinetic_energy < 0.01 { - simulation_active.set(false); - } - } - } - - // Sleep for ~16ms (60 FPS) - tokio::time::sleep(tokio::time::Duration::from_millis(16)).await; - } + // Initialize physics nodes with random positions + let mut physics_nodes = use_signal(|| { + nodes + .iter() + .map(|n| { + let angle = rand::random::() * 2.0 * std::f64::consts::PI; + let radius = 100.0 + rand::random::() * 200.0; + PhysicsNode { + id: n.id.clone(), + label: n.label.clone(), + title: n.title.clone(), + link_count: n.link_count as usize, + backlink_count: n.backlink_count as usize, + x: radius * angle.cos(), + y: radius * angle.sin(), + vx: 0.0, + vy: 0.0, } - }); + }) + .collect::>() + }); - let selected = selected_node.read(); - let current_zoom = *zoom.read(); - let current_pan_x = *pan_x.read(); - let current_pan_y = *pan_y.read(); + // Animation loop + let edges_for_sim = edges.clone(); + use_future(move || { + let edges_for_sim = edges_for_sim.clone(); + async move { + loop { + // Check simulation state + let is_active = *simulation_active.peek(); + let is_dragging = dragged_node.peek().is_some(); - // Create id to position map - let nodes_read = physics_nodes.read(); - let id_to_pos: HashMap<&str, (f64, f64)> = nodes_read - .iter() - .map(|n| (n.id.as_str(), (n.x, n.y))) - .collect(); + if is_active && !is_dragging { + let mut nodes = physics_nodes.write(); + let node_count = nodes.len(); - rsx! { - div { class: "graph-svg-container", - // Zoom and physics controls - div { class: "graph-zoom-controls", - button { - class: "zoom-btn", - title: "Zoom In", - onclick: move |_| { - let new_zoom = (*zoom.read() * 1.2).min(5.0); - zoom.set(new_zoom); - }, - "+" - } - button { - class: "zoom-btn", - title: "Zoom Out", - onclick: move |_| { - let new_zoom = (*zoom.read() / 1.2).max(0.1); - zoom.set(new_zoom); - }, - "−" - } - button { - class: "zoom-btn", - title: "Reset View", - onclick: move |_| { - zoom.set(1.0); - pan_x.set(0.0); - pan_y.set(0.0); - }, - "⊙" - } - button { - class: "zoom-btn", - title: "Physics Settings", - onclick: move |_| { - let current = *show_controls.read(); - show_controls.set(!current); - }, - "⚙" + if node_count > 0 { + // Read physics parameters each frame + let rep_strength = *repulsion_strength.peek(); + let link_strength_val = *link_strength.peek(); + let link_distance = *link_distance.peek(); + let center_strength_val = *center_strength.peek(); + let damping_val = *damping.peek(); + + // Apply forces + for i in 0..node_count { + let mut fx = 0.0; + let mut fy = 0.0; + + // Repulsion between all nodes + for j in 0..node_count { + if i != j { + let dx = nodes[i].x - nodes[j].x; + let dy = nodes[i].y - nodes[j].y; + let dist_sq = (dx * dx + dy * dy).max(1.0); + let dist = dist_sq.sqrt(); + let force = rep_strength / dist_sq; + fx += (dx / dist) * force; + fy += (dy / dist) * force; } + } + + // Center force (pull towards origin) + fx -= nodes[i].x * center_strength_val; + fy -= nodes[i].y * center_strength_val; + + // Store force temporarily + nodes[i].vx = fx; + nodes[i].vy = fy; } - // Physics control panel - if *show_controls.read() { - div { class: "physics-controls-panel", - h4 { "Physics Settings" } + // Attraction along edges + for edge in &edges_for_sim { + if let (Some(i), Some(j)) = ( + nodes.iter().position(|n| n.id == edge.source), + nodes.iter().position(|n| n.id == edge.target), + ) { + let dx = nodes[j].x - nodes[i].x; + let dy = nodes[j].y - nodes[i].y; + let dist = (dx * dx + dy * dy).sqrt().max(1.0); + let force = (dist - link_distance) * link_strength_val; - div { class: "control-group", - label { "Repulsion Strength" } - input { - r#type: "range", - min: "100", - max: "5000", - step: "100", - value: "{*repulsion_strength.read()}", - oninput: move |evt| { - if let Ok(v) = evt.value().parse::() { - repulsion_strength.set(v); - simulation_active.set(true); - } - }, - } - span { class: "control-value", "{*repulsion_strength.read():.0}" } - } + let fx = (dx / dist) * force; + let fy = (dy / dist) * force; - div { class: "control-group", - label { "Link Strength" } - input { - r#type: "range", - min: "0.1", - max: "2.0", - step: "0.1", - value: "{*link_strength.read()}", - oninput: move |evt| { - if let Ok(v) = evt.value().parse::() { - link_strength.set(v); - simulation_active.set(true); - } - }, - } - span { class: "control-value", "{*link_strength.read():.1}" } - } - - div { class: "control-group", - label { "Link Distance" } - input { - r#type: "range", - min: "50", - max: "300", - step: "10", - value: "{*link_distance.read()}", - oninput: move |evt| { - if let Ok(v) = evt.value().parse::() { - link_distance.set(v); - simulation_active.set(true); - } - }, - } - span { class: "control-value", "{*link_distance.read():.0}" } - } - - div { class: "control-group", - label { "Center Gravity" } - input { - r#type: "range", - min: "0.01", - max: "0.5", - step: "0.01", - value: "{*center_strength.read()}", - oninput: move |evt| { - if let Ok(v) = evt.value().parse::() { - center_strength.set(v); - simulation_active.set(true); - } - }, - } - span { class: "control-value", "{*center_strength.read():.2}" } - } - - div { class: "control-group", - label { "Damping" } - input { - r#type: "range", - min: "0.5", - max: "0.95", - step: "0.05", - value: "{*damping.read()}", - oninput: move |evt| { - if let Ok(v) = evt.value().parse::() { - damping.set(v); - simulation_active.set(true); - } - }, - } - span { class: "control-value", "{*damping.read():.2}" } - } - - div { class: "control-group", - label { "Simulation Status" } - span { style: if *simulation_active.read() { "color: #4ade80;" } else { "color: #94a3b8;" }, - if *simulation_active.read() { - "Running" - } else { - "Paused (settled)" - } - } - } - - button { - class: "btn btn-sm btn-secondary", - onclick: move |_| { - simulation_active.set(true); - }, - disabled: *simulation_active.read(), - "Restart Simulation" - } - - button { - class: "btn btn-sm btn-secondary", - onclick: move |_| { - repulsion_strength.set(1000.0); - link_strength.set(0.5); - link_distance.set(100.0); - center_strength.set(0.1); - damping.set(0.8); - simulation_active.set(true); - }, - "Reset to Defaults" - } - } + nodes[i].vx += fx; + nodes[i].vy += fy; + nodes[j].vx -= fx; + nodes[j].vy -= fy; + } } - // SVG canvas - fills available space - svg { - class: "graph-svg", - style: "width: 100%; height: 100%;", - view_box: "-1000 -1000 2000 2000", - onmousedown: move |evt| { - // Check if clicking on background (not a node) - is_dragging_canvas.set(true); - drag_start_x.set(evt.page_coordinates().x); - drag_start_y.set(evt.page_coordinates().y); - }, - onmousemove: move |evt| { - if *is_dragging_canvas.read() { - let dx = (evt.page_coordinates().x - *drag_start_x.read()) / current_zoom; - let dy = (evt.page_coordinates().y - *drag_start_y.read()) / current_zoom; - pan_x.set(current_pan_x + dx); - pan_y.set(current_pan_y + dy); - drag_start_x.set(evt.page_coordinates().x); - drag_start_y.set(evt.page_coordinates().y); - } + // Update positions with velocity and damping + let mut total_kinetic_energy = 0.0; + for node in nodes.iter_mut() { + node.x += node.vx * 0.01; + node.y += node.vy * 0.01; + node.vx *= damping_val; + node.vy *= damping_val; - // Handle node dragging - if let Some(ref node_id) = *dragged_node.read() { - let mut nodes = physics_nodes.write(); - if let Some(node) = nodes.iter_mut().find(|n| &n.id == node_id) { - let dx = (evt.page_coordinates().x - *drag_start_x.read()) / current_zoom - * 2.0; - // Reset velocity when dragging - let dy = (evt.page_coordinates().y - *drag_start_y.read()) / current_zoom - * 2.0; - node.x += dx; - node.y += dy; - node.vx = 0.0; - node.vy = 0.0; - drag_start_x.set(evt.page_coordinates().x); - drag_start_y.set(evt.page_coordinates().y); - } - } - }, - onmouseup: move |_| { - is_dragging_canvas.set(false); - dragged_node.set(None); - }, - onmouseleave: move |_| { - is_dragging_canvas.set(false); - dragged_node.set(None); - }, - onwheel: move |evt| { - let delta = if evt.delta().strip_units().y < 0.0 { 1.1 } else { 0.9 }; - let new_zoom = (*zoom.read() * delta).max(0.1).min(5.0); - zoom.set(new_zoom); - }, - - // Transform group for zoom and pan - g { transform: "translate({current_pan_x}, {current_pan_y}) scale({current_zoom})", - - // Draw edges first - g { class: "graph-edges", - for edge in &edges { - if let (Some(&(x1, y1)), Some(&(x2, y2))) = ( - id_to_pos.get(edge.source.as_str()), - id_to_pos.get(edge.target.as_str()), - ) - { - line { - class: "graph-edge edge-type-{edge.link_type}", - x1: "{x1}", - y1: "{y1}", - x2: "{x2}", - y2: "{y2}", - stroke: "#666", - stroke_width: "{1.5 / current_zoom}", - stroke_opacity: "0.6", - marker_end: "url(#arrowhead)", - } - } - } - } - - // Arrow marker definition - defs { - marker { - id: "arrowhead", - marker_width: "10", - marker_height: "7", - ref_x: "9", - ref_y: "3.5", - orient: "auto", - polygon { - points: "0 0, 10 3.5, 0 7", - fill: "#666", - fill_opacity: "0.6", - } - } - } - - // Draw nodes - g { class: "graph-nodes", - for node in nodes_read.iter() { - { - let node_id = node.id.clone(); - let node_id2 = node.id.clone(); - let node_id3 = node.id.clone(); - let display_text = node.title.as_ref().unwrap_or(&node.label).clone(); - let is_selected = selected.as_ref() == Some(&node.id); - - // Node size based on connections - let total_links = node.link_count + node.backlink_count; - let node_radius = 8.0 + (total_links as f64 * 1.5).min(20.0); - let scaled_radius = node_radius / current_zoom; - - rsx! { - g { - class: if is_selected { "graph-node selected" } else { "graph-node" }, - style: "cursor: pointer;", - onclick: move |evt| { - evt.stop_propagation(); - on_node_click.call(node_id.clone()); - }, - ondoubleclick: move |evt| { - evt.stop_propagation(); - on_node_double_click.call(node_id2.clone()); - }, - onmousedown: move |evt| { - evt.stop_propagation(); - dragged_node.set(Some(node_id3.clone())); - drag_start_x.set(evt.page_coordinates().x); - drag_start_y.set(evt.page_coordinates().y); - }, - - - circle { - cx: "{node.x}", - cy: "{node.y}", - r: "{scaled_radius}", - fill: if is_selected { "#2196f3" } else { "#4caf50" }, - stroke: if is_selected { "#1565c0" } else { "#2e7d32" }, - stroke_width: "{2.0 / current_zoom}", - } - text { - x: "{node.x}", - y: "{node.y + scaled_radius + 15.0 / current_zoom}", - text_anchor: "middle", - font_size: "{12.0 / current_zoom}", - fill: "#333", - pointer_events: "none", - "{display_text}" - } - } - } - } - } - } - } + // Calculate kinetic energy (1/2 * m * v^2, assume m=1) + let speed_sq = node.vx * node.vx + node.vy * node.vy; + total_kinetic_energy += speed_sq; } + + // If total kinetic energy is below threshold, pause simulation + let avg_kinetic_energy = total_kinetic_energy / node_count as f64; + if avg_kinetic_energy < 0.01 { + simulation_active.set(false); + } + } } + + // Sleep for ~16ms (60 FPS) + tokio::time::sleep(tokio::time::Duration::from_millis(16)).await; + } } + }); + + let selected = selected_node.read(); + let current_zoom = *zoom.read(); + let current_pan_x = *pan_x.read(); + let current_pan_y = *pan_y.read(); + + // Create id to position map + let nodes_read = physics_nodes.read(); + let id_to_pos: HashMap<&str, (f64, f64)> = nodes_read + .iter() + .map(|n| (n.id.as_str(), (n.x, n.y))) + .collect(); + + rsx! { + div { class: "graph-svg-container", + // Zoom and physics controls + div { class: "graph-zoom-controls", + button { + class: "zoom-btn", + title: "Zoom In", + onclick: move |_| { + let new_zoom = (*zoom.read() * 1.2).min(5.0); + zoom.set(new_zoom); + }, + "+" + } + button { + class: "zoom-btn", + title: "Zoom Out", + onclick: move |_| { + let new_zoom = (*zoom.read() / 1.2).max(0.1); + zoom.set(new_zoom); + }, + "−" + } + button { + class: "zoom-btn", + title: "Reset View", + onclick: move |_| { + zoom.set(1.0); + pan_x.set(0.0); + pan_y.set(0.0); + }, + "⊙" + } + button { + class: "zoom-btn", + title: "Physics Settings", + onclick: move |_| { + let current = *show_controls.read(); + show_controls.set(!current); + }, + "⚙" + } + } + + // Physics control panel + if *show_controls.read() { + div { class: "physics-controls-panel", + h4 { "Physics Settings" } + + div { class: "control-group", + label { "Repulsion Strength" } + input { + r#type: "range", + min: "100", + max: "5000", + step: "100", + value: "{*repulsion_strength.read()}", + oninput: move |evt| { + if let Ok(v) = evt.value().parse::() { + repulsion_strength.set(v); + simulation_active.set(true); + } + }, + } + span { class: "control-value", "{*repulsion_strength.read():.0}" } + } + + div { class: "control-group", + label { "Link Strength" } + input { + r#type: "range", + min: "0.1", + max: "2.0", + step: "0.1", + value: "{*link_strength.read()}", + oninput: move |evt| { + if let Ok(v) = evt.value().parse::() { + link_strength.set(v); + simulation_active.set(true); + } + }, + } + span { class: "control-value", "{*link_strength.read():.1}" } + } + + div { class: "control-group", + label { "Link Distance" } + input { + r#type: "range", + min: "50", + max: "300", + step: "10", + value: "{*link_distance.read()}", + oninput: move |evt| { + if let Ok(v) = evt.value().parse::() { + link_distance.set(v); + simulation_active.set(true); + } + }, + } + span { class: "control-value", "{*link_distance.read():.0}" } + } + + div { class: "control-group", + label { "Center Gravity" } + input { + r#type: "range", + min: "0.01", + max: "0.5", + step: "0.01", + value: "{*center_strength.read()}", + oninput: move |evt| { + if let Ok(v) = evt.value().parse::() { + center_strength.set(v); + simulation_active.set(true); + } + }, + } + span { class: "control-value", "{*center_strength.read():.2}" } + } + + div { class: "control-group", + label { "Damping" } + input { + r#type: "range", + min: "0.5", + max: "0.95", + step: "0.05", + value: "{*damping.read()}", + oninput: move |evt| { + if let Ok(v) = evt.value().parse::() { + damping.set(v); + simulation_active.set(true); + } + }, + } + span { class: "control-value", "{*damping.read():.2}" } + } + + div { class: "control-group", + label { "Simulation Status" } + span { style: if *simulation_active.read() { "color: #4ade80;" } else { "color: #94a3b8;" }, + if *simulation_active.read() { + "Running" + } else { + "Paused (settled)" + } + } + } + + button { + class: "btn btn-sm btn-secondary", + onclick: move |_| { + simulation_active.set(true); + }, + disabled: *simulation_active.read(), + "Restart Simulation" + } + + button { + class: "btn btn-sm btn-secondary", + onclick: move |_| { + repulsion_strength.set(1000.0); + link_strength.set(0.5); + link_distance.set(100.0); + center_strength.set(0.1); + damping.set(0.8); + simulation_active.set(true); + }, + "Reset to Defaults" + } + } + } + + // SVG canvas - fills available space + svg { + class: "graph-svg", + style: "width: 100%; height: 100%;", + view_box: "-1000 -1000 2000 2000", + onmousedown: move |evt| { + // Check if clicking on background (not a node) + is_dragging_canvas.set(true); + drag_start_x.set(evt.page_coordinates().x); + drag_start_y.set(evt.page_coordinates().y); + }, + onmousemove: move |evt| { + if *is_dragging_canvas.read() { + let dx = (evt.page_coordinates().x - *drag_start_x.read()) / current_zoom; + let dy = (evt.page_coordinates().y - *drag_start_y.read()) / current_zoom; + pan_x.set(current_pan_x + dx); + pan_y.set(current_pan_y + dy); + drag_start_x.set(evt.page_coordinates().x); + drag_start_y.set(evt.page_coordinates().y); + } + + // Handle node dragging + if let Some(ref node_id) = *dragged_node.read() { + let mut nodes = physics_nodes.write(); + if let Some(node) = nodes.iter_mut().find(|n| &n.id == node_id) { + let dx = (evt.page_coordinates().x - *drag_start_x.read()) / current_zoom + * 2.0; + // Reset velocity when dragging + let dy = (evt.page_coordinates().y - *drag_start_y.read()) / current_zoom + * 2.0; + node.x += dx; + node.y += dy; + node.vx = 0.0; + node.vy = 0.0; + drag_start_x.set(evt.page_coordinates().x); + drag_start_y.set(evt.page_coordinates().y); + } + } + }, + onmouseup: move |_| { + is_dragging_canvas.set(false); + dragged_node.set(None); + }, + onmouseleave: move |_| { + is_dragging_canvas.set(false); + dragged_node.set(None); + }, + onwheel: move |evt| { + let delta = if evt.delta().strip_units().y < 0.0 { 1.1 } else { 0.9 }; + let new_zoom = (*zoom.read() * delta).max(0.1).min(5.0); + zoom.set(new_zoom); + }, + + // Transform group for zoom and pan + g { transform: "translate({current_pan_x}, {current_pan_y}) scale({current_zoom})", + + // Draw edges first + g { class: "graph-edges", + for edge in &edges { + if let (Some(&(x1, y1)), Some(&(x2, y2))) = ( + id_to_pos.get(edge.source.as_str()), + id_to_pos.get(edge.target.as_str()), + ) + { + line { + class: "graph-edge edge-type-{edge.link_type}", + x1: "{x1}", + y1: "{y1}", + x2: "{x2}", + y2: "{y2}", + stroke: "#666", + stroke_width: "{1.5 / current_zoom}", + stroke_opacity: "0.6", + marker_end: "url(#arrowhead)", + } + } + } + } + + // Arrow marker definition + defs { + marker { + id: "arrowhead", + marker_width: "10", + marker_height: "7", + ref_x: "9", + ref_y: "3.5", + orient: "auto", + polygon { + points: "0 0, 10 3.5, 0 7", + fill: "#666", + fill_opacity: "0.6", + } + } + } + + // Draw nodes + g { class: "graph-nodes", + for node in nodes_read.iter() { + { + let node_id = node.id.clone(); + let node_id2 = node.id.clone(); + let node_id3 = node.id.clone(); + let display_text = node.title.as_ref().unwrap_or(&node.label).clone(); + let is_selected = selected.as_ref() == Some(&node.id); + + // Node size based on connections + let total_links = node.link_count + node.backlink_count; + let node_radius = 8.0 + (total_links as f64 * 1.5).min(20.0); + let scaled_radius = node_radius / current_zoom; + + rsx! { + g { + class: if is_selected { "graph-node selected" } else { "graph-node" }, + style: "cursor: pointer;", + onclick: move |evt| { + evt.stop_propagation(); + on_node_click.call(node_id.clone()); + }, + ondoubleclick: move |evt| { + evt.stop_propagation(); + on_node_double_click.call(node_id2.clone()); + }, + onmousedown: move |evt| { + evt.stop_propagation(); + dragged_node.set(Some(node_id3.clone())); + drag_start_x.set(evt.page_coordinates().x); + drag_start_y.set(evt.page_coordinates().y); + }, + + + circle { + cx: "{node.x}", + cy: "{node.y}", + r: "{scaled_radius}", + fill: if is_selected { "#2196f3" } else { "#4caf50" }, + stroke: if is_selected { "#1565c0" } else { "#2e7d32" }, + stroke_width: "{2.0 / current_zoom}", + } + text { + x: "{node.x}", + y: "{node.y + scaled_radius + 15.0 / current_zoom}", + text_anchor: "middle", + font_size: "{12.0 / current_zoom}", + fill: "#333", + pointer_events: "none", + "{display_text}" + } + } + } + } + } + } + } + } + } + } } /// Panel showing details about the selected node. #[component] fn NodeDetailsPanel( - node: GraphNodeResponse, - on_close: EventHandler<()>, - on_navigate: EventHandler, + node: GraphNodeResponse, + on_close: EventHandler<()>, + on_navigate: EventHandler, ) -> Element { - let node_id = node.id.clone(); + let node_id = node.id.clone(); - rsx! { - div { class: "node-details-panel", - div { class: "node-details-header", - h3 { "{node.label}" } - button { class: "close-btn", onclick: move |_| on_close.call(()), "×" } - } - div { class: "node-details-content", - if let Some(ref title) = node.title { - p { class: "node-title", "{title}" } - } - div { class: "node-stats", - span { class: "stat", - "Outgoing: " - strong { "{node.link_count}" } - } - span { class: "stat", - "Incoming: " - strong { "{node.backlink_count}" } - } - } - button { - class: "btn btn-primary", - onclick: move |_| on_navigate.call(node_id.clone()), - "Open Note" - } - } - } - } + rsx! { + div { class: "node-details-panel", + div { class: "node-details-header", + h3 { "{node.label}" } + button { class: "close-btn", onclick: move |_| on_close.call(()), "×" } + } + div { class: "node-details-content", + if let Some(ref title) = node.title { + p { class: "node-title", "{title}" } + } + div { class: "node-stats", + span { class: "stat", + "Outgoing: " + strong { "{node.link_count}" } + } + span { class: "stat", + "Incoming: " + strong { "{node.backlink_count}" } + } + } + button { + class: "btn btn-primary", + onclick: move |_| on_navigate.call(node_id.clone()), + "Open Note" + } + } + } + } } diff --git a/crates/pinakes-ui/src/components/image_viewer.rs b/crates/pinakes-ui/src/components/image_viewer.rs index d177593..ca62375 100644 --- a/crates/pinakes-ui/src/components/image_viewer.rs +++ b/crates/pinakes-ui/src/components/image_viewer.rs @@ -2,243 +2,251 @@ use dioxus::prelude::*; #[derive(Debug, Clone, Copy, PartialEq)] enum FitMode { - FitScreen, - FitWidth, - Actual, + FitScreen, + FitWidth, + Actual, } impl FitMode { - fn next(self) -> Self { - match self { - Self::FitScreen => Self::FitWidth, - Self::FitWidth => Self::Actual, - Self::Actual => Self::FitScreen, - } + fn next(self) -> Self { + match self { + Self::FitScreen => Self::FitWidth, + Self::FitWidth => Self::Actual, + Self::Actual => Self::FitScreen, } + } - fn label(self) -> &'static str { - match self { - Self::FitScreen => "Fit", - Self::FitWidth => "Width", - Self::Actual => "100%", - } + fn label(self) -> &'static str { + match self { + Self::FitScreen => "Fit", + Self::FitWidth => "Width", + Self::Actual => "100%", } + } } #[component] pub fn ImageViewer( - src: String, - alt: String, - on_close: EventHandler<()>, - #[props(default)] on_prev: Option>, - #[props(default)] on_next: Option>, + src: String, + alt: String, + on_close: EventHandler<()>, + #[props(default)] on_prev: Option>, + #[props(default)] on_next: Option>, ) -> Element { - let mut zoom = use_signal(|| 1.0f64); - let mut offset_x = use_signal(|| 0.0f64); - let mut offset_y = use_signal(|| 0.0f64); - let mut dragging = use_signal(|| false); - let mut drag_start_x = use_signal(|| 0.0f64); - let mut drag_start_y = use_signal(|| 0.0f64); - let mut fit_mode = use_signal(|| FitMode::FitScreen); + let mut zoom = use_signal(|| 1.0f64); + let mut offset_x = use_signal(|| 0.0f64); + let mut offset_y = use_signal(|| 0.0f64); + let mut dragging = use_signal(|| false); + let mut drag_start_x = use_signal(|| 0.0f64); + let mut drag_start_y = use_signal(|| 0.0f64); + let mut fit_mode = use_signal(|| FitMode::FitScreen); - let z = *zoom.read(); - let ox = *offset_x.read(); - let oy = *offset_y.read(); - let is_dragging = *dragging.read(); - let zoom_pct = (z * 100.0) as u32; - let current_fit = *fit_mode.read(); + let z = *zoom.read(); + let ox = *offset_x.read(); + let oy = *offset_y.read(); + let is_dragging = *dragging.read(); + let zoom_pct = (z * 100.0) as u32; + let current_fit = *fit_mode.read(); - let transform = format!("translate({ox}px, {oy}px) scale({z})"); - let cursor = if z > 1.0 { - if is_dragging { "grabbing" } else { "grab" } - } else { - "default" - }; + let transform = format!("translate({ox}px, {oy}px) scale({z})"); + let cursor = if z > 1.0 { + if is_dragging { "grabbing" } else { "grab" } + } else { + "default" + }; - // Compute image style based on fit mode - let img_style = match current_fit { - FitMode::FitScreen => format!( - "transform: {transform}; cursor: {cursor}; max-width: 100%; max-height: 100%; object-fit: contain;" - ), - FitMode::FitWidth => { - format!("transform: {transform}; cursor: {cursor}; width: 100%; object-fit: contain;") - } - FitMode::Actual => format!("transform: {transform}; cursor: {cursor};"), - }; + // Compute image style based on fit mode + let img_style = match current_fit { + FitMode::FitScreen => { + format!( + "transform: {transform}; cursor: {cursor}; max-width: 100%; \ + max-height: 100%; object-fit: contain;" + ) + }, + FitMode::FitWidth => { + format!( + "transform: {transform}; cursor: {cursor}; width: 100%; object-fit: \ + contain;" + ) + }, + FitMode::Actual => format!("transform: {transform}; cursor: {cursor};"), + }; - let on_wheel = move |e: WheelEvent| { - e.prevent_default(); - let delta = e.delta().strip_units(); - let factor = if delta.y < 0.0 { 1.1 } else { 1.0 / 1.1 }; - let new_zoom = (*zoom.read() * factor).clamp(0.1, 20.0); - zoom.set(new_zoom); - }; + let on_wheel = move |e: WheelEvent| { + e.prevent_default(); + let delta = e.delta().strip_units(); + let factor = if delta.y < 0.0 { 1.1 } else { 1.0 / 1.1 }; + let new_zoom = (*zoom.read() * factor).clamp(0.1, 20.0); + zoom.set(new_zoom); + }; - let on_mouse_down = move |e: MouseEvent| { - if *zoom.read() > 1.0 { - dragging.set(true); - let coords = e.client_coordinates(); - drag_start_x.set(coords.x - *offset_x.read()); - drag_start_y.set(coords.y - *offset_y.read()); - } - }; - - let on_mouse_move = move |e: MouseEvent| { - if *dragging.read() { - let coords = e.client_coordinates(); - offset_x.set(coords.x - *drag_start_x.read()); - offset_y.set(coords.y - *drag_start_y.read()); - } - }; - - let on_mouse_up = move |_: MouseEvent| { - dragging.set(false); - }; - - let on_keydown = { - move |evt: KeyboardEvent| match evt.key() { - Key::Escape => on_close.call(()), - Key::Character(ref c) if c == "+" || c == "=" => { - let new_zoom = (*zoom.read() * 1.2).min(20.0); - zoom.set(new_zoom); - } - Key::Character(ref c) if c == "-" => { - let new_zoom = (*zoom.read() / 1.2).max(0.1); - zoom.set(new_zoom); - } - Key::Character(ref c) if c == "0" => { - zoom.set(1.0); - offset_x.set(0.0); - offset_y.set(0.0); - fit_mode.set(FitMode::FitScreen); - } - Key::ArrowLeft => { - if let Some(ref prev) = on_prev { - prev.call(()); - zoom.set(1.0); - offset_x.set(0.0); - offset_y.set(0.0); - } - } - Key::ArrowRight => { - if let Some(ref next) = on_next { - next.call(()); - zoom.set(1.0); - offset_x.set(0.0); - offset_y.set(0.0); - } - } - _ => {} - } - }; - - let zoom_in = move |_| { - let new_zoom = (*zoom.read() * 1.2).min(20.0); - zoom.set(new_zoom); - }; - - let zoom_out = move |_| { - let new_zoom = (*zoom.read() / 1.2).max(0.1); - zoom.set(new_zoom); - }; - - let cycle_fit = move |_| { - let next = fit_mode.read().next(); - fit_mode.set(next); - zoom.set(1.0); - offset_x.set(0.0); - offset_y.set(0.0); - }; - - let has_prev = on_prev.is_some(); - let has_next = on_next.is_some(); - - rsx! { - div { - class: "image-viewer-overlay", - tabindex: "0", - onkeydown: on_keydown, - - // Toolbar - div { class: "image-viewer-toolbar", - div { class: "image-viewer-toolbar-left", - if has_prev { - button { - class: "iv-btn", - onclick: move |_| { - if let Some(ref prev) = on_prev { - prev.call(()); - zoom.set(1.0); - offset_x.set(0.0); - offset_y.set(0.0); - } - }, - title: "Previous", - "\u{25c0}" - } - } - if has_next { - button { - class: "iv-btn", - onclick: move |_| { - if let Some(ref next) = on_next { - next.call(()); - zoom.set(1.0); - offset_x.set(0.0); - offset_y.set(0.0); - } - }, - title: "Next", - "\u{25b6}" - } - } - } - div { class: "image-viewer-toolbar-center", - button { - class: "iv-btn", - onclick: cycle_fit, - title: "Cycle fit mode", - "{current_fit.label()}" - } - button { - class: "iv-btn", - onclick: zoom_out, - title: "Zoom out", - "\u{2212}" - } - span { class: "iv-zoom-label", "{zoom_pct}%" } - button { class: "iv-btn", onclick: zoom_in, title: "Zoom in", "+" } - } - div { class: "image-viewer-toolbar-right", - button { - class: "iv-btn iv-close", - onclick: move |_| on_close.call(()), - title: "Close", - "\u{2715}" - } - } - } - - // Image canvas - div { - class: "image-viewer-canvas", - onwheel: on_wheel, - onmousedown: on_mouse_down, - onmousemove: on_mouse_move, - onmouseup: on_mouse_up, - onclick: move |e: MouseEvent| { - // Close on background click (not on image) - e.stop_propagation(); - }, - - img { - src: "{src}", - alt: "{alt}", - style: "{img_style}", - draggable: "false", - onclick: move |e: MouseEvent| e.stop_propagation(), - } - } - } + let on_mouse_down = move |e: MouseEvent| { + if *zoom.read() > 1.0 { + dragging.set(true); + let coords = e.client_coordinates(); + drag_start_x.set(coords.x - *offset_x.read()); + drag_start_y.set(coords.y - *offset_y.read()); } + }; + + let on_mouse_move = move |e: MouseEvent| { + if *dragging.read() { + let coords = e.client_coordinates(); + offset_x.set(coords.x - *drag_start_x.read()); + offset_y.set(coords.y - *drag_start_y.read()); + } + }; + + let on_mouse_up = move |_: MouseEvent| { + dragging.set(false); + }; + + let on_keydown = { + move |evt: KeyboardEvent| { + match evt.key() { + Key::Escape => on_close.call(()), + Key::Character(ref c) if c == "+" || c == "=" => { + let new_zoom = (*zoom.read() * 1.2).min(20.0); + zoom.set(new_zoom); + }, + Key::Character(ref c) if c == "-" => { + let new_zoom = (*zoom.read() / 1.2).max(0.1); + zoom.set(new_zoom); + }, + Key::Character(ref c) if c == "0" => { + zoom.set(1.0); + offset_x.set(0.0); + offset_y.set(0.0); + fit_mode.set(FitMode::FitScreen); + }, + Key::ArrowLeft => { + if let Some(ref prev) = on_prev { + prev.call(()); + zoom.set(1.0); + offset_x.set(0.0); + offset_y.set(0.0); + } + }, + Key::ArrowRight => { + if let Some(ref next) = on_next { + next.call(()); + zoom.set(1.0); + offset_x.set(0.0); + offset_y.set(0.0); + } + }, + _ => {}, + } + } + }; + + let zoom_in = move |_| { + let new_zoom = (*zoom.read() * 1.2).min(20.0); + zoom.set(new_zoom); + }; + + let zoom_out = move |_| { + let new_zoom = (*zoom.read() / 1.2).max(0.1); + zoom.set(new_zoom); + }; + + let cycle_fit = move |_| { + let next = fit_mode.read().next(); + fit_mode.set(next); + zoom.set(1.0); + offset_x.set(0.0); + offset_y.set(0.0); + }; + + let has_prev = on_prev.is_some(); + let has_next = on_next.is_some(); + + rsx! { + div { + class: "image-viewer-overlay", + tabindex: "0", + onkeydown: on_keydown, + + // Toolbar + div { class: "image-viewer-toolbar", + div { class: "image-viewer-toolbar-left", + if has_prev { + button { + class: "iv-btn", + onclick: move |_| { + if let Some(ref prev) = on_prev { + prev.call(()); + zoom.set(1.0); + offset_x.set(0.0); + offset_y.set(0.0); + } + }, + title: "Previous", + "\u{25c0}" + } + } + if has_next { + button { + class: "iv-btn", + onclick: move |_| { + if let Some(ref next) = on_next { + next.call(()); + zoom.set(1.0); + offset_x.set(0.0); + offset_y.set(0.0); + } + }, + title: "Next", + "\u{25b6}" + } + } + } + div { class: "image-viewer-toolbar-center", + button { + class: "iv-btn", + onclick: cycle_fit, + title: "Cycle fit mode", + "{current_fit.label()}" + } + button { + class: "iv-btn", + onclick: zoom_out, + title: "Zoom out", + "\u{2212}" + } + span { class: "iv-zoom-label", "{zoom_pct}%" } + button { class: "iv-btn", onclick: zoom_in, title: "Zoom in", "+" } + } + div { class: "image-viewer-toolbar-right", + button { + class: "iv-btn iv-close", + onclick: move |_| on_close.call(()), + title: "Close", + "\u{2715}" + } + } + } + + // Image canvas + div { + class: "image-viewer-canvas", + onwheel: on_wheel, + onmousedown: on_mouse_down, + onmousemove: on_mouse_move, + onmouseup: on_mouse_up, + onclick: move |e: MouseEvent| { + // Close on background click (not on image) + e.stop_propagation(); + }, + + img { + src: "{src}", + alt: "{alt}", + style: "{img_style}", + draggable: "false", + onclick: move |e: MouseEvent| e.stop_propagation(), + } + } + } + } } diff --git a/crates/pinakes-ui/src/components/import.rs b/crates/pinakes-ui/src/components/import.rs index b04f67f..9dbd8ee 100644 --- a/crates/pinakes-ui/src/components/import.rs +++ b/crates/pinakes-ui/src/components/import.rs @@ -4,821 +4,827 @@ use dioxus::prelude::*; use super::utils::{format_size, type_badge_class}; use crate::client::{ - CollectionResponse, DirectoryPreviewFile, ImportEvent, ScanStatusResponse, TagResponse, + CollectionResponse, + DirectoryPreviewFile, + ImportEvent, + ScanStatusResponse, + TagResponse, }; /// Import event for batch: (paths, tag_ids, new_tags, collection_id) -pub type BatchImportEvent = (Vec, Vec, Vec, Option); +pub type BatchImportEvent = + (Vec, Vec, Vec, Option); #[component] pub fn Import( - tags: Vec, - collections: Vec, - on_import_file: EventHandler, - on_import_directory: EventHandler, - on_import_batch: EventHandler, - on_scan: EventHandler<()>, - on_preview_directory: EventHandler<(String, bool)>, - preview_files: Vec, - preview_total_size: u64, - scan_progress: Option, - #[props(default = false)] is_importing: bool, - // Extended import state - #[props(default)] current_file: Option, - #[props(default)] import_queue: Vec, - #[props(default = (0, 0))] import_progress: (usize, usize), + tags: Vec, + collections: Vec, + on_import_file: EventHandler, + on_import_directory: EventHandler, + on_import_batch: EventHandler, + on_scan: EventHandler<()>, + on_preview_directory: EventHandler<(String, bool)>, + preview_files: Vec, + preview_total_size: u64, + scan_progress: Option, + #[props(default = false)] is_importing: bool, + // Extended import state + #[props(default)] current_file: Option, + #[props(default)] import_queue: Vec, + #[props(default = (0, 0))] import_progress: (usize, usize), ) -> Element { - let mut import_mode = use_signal(|| 0usize); - let mut file_path = use_signal(String::new); - let mut dir_path = use_signal(String::new); - let selected_tags = use_signal(Vec::::new); - let new_tags_input = use_signal(String::new); - let selected_collection = use_signal(|| Option::::None); + let mut import_mode = use_signal(|| 0usize); + let mut file_path = use_signal(String::new); + let mut dir_path = use_signal(String::new); + let selected_tags = use_signal(Vec::::new); + let new_tags_input = use_signal(String::new); + let selected_collection = use_signal(|| Option::::None); - // Recursive toggle for directory preview - let mut recursive = use_signal(|| true); + // Recursive toggle for directory preview + let mut recursive = use_signal(|| true); - // Filter state for directory preview - let mut filter_types = use_signal(|| vec![true, true, true, true, true, true]); // audio, video, image, document, text, other - let mut filter_min_size = use_signal(|| 0u64); - let mut filter_max_size = use_signal(|| 0u64); // 0 means no limit + // Filter state for directory preview + let mut filter_types = + use_signal(|| vec![true, true, true, true, true, true]); // audio, video, image, document, text, other + let mut filter_min_size = use_signal(|| 0u64); + let mut filter_max_size = use_signal(|| 0u64); // 0 means no limit - // File selection state - let mut selected_file_paths = use_signal(HashSet::::new); + // File selection state + let mut selected_file_paths = use_signal(HashSet::::new); - let current_mode = *import_mode.read(); + let current_mode = *import_mode.read(); - rsx! { - // Import status panel (shown when import is in progress) - if is_importing { - { - let (completed, total) = import_progress; - let has_progress = total > 0; - let pct = (completed * 100).checked_div(total).unwrap_or(0); - let queue_count = import_queue.len(); - rsx! { - div { class: "import-status-panel", - div { class: "import-status-header", - div { class: "status-dot checking" } - span { - if has_progress { - "Importing {completed}/{total}..." - } else { - "Import in progress..." - } - } - } - // Show current file being imported - if let Some(ref file_name) = current_file { - div { class: "import-current-file", - span { class: "import-file-label", "Current: " } - span { class: "import-file-name", "{file_name}" } - } - } - // Show queue indicator - if queue_count > 0 { - div { class: "import-queue-indicator", - span { class: "import-queue-badge", "{queue_count}" } - span { class: "import-queue-text", " item(s) queued" } - } - } - div { class: "progress-bar", - if has_progress { - div { class: "progress-fill", style: "width: {pct}%;" } - } else { - div { class: "progress-fill indeterminate" } - } - } - } - } - } - } + rsx! { + // Import status panel (shown when import is in progress) + if is_importing { + { + let (completed, total) = import_progress; + let has_progress = total > 0; + let pct = (completed * 100).checked_div(total).unwrap_or(0); + let queue_count = import_queue.len(); + rsx! { + div { class: "import-status-panel", + div { class: "import-status-header", + div { class: "status-dot checking" } + span { + if has_progress { + "Importing {completed}/{total}..." + } else { + "Import in progress..." + } + } + } + // Show current file being imported + if let Some(ref file_name) = current_file { + div { class: "import-current-file", + span { class: "import-file-label", "Current: " } + span { class: "import-file-name", "{file_name}" } + } + } + // Show queue indicator + if queue_count > 0 { + div { class: "import-queue-indicator", + span { class: "import-queue-badge", "{queue_count}" } + span { class: "import-queue-text", " item(s) queued" } + } + } + div { class: "progress-bar", + if has_progress { + div { class: "progress-fill", style: "width: {pct}%;" } + } else { + div { class: "progress-fill indeterminate" } + } + } + } + } + } + } - // Tab bar - div { class: "import-tabs", - button { - class: if current_mode == 0 { "import-tab active" } else { "import-tab" }, - onclick: move |_| import_mode.set(0), - "Single File" - } - button { - class: if current_mode == 1 { "import-tab active" } else { "import-tab" }, - onclick: move |_| import_mode.set(1), - "Directory" - } - button { - class: if current_mode == 2 { "import-tab active" } else { "import-tab" }, - onclick: move |_| import_mode.set(2), - "Scan Roots" - } - } + // Tab bar + div { class: "import-tabs", + button { + class: if current_mode == 0 { "import-tab active" } else { "import-tab" }, + onclick: move |_| import_mode.set(0), + "Single File" + } + button { + class: if current_mode == 1 { "import-tab active" } else { "import-tab" }, + onclick: move |_| import_mode.set(1), + "Directory" + } + button { + class: if current_mode == 2 { "import-tab active" } else { "import-tab" }, + onclick: move |_| import_mode.set(2), + "Scan Roots" + } + } - // Mode 0: Single File - if current_mode == 0 { - div { class: "card mb-16", - div { class: "card-header", - h3 { class: "card-title", "Import Single File" } - } + // Mode 0: Single File + if current_mode == 0 { + div { class: "card mb-16", + div { class: "card-header", + h3 { class: "card-title", "Import Single File" } + } - div { class: "form-group", - label { class: "form-label", "File Path" } - div { class: "form-row", - input { - r#type: "text", - placeholder: "/path/to/file...", - value: "{file_path}", - oninput: move |e| file_path.set(e.value()), - onkeypress: { - let mut file_path = file_path; - let mut selected_tags = selected_tags; - let mut new_tags_input = new_tags_input; - let mut selected_collection = selected_collection; - move |e: KeyboardEvent| { - if e.key() == Key::Enter { - let path = file_path.read().clone(); - if !path.is_empty() { - let tag_ids = selected_tags.read().clone(); - let new_tags = parse_new_tags(&new_tags_input.read()); - let col_id = selected_collection.read().clone(); - on_import_file.call((path, tag_ids, new_tags, col_id)); - file_path.set(String::new()); - selected_tags.set(Vec::new()); - new_tags_input.set(String::new()); - selected_collection.set(None); - } - } - } - }, - } - button { - class: "btn btn-secondary", - onclick: move |_| { - let mut file_path = file_path; - spawn(async move { - if let Some(handle) = rfd::AsyncFileDialog::new().pick_file().await { - file_path.set(handle.path().to_string_lossy().to_string()); - } - }); - }, - "Browse..." - } - button { - class: "btn btn-primary", - disabled: is_importing, - onclick: { - let mut file_path = file_path; - let mut selected_tags = selected_tags; - let mut new_tags_input = new_tags_input; - let mut selected_collection = selected_collection; - move |_| { - let path = file_path.read().clone(); - if !path.is_empty() { - let tag_ids = selected_tags.read().clone(); - let new_tags = parse_new_tags(&new_tags_input.read()); - let col_id = selected_collection.read().clone(); - on_import_file.call((path, tag_ids, new_tags, col_id)); - file_path.set(String::new()); - selected_tags.set(Vec::new()); - new_tags_input.set(String::new()); - selected_collection.set(None); - } - } - }, - if is_importing { - "Importing..." - } else { - "Import" - } - } - } - } - } + div { class: "form-group", + label { class: "form-label", "File Path" } + div { class: "form-row", + input { + r#type: "text", + placeholder: "/path/to/file...", + value: "{file_path}", + oninput: move |e| file_path.set(e.value()), + onkeypress: { + let mut file_path = file_path; + let mut selected_tags = selected_tags; + let mut new_tags_input = new_tags_input; + let mut selected_collection = selected_collection; + move |e: KeyboardEvent| { + if e.key() == Key::Enter { + let path = file_path.read().clone(); + if !path.is_empty() { + let tag_ids = selected_tags.read().clone(); + let new_tags = parse_new_tags(&new_tags_input.read()); + let col_id = selected_collection.read().clone(); + on_import_file.call((path, tag_ids, new_tags, col_id)); + file_path.set(String::new()); + selected_tags.set(Vec::new()); + new_tags_input.set(String::new()); + selected_collection.set(None); + } + } + } + }, + } + button { + class: "btn btn-secondary", + onclick: move |_| { + let mut file_path = file_path; + spawn(async move { + if let Some(handle) = rfd::AsyncFileDialog::new().pick_file().await { + file_path.set(handle.path().to_string_lossy().to_string()); + } + }); + }, + "Browse..." + } + button { + class: "btn btn-primary", + disabled: is_importing, + onclick: { + let mut file_path = file_path; + let mut selected_tags = selected_tags; + let mut new_tags_input = new_tags_input; + let mut selected_collection = selected_collection; + move |_| { + let path = file_path.read().clone(); + if !path.is_empty() { + let tag_ids = selected_tags.read().clone(); + let new_tags = parse_new_tags(&new_tags_input.read()); + let col_id = selected_collection.read().clone(); + on_import_file.call((path, tag_ids, new_tags, col_id)); + file_path.set(String::new()); + selected_tags.set(Vec::new()); + new_tags_input.set(String::new()); + selected_collection.set(None); + } + } + }, + if is_importing { + "Importing..." + } else { + "Import" + } + } + } + } + } - ImportOptions { - tags: tags.clone(), - collections: collections.clone(), - selected_tags, - new_tags_input, - selected_collection, - } - } + ImportOptions { + tags: tags.clone(), + collections: collections.clone(), + selected_tags, + new_tags_input, + selected_collection, + } + } - // Mode 1: Directory - if current_mode == 1 { - div { class: "card mb-16", - div { class: "card-header", - h3 { class: "card-title", "Import Directory" } - } + // Mode 1: Directory + if current_mode == 1 { + div { class: "card mb-16", + div { class: "card-header", + h3 { class: "card-title", "Import Directory" } + } - div { class: "form-group", - label { class: "form-label", "Directory Path" } - div { class: "form-row", - input { - r#type: "text", - placeholder: "/path/to/directory...", - value: "{dir_path}", - oninput: move |e| dir_path.set(e.value()), - onkeypress: { - let dir_path = dir_path; - let recursive = recursive; - move |e: KeyboardEvent| { - if e.key() == Key::Enter { - let path = dir_path.read().clone(); - if !path.is_empty() { - on_preview_directory.call((path, *recursive.read())); - } - } - } - }, - } - button { - class: "btn btn-secondary", - onclick: move |_| { - let mut dir_path = dir_path; - let recursive = recursive; - spawn(async move { - if let Some(handle) = rfd::AsyncFileDialog::new().pick_folder().await { - let path = handle.path().to_string_lossy().to_string(); - dir_path.set(path.clone()); - on_preview_directory.call((path, *recursive.read())); - } - }); - }, - "Browse..." - } - button { - class: "btn btn-secondary", - onclick: { - let dir_path = dir_path; - let recursive = recursive; - move |_| { - let path = dir_path.read().clone(); - if !path.is_empty() { - on_preview_directory.call((path, *recursive.read())); - } - } - }, - "Preview" - } - } - } + div { class: "form-group", + label { class: "form-label", "Directory Path" } + div { class: "form-row", + input { + r#type: "text", + placeholder: "/path/to/directory...", + value: "{dir_path}", + oninput: move |e| dir_path.set(e.value()), + onkeypress: { + let dir_path = dir_path; + let recursive = recursive; + move |e: KeyboardEvent| { + if e.key() == Key::Enter { + let path = dir_path.read().clone(); + if !path.is_empty() { + on_preview_directory.call((path, *recursive.read())); + } + } + } + }, + } + button { + class: "btn btn-secondary", + onclick: move |_| { + let mut dir_path = dir_path; + let recursive = recursive; + spawn(async move { + if let Some(handle) = rfd::AsyncFileDialog::new().pick_folder().await { + let path = handle.path().to_string_lossy().to_string(); + dir_path.set(path.clone()); + on_preview_directory.call((path, *recursive.read())); + } + }); + }, + "Browse..." + } + button { + class: "btn btn-secondary", + onclick: { + let dir_path = dir_path; + let recursive = recursive; + move |_| { + let path = dir_path.read().clone(); + if !path.is_empty() { + on_preview_directory.call((path, *recursive.read())); + } + } + }, + "Preview" + } + } + } - // Recursive toggle - div { class: "form-group", - label { class: "checkbox-label", - input { - r#type: "checkbox", - checked: *recursive.read(), - onchange: move |_| recursive.toggle(), - } - span { "Recursive (include subdirectories)" } - } - } - } + // Recursive toggle + div { class: "form-group", + label { class: "checkbox-label", + input { + r#type: "checkbox", + checked: *recursive.read(), + onchange: move |_| recursive.toggle(), + } + span { "Recursive (include subdirectories)" } + } + } + } - // Preview results - if !preview_files.is_empty() { - { - // Read filter signals once before the loop to avoid per-item reads - let types_snapshot = filter_types.read().clone(); - let min = *filter_min_size.read(); - let max = *filter_max_size.read(); + // Preview results + if !preview_files.is_empty() { + { + // Read filter signals once before the loop to avoid per-item reads + let types_snapshot = filter_types.read().clone(); + let min = *filter_min_size.read(); + let max = *filter_max_size.read(); - let filtered: Vec<&DirectoryPreviewFile> = preview_files + let filtered: Vec<&DirectoryPreviewFile> = preview_files - // Read selection once for display - .iter() + // Read selection once for display + .iter() - // Filter bar + // Filter bar - // Selection toolbar + // Selection toolbar - // Deselect all filtered - // Select all filtered - .filter(|f| { - let type_idx = match type_badge_class(&f.media_type) { - "type-audio" => 0, - "type-video" => 1, - "type-image" => 2, - "type-document" => 3, - "type-text" => 4, - _ => 5, - }; - if !types_snapshot[type_idx] { - return false; - } - if min > 0 && f.file_size < min { - return false; - } - if max > 0 && f.file_size > max { - return false; - } - true - }) - .collect(); - let filtered_count = filtered.len(); - let total_count = preview_files.len(); - let selection = selected_file_paths.read().clone(); - let selected_count = selection.len(); - let all_filtered_selected = !filtered.is_empty() - && filtered.iter().all(|f| selection.contains(&f.path)); - let filtered_paths: Vec = filtered - .iter() - .map(|f| f.path.clone()) - .collect(); - rsx! { - div { class: "card mb-16", - div { class: "card-header", - h3 { class: "card-title", "Preview" } - p { class: "text-muted text-sm", - "{filtered_count} of {total_count} files shown, {format_size(preview_total_size)} total" - } - } + // Deselect all filtered + // Select all filtered + .filter(|f| { + let type_idx = match type_badge_class(&f.media_type) { + "type-audio" => 0, + "type-video" => 1, + "type-image" => 2, + "type-document" => 3, + "type-text" => 4, + _ => 5, + }; + if !types_snapshot[type_idx] { + return false; + } + if min > 0 && f.file_size < min { + return false; + } + if max > 0 && f.file_size > max { + return false; + } + true + }) + .collect(); + let filtered_count = filtered.len(); + let total_count = preview_files.len(); + let selection = selected_file_paths.read().clone(); + let selected_count = selection.len(); + let all_filtered_selected = !filtered.is_empty() + && filtered.iter().all(|f| selection.contains(&f.path)); + let filtered_paths: Vec = filtered + .iter() + .map(|f| f.path.clone()) + .collect(); + rsx! { + div { class: "card mb-16", + div { class: "card-header", + h3 { class: "card-title", "Preview" } + p { class: "text-muted text-sm", + "{filtered_count} of {total_count} files shown, {format_size(preview_total_size)} total" + } + } - div { class: "filter-bar", - div { class: "filter-row", - span { class: "filter-label", "Types" } - label { class: if types_snapshot[0] { "filter-chip active" } else { "filter-chip" }, - input { - r#type: "checkbox", - checked: types_snapshot[0], - onchange: move |_| { - let mut types = filter_types.read().clone(); - types[0] = !types[0]; - filter_types.set(types); - }, - } - "Audio" - } - label { class: if types_snapshot[1] { "filter-chip active" } else { "filter-chip" }, - input { - r#type: "checkbox", - checked: types_snapshot[1], - onchange: move |_| { - let mut types = filter_types.read().clone(); - types[1] = !types[1]; - filter_types.set(types); - }, - } - "Video" - } - label { class: if types_snapshot[2] { "filter-chip active" } else { "filter-chip" }, - input { - r#type: "checkbox", - checked: types_snapshot[2], - onchange: move |_| { - let mut types = filter_types.read().clone(); - types[2] = !types[2]; - filter_types.set(types); - }, - } - "Image" - } - label { class: if types_snapshot[3] { "filter-chip active" } else { "filter-chip" }, - input { - r#type: "checkbox", - checked: types_snapshot[3], - onchange: move |_| { - let mut types = filter_types.read().clone(); - types[3] = !types[3]; - filter_types.set(types); - }, - } - "Document" - } - label { class: if types_snapshot[4] { "filter-chip active" } else { "filter-chip" }, - input { - r#type: "checkbox", - checked: types_snapshot[4], - onchange: move |_| { - let mut types = filter_types.read().clone(); - types[4] = !types[4]; - filter_types.set(types); - }, - } - "Text" - } - label { class: if types_snapshot[5] { "filter-chip active" } else { "filter-chip" }, - input { - r#type: "checkbox", - checked: types_snapshot[5], - onchange: move |_| { - let mut types = filter_types.read().clone(); - types[5] = !types[5]; - filter_types.set(types); - }, - } - "Other" - } - } - div { class: "size-filters", - div { class: "size-filter-group", - label { "Min size" } - input { - r#type: "number", - placeholder: "MB", - value: if min > 0 { format!("{}", min / (1024 * 1024)) } else { String::new() }, - oninput: move |e| { - if let Ok(mb) = e.value().parse::() { - filter_min_size.set(mb * 1024 * 1024); - } else { - filter_min_size.set(0); - } - }, - } - span { class: "text-muted text-sm", "MB" } - } - div { class: "size-filter-group", - label { "Max size" } - input { - r#type: "number", - placeholder: "MB", - value: if max > 0 { format!("{}", max / (1024 * 1024)) } else { String::new() }, - oninput: move |e| { - if let Ok(mb) = e.value().parse::() { - filter_max_size.set(mb * 1024 * 1024); - } else { - filter_max_size.set(0); - } - }, - } - span { class: "text-muted text-sm", "MB" } - } - } - } + div { class: "filter-bar", + div { class: "filter-row", + span { class: "filter-label", "Types" } + label { class: if types_snapshot[0] { "filter-chip active" } else { "filter-chip" }, + input { + r#type: "checkbox", + checked: types_snapshot[0], + onchange: move |_| { + let mut types = filter_types.read().clone(); + types[0] = !types[0]; + filter_types.set(types); + }, + } + "Audio" + } + label { class: if types_snapshot[1] { "filter-chip active" } else { "filter-chip" }, + input { + r#type: "checkbox", + checked: types_snapshot[1], + onchange: move |_| { + let mut types = filter_types.read().clone(); + types[1] = !types[1]; + filter_types.set(types); + }, + } + "Video" + } + label { class: if types_snapshot[2] { "filter-chip active" } else { "filter-chip" }, + input { + r#type: "checkbox", + checked: types_snapshot[2], + onchange: move |_| { + let mut types = filter_types.read().clone(); + types[2] = !types[2]; + filter_types.set(types); + }, + } + "Image" + } + label { class: if types_snapshot[3] { "filter-chip active" } else { "filter-chip" }, + input { + r#type: "checkbox", + checked: types_snapshot[3], + onchange: move |_| { + let mut types = filter_types.read().clone(); + types[3] = !types[3]; + filter_types.set(types); + }, + } + "Document" + } + label { class: if types_snapshot[4] { "filter-chip active" } else { "filter-chip" }, + input { + r#type: "checkbox", + checked: types_snapshot[4], + onchange: move |_| { + let mut types = filter_types.read().clone(); + types[4] = !types[4]; + filter_types.set(types); + }, + } + "Text" + } + label { class: if types_snapshot[5] { "filter-chip active" } else { "filter-chip" }, + input { + r#type: "checkbox", + checked: types_snapshot[5], + onchange: move |_| { + let mut types = filter_types.read().clone(); + types[5] = !types[5]; + filter_types.set(types); + }, + } + "Other" + } + } + div { class: "size-filters", + div { class: "size-filter-group", + label { "Min size" } + input { + r#type: "number", + placeholder: "MB", + value: if min > 0 { format!("{}", min / (1024 * 1024)) } else { String::new() }, + oninput: move |e| { + if let Ok(mb) = e.value().parse::() { + filter_min_size.set(mb * 1024 * 1024); + } else { + filter_min_size.set(0); + } + }, + } + span { class: "text-muted text-sm", "MB" } + } + div { class: "size-filter-group", + label { "Max size" } + input { + r#type: "number", + placeholder: "MB", + value: if max > 0 { format!("{}", max / (1024 * 1024)) } else { String::new() }, + oninput: move |e| { + if let Ok(mb) = e.value().parse::() { + filter_max_size.set(mb * 1024 * 1024); + } else { + filter_max_size.set(0); + } + }, + } + span { class: "text-muted text-sm", "MB" } + } + } + } - div { - class: "flex-row mb-8", - style: "gap: 8px; align-items: center; padding: 0 8px;", - button { - class: "btn btn-sm btn-secondary", - onclick: { - let filtered_paths = filtered_paths.clone(); - move |_| { - let mut sel = selected_file_paths.read().clone(); - for p in &filtered_paths { - sel.insert(p.clone()); - } - selected_file_paths.set(sel); - } - }, - "Select All ({filtered_count})" - } - button { - class: "btn btn-sm btn-ghost", - onclick: move |_| { - selected_file_paths.set(HashSet::new()); - }, - "Deselect All" - } - if selected_count > 0 { - span { class: "text-muted text-sm", "{selected_count} files selected" } - } - } + div { + class: "flex-row mb-8", + style: "gap: 8px; align-items: center; padding: 0 8px;", + button { + class: "btn btn-sm btn-secondary", + onclick: { + let filtered_paths = filtered_paths.clone(); + move |_| { + let mut sel = selected_file_paths.read().clone(); + for p in &filtered_paths { + sel.insert(p.clone()); + } + selected_file_paths.set(sel); + } + }, + "Select All ({filtered_count})" + } + button { + class: "btn btn-sm btn-ghost", + onclick: move |_| { + selected_file_paths.set(HashSet::new()); + }, + "Deselect All" + } + if selected_count > 0 { + span { class: "text-muted text-sm", "{selected_count} files selected" } + } + } - div { style: "max-height: 400px; overflow-y: auto;", - table { class: "data-table", - thead { - tr { - th { style: "width: 32px;", - input { - r#type: "checkbox", - checked: all_filtered_selected, - onclick: { - let filtered_paths = filtered_paths.clone(); - move |_| { - if all_filtered_selected { - let filtered_set: HashSet = filtered_paths - .iter() - .cloned() - .collect(); - let sel = selected_file_paths.read().clone(); - let new_sel: HashSet = sel - .difference(&filtered_set) - .cloned() - .collect(); - selected_file_paths.set(new_sel); - } else { - let mut sel = selected_file_paths.read().clone(); - for p in &filtered_paths { - sel.insert(p.clone()); - } - selected_file_paths.set(sel); - } - } - }, - } - } - th { "File Name" } - th { "Type" } - th { "Size" } - } - } - tbody { - for file in filtered.iter() { - { - let size = format_size(file.file_size); - let badge_class = type_badge_class(&file.media_type); - let is_selected = selection.contains(&file.path); - let file_path_clone = file.path.clone(); - rsx! { - tr { key: "{file.path}", class: if is_selected { "row-selected" } else { "" }, - td { - input { - r#type: "checkbox", - checked: is_selected, - onclick: { - let path = file_path_clone.clone(); - move |_| { - let mut sel = selected_file_paths.read().clone(); - if sel.contains(&path) { - sel.remove(&path); - } else { - sel.insert(path.clone()); - } - selected_file_paths.set(sel); - } - }, - } - } - td { "{file.file_name}" } - td { - span { class: "type-badge {badge_class}", "{file.media_type}" } - } - td { "{size}" } - } - } - } - } - } - } - } - } - } - } - } + div { style: "max-height: 400px; overflow-y: auto;", + table { class: "data-table", + thead { + tr { + th { style: "width: 32px;", + input { + r#type: "checkbox", + checked: all_filtered_selected, + onclick: { + let filtered_paths = filtered_paths.clone(); + move |_| { + if all_filtered_selected { + let filtered_set: HashSet = filtered_paths + .iter() + .cloned() + .collect(); + let sel = selected_file_paths.read().clone(); + let new_sel: HashSet = sel + .difference(&filtered_set) + .cloned() + .collect(); + selected_file_paths.set(new_sel); + } else { + let mut sel = selected_file_paths.read().clone(); + for p in &filtered_paths { + sel.insert(p.clone()); + } + selected_file_paths.set(sel); + } + } + }, + } + } + th { "File Name" } + th { "Type" } + th { "Size" } + } + } + tbody { + for file in filtered.iter() { + { + let size = format_size(file.file_size); + let badge_class = type_badge_class(&file.media_type); + let is_selected = selection.contains(&file.path); + let file_path_clone = file.path.clone(); + rsx! { + tr { key: "{file.path}", class: if is_selected { "row-selected" } else { "" }, + td { + input { + r#type: "checkbox", + checked: is_selected, + onclick: { + let path = file_path_clone.clone(); + move |_| { + let mut sel = selected_file_paths.read().clone(); + if sel.contains(&path) { + sel.remove(&path); + } else { + sel.insert(path.clone()); + } + selected_file_paths.set(sel); + } + }, + } + } + td { "{file.file_name}" } + td { + span { class: "type-badge {badge_class}", "{file.media_type}" } + } + td { "{size}" } + } + } + } + } + } + } + } + } + } + } + } - ImportOptions { - tags: tags.clone(), - collections: collections.clone(), - selected_tags, - new_tags_input, - selected_collection, - } + ImportOptions { + tags: tags.clone(), + collections: collections.clone(), + selected_tags, + new_tags_input, + selected_collection, + } - div { class: "flex-row mb-16", style: "gap: 8px;", - // Import selected files only (batch import) - { - let sel_count = selected_file_paths.read().len(); - let has_selected = sel_count > 0; - rsx! { - button { - class: "btn btn-primary", - disabled: !has_selected || is_importing, - onclick: { - let mut selected_file_paths = selected_file_paths; - let mut selected_tags = selected_tags; - let mut new_tags_input = new_tags_input; - let mut selected_collection = selected_collection; - move |_| { - let paths: Vec = selected_file_paths - .read() - .iter() - .cloned() - .collect(); - if !paths.is_empty() { - let tag_ids = selected_tags.read().clone(); - let new_tags = parse_new_tags(&new_tags_input.read()); - let col_id = selected_collection.read().clone(); - on_import_batch.call((paths, tag_ids, new_tags, col_id)); - selected_file_paths.set(HashSet::new()); - selected_tags.set(Vec::new()); - new_tags_input.set(String::new()); - selected_collection.set(None); - } - } - }, - if is_importing { - "Importing..." - } else if has_selected { - "Import Selected ({sel_count})" - } else { - "Import Selected" - } - } - } - } + div { class: "flex-row mb-16", style: "gap: 8px;", + // Import selected files only (batch import) + { + let sel_count = selected_file_paths.read().len(); + let has_selected = sel_count > 0; + rsx! { + button { + class: "btn btn-primary", + disabled: !has_selected || is_importing, + onclick: { + let mut selected_file_paths = selected_file_paths; + let mut selected_tags = selected_tags; + let mut new_tags_input = new_tags_input; + let mut selected_collection = selected_collection; + move |_| { + let paths: Vec = selected_file_paths + .read() + .iter() + .cloned() + .collect(); + if !paths.is_empty() { + let tag_ids = selected_tags.read().clone(); + let new_tags = parse_new_tags(&new_tags_input.read()); + let col_id = selected_collection.read().clone(); + on_import_batch.call((paths, tag_ids, new_tags, col_id)); + selected_file_paths.set(HashSet::new()); + selected_tags.set(Vec::new()); + new_tags_input.set(String::new()); + selected_collection.set(None); + } + } + }, + if is_importing { + "Importing..." + } else if has_selected { + "Import Selected ({sel_count})" + } else { + "Import Selected" + } + } + } + } - // Import entire directory - { - let has_dir = !dir_path.read().is_empty(); - let has_preview = !preview_files.is_empty(); - let file_count = preview_files.len(); - rsx! { - button { - class: if has_dir { "btn btn-secondary" } else { "btn btn-secondary btn-disabled-hint" }, - disabled: is_importing || !has_dir, - title: if !has_dir { "Select a directory first" } else { "" }, - onclick: { - let mut dir_path = dir_path; - let mut selected_tags = selected_tags; - let mut new_tags_input = new_tags_input; - let mut selected_collection = selected_collection; - let mut selected_file_paths = selected_file_paths; - move |_| { - let path = dir_path.read().clone(); - if !path.is_empty() { - let tag_ids = selected_tags.read().clone(); - let new_tags = parse_new_tags(&new_tags_input.read()); - let col_id = selected_collection.read().clone(); - on_import_directory.call((path, tag_ids, new_tags, col_id)); - dir_path.set(String::new()); - selected_tags.set(Vec::new()); - new_tags_input.set(String::new()); - selected_collection.set(None); - selected_file_paths.set(HashSet::new()); - } - } - }, - if is_importing { - "Importing..." - } else if has_preview { - "Import All ({file_count} files)" - } else if has_dir { - "Import Entire Directory" - } else { - "Select Directory First" - } - } - } - } - } - } + // Import entire directory + { + let has_dir = !dir_path.read().is_empty(); + let has_preview = !preview_files.is_empty(); + let file_count = preview_files.len(); + rsx! { + button { + class: if has_dir { "btn btn-secondary" } else { "btn btn-secondary btn-disabled-hint" }, + disabled: is_importing || !has_dir, + title: if !has_dir { "Select a directory first" } else { "" }, + onclick: { + let mut dir_path = dir_path; + let mut selected_tags = selected_tags; + let mut new_tags_input = new_tags_input; + let mut selected_collection = selected_collection; + let mut selected_file_paths = selected_file_paths; + move |_| { + let path = dir_path.read().clone(); + if !path.is_empty() { + let tag_ids = selected_tags.read().clone(); + let new_tags = parse_new_tags(&new_tags_input.read()); + let col_id = selected_collection.read().clone(); + on_import_directory.call((path, tag_ids, new_tags, col_id)); + dir_path.set(String::new()); + selected_tags.set(Vec::new()); + new_tags_input.set(String::new()); + selected_collection.set(None); + selected_file_paths.set(HashSet::new()); + } + } + }, + if is_importing { + "Importing..." + } else if has_preview { + "Import All ({file_count} files)" + } else if has_dir { + "Import Entire Directory" + } else { + "Select Directory First" + } + } + } + } + } + } - // Mode 2: Scan Roots - if current_mode == 2 { - div { class: "card mb-16", - div { class: "card-header", - h3 { class: "card-title", "Scan Root Directories" } - } + // Mode 2: Scan Roots + if current_mode == 2 { + div { class: "card mb-16", + div { class: "card-header", + h3 { class: "card-title", "Scan Root Directories" } + } - div { class: "empty-state", - p { class: "empty-subtitle", - "Scan all configured root directories for media files. " - "This will discover and import any new files found in your root paths." - } - } + div { class: "empty-state", + p { class: "empty-subtitle", + "Scan all configured root directories for media files. " + "This will discover and import any new files found in your root paths." + } + } - div { class: "mb-16", style: "text-align: center;", - button { - class: "btn btn-primary", - disabled: is_importing, - onclick: move |_| on_scan.call(()), - if is_importing { - "Scanning..." - } else { - "Scan All Roots" - } - } - } + div { class: "mb-16", style: "text-align: center;", + button { + class: "btn btn-primary", + disabled: is_importing, + onclick: move |_| on_scan.call(()), + if is_importing { + "Scanning..." + } else { + "Scan All Roots" + } + } + } - if let Some(ref progress) = scan_progress { - { - let pct = (progress.files_processed * 100) - .checked_div(progress.files_found) - .unwrap_or(0); - rsx! { - div { class: "mb-16", - div { class: "progress-bar", - div { class: "progress-fill", style: "width: {pct}%;" } - } - p { class: "text-muted text-sm", - "{progress.files_processed} / {progress.files_found} files processed" - } - if progress.error_count > 0 { - p { class: "text-muted text-sm", "{progress.error_count} errors" } - } - if progress.scanning { - p { class: "text-muted text-sm", "Scanning..." } - } else { - p { class: "text-muted text-sm", "Scan complete" } - } - } - } - } - } - } - } - } + if let Some(ref progress) = scan_progress { + { + let pct = (progress.files_processed * 100) + .checked_div(progress.files_found) + .unwrap_or(0); + rsx! { + div { class: "mb-16", + div { class: "progress-bar", + div { class: "progress-fill", style: "width: {pct}%;" } + } + p { class: "text-muted text-sm", + "{progress.files_processed} / {progress.files_found} files processed" + } + if progress.error_count > 0 { + p { class: "text-muted text-sm", "{progress.error_count} errors" } + } + if progress.scanning { + p { class: "text-muted text-sm", "Scanning..." } + } else { + p { class: "text-muted text-sm", "Scan complete" } + } + } + } + } + } + } + } + } } #[component] fn ImportOptions( - tags: Vec, - collections: Vec, - selected_tags: Signal>, - new_tags_input: Signal, - selected_collection: Signal>, + tags: Vec, + collections: Vec, + selected_tags: Signal>, + new_tags_input: Signal, + selected_collection: Signal>, ) -> Element { - let selected_tags = selected_tags; - let mut new_tags_input = new_tags_input; - let selected_collection = selected_collection; + let selected_tags = selected_tags; + let mut new_tags_input = new_tags_input; + let selected_collection = selected_collection; - rsx! { - div { class: "card mb-16", - div { class: "card-header", - h4 { class: "card-title", "Import Options" } - } + rsx! { + div { class: "card mb-16", + div { class: "card-header", + h4 { class: "card-title", "Import Options" } + } - div { class: "form-group", - label { class: "form-label", "Tags" } - if tags.is_empty() { - p { class: "text-muted text-sm", - "No tags available. Create tags from the Tags page." - } - } else { - div { class: "tag-list", - for tag in tags.iter() { - { - let tag_id = tag.id.clone(); - let tag_name = tag.name.clone(); - let is_selected = selected_tags.read().contains(&tag_id); - let badge_class = if is_selected { "tag-badge selected" } else { "tag-badge" }; - rsx! { - span { - class: "{badge_class}", - onclick: { - let tag_id = tag_id.clone(); - let mut selected_tags = selected_tags; - move |_| { - let mut current = selected_tags.read().clone(); - if let Some(pos) = current.iter().position(|t| t == &tag_id) { - current.remove(pos); - } else { - current.push(tag_id.clone()); - } - selected_tags.set(current); - } - }, - "{tag_name}" - } - } - } - } - } - } - } + div { class: "form-group", + label { class: "form-label", "Tags" } + if tags.is_empty() { + p { class: "text-muted text-sm", + "No tags available. Create tags from the Tags page." + } + } else { + div { class: "tag-list", + for tag in tags.iter() { + { + let tag_id = tag.id.clone(); + let tag_name = tag.name.clone(); + let is_selected = selected_tags.read().contains(&tag_id); + let badge_class = if is_selected { "tag-badge selected" } else { "tag-badge" }; + rsx! { + span { + class: "{badge_class}", + onclick: { + let tag_id = tag_id.clone(); + let mut selected_tags = selected_tags; + move |_| { + let mut current = selected_tags.read().clone(); + if let Some(pos) = current.iter().position(|t| t == &tag_id) { + current.remove(pos); + } else { + current.push(tag_id.clone()); + } + selected_tags.set(current); + } + }, + "{tag_name}" + } + } + } + } + } + } + } - div { class: "form-group", - label { class: "form-label", "Create New Tags" } - input { - r#type: "text", - placeholder: "tag1, tag2, tag3...", - value: "{new_tags_input}", - oninput: move |e| new_tags_input.set(e.value()), - } - p { class: "text-muted text-sm", - "Comma-separated. Will be created if they don't exist." - } - } + div { class: "form-group", + label { class: "form-label", "Create New Tags" } + input { + r#type: "text", + placeholder: "tag1, tag2, tag3...", + value: "{new_tags_input}", + oninput: move |e| new_tags_input.set(e.value()), + } + p { class: "text-muted text-sm", + "Comma-separated. Will be created if they don't exist." + } + } - div { class: "form-group", - label { class: "form-label", "Add to Collection" } - select { - value: "{selected_collection.read().clone().unwrap_or_default()}", - onchange: { - let mut selected_collection = selected_collection; - move |e: Event| { - let val = e.value(); - if val.is_empty() { - selected_collection.set(None); - } else { - selected_collection.set(Some(val)); - } - } - }, - option { value: "", "None" } - for col in collections.iter() { - { - let col_id = col.id.clone(); - let col_name = col.name.clone(); - rsx! { - option { value: "{col_id}", "{col_name}" } - } - } - } - } - } - } - } + div { class: "form-group", + label { class: "form-label", "Add to Collection" } + select { + value: "{selected_collection.read().clone().unwrap_or_default()}", + onchange: { + let mut selected_collection = selected_collection; + move |e: Event| { + let val = e.value(); + if val.is_empty() { + selected_collection.set(None); + } else { + selected_collection.set(Some(val)); + } + } + }, + option { value: "", "None" } + for col in collections.iter() { + { + let col_id = col.id.clone(); + let col_name = col.name.clone(); + rsx! { + option { value: "{col_id}", "{col_name}" } + } + } + } + } + } + } + } } fn parse_new_tags(input: &str) -> Vec { - input - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect() + input + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() } diff --git a/crates/pinakes-ui/src/components/library.rs b/crates/pinakes-ui/src/components/library.rs index ed84862..b447bb1 100644 --- a/crates/pinakes-ui/src/components/library.rs +++ b/crates/pinakes-ui/src/components/library.rs @@ -1,865 +1,871 @@ use dioxus::prelude::*; -use super::pagination::Pagination as PaginationControls; -use super::utils::{format_size, media_category, type_badge_class, type_icon}; +use super::{ + pagination::Pagination as PaginationControls, + utils::{format_size, media_category, type_badge_class, type_icon}, +}; use crate::client::{CollectionResponse, MediaResponse, TagResponse}; #[derive(Debug, Clone, Copy, PartialEq)] pub enum ViewMode { - Grid, - Table, + Grid, + Table, } /// The set of type filter categories available to the user. -const TYPE_FILTERS: &[&str] = &["all", "audio", "video", "image", "document", "text"]; +const TYPE_FILTERS: &[&str] = + &["all", "audio", "video", "image", "document", "text"]; /// Human-readable label for a type filter value. fn filter_label(f: &str) -> &str { - match f { - "all" => "All", - "audio" => "Audio", - "video" => "Video", - "image" => "Image", - "document" => "Document", - "text" => "Text", - _ => f, - } + match f { + "all" => "All", + "audio" => "Audio", + "video" => "Video", + "image" => "Image", + "document" => "Document", + "text" => "Text", + _ => f, + } } /// Parse the current sort field string into (column, direction) so table /// headers can show the correct arrow indicator. fn parse_sort(sort: &str) -> (&str, &str) { - if let Some(col) = sort.strip_suffix("_asc") { - (col, "asc") - } else if let Some(col) = sort.strip_suffix("_desc") { - (col, "desc") - } else { - (sort, "asc") - } + if let Some(col) = sort.strip_suffix("_asc") { + (col, "asc") + } else if let Some(col) = sort.strip_suffix("_desc") { + (col, "desc") + } else { + (sort, "asc") + } } /// Return the sort arrow indicator for a table column header. Returns an empty /// string when the column is not the active sort column. fn sort_arrow(current_sort: &str, column: &str) -> &'static str { - let (col, dir) = parse_sort(current_sort); - if col == column { - if dir == "asc" { - " \u{25b2}" - } else { - " \u{25bc}" - } + let (col, dir) = parse_sort(current_sort); + if col == column { + if dir == "asc" { + " \u{25b2}" } else { - "" + " \u{25bc}" } + } else { + "" + } } /// Compute the next sort value when a table column header is clicked. If the /// column is already sorted ascending, flip to descending and vice-versa. /// Otherwise default to ascending. fn next_sort(current_sort: &str, column: &str) -> String { - let (col, dir) = parse_sort(current_sort); - if col == column { - let new_dir = if dir == "asc" { "desc" } else { "asc" }; - format!("{column}_{new_dir}") - } else { - format!("{column}_asc") - } + let (col, dir) = parse_sort(current_sort); + if col == column { + let new_dir = if dir == "asc" { "desc" } else { "asc" }; + format!("{column}_{new_dir}") + } else { + format!("{column}_asc") + } } #[component] pub fn Library( - media: Vec, - tags: Vec, - collections: Vec, - total_count: u64, - current_page: u64, - page_size: u64, - server_url: String, - on_select: EventHandler, - on_delete: EventHandler, - on_batch_delete: EventHandler>, - on_batch_tag: EventHandler<(Vec, Vec)>, - on_batch_collection: EventHandler<(Vec, String)>, - on_page_change: EventHandler, - on_page_size_change: EventHandler, - on_sort_change: EventHandler, - #[props(default)] on_select_all_global: Option>>>, - #[props(default)] on_delete_all: Option>, + media: Vec, + tags: Vec, + collections: Vec, + total_count: u64, + current_page: u64, + page_size: u64, + server_url: String, + on_select: EventHandler, + on_delete: EventHandler, + on_batch_delete: EventHandler>, + on_batch_tag: EventHandler<(Vec, Vec)>, + on_batch_collection: EventHandler<(Vec, String)>, + on_page_change: EventHandler, + on_page_size_change: EventHandler, + on_sort_change: EventHandler, + #[props(default)] on_select_all_global: Option< + EventHandler>>, + >, + #[props(default)] on_delete_all: Option>, ) -> Element { - let mut selected_ids = use_signal(Vec::::new); - let mut select_all = use_signal(|| false); - let mut confirm_delete = use_signal(|| Option::::None); - let mut confirm_batch_delete = use_signal(|| false); - let mut confirm_delete_all = use_signal(|| false); - let mut show_batch_tag = use_signal(|| false); - let mut batch_tag_selection = use_signal(Vec::::new); - let mut show_batch_collection = use_signal(|| false); - let mut batch_collection_id = use_signal(String::new); - let mut view_mode = use_signal(|| ViewMode::Grid); - let mut sort_field = use_signal(|| "created_at_desc".to_string()); - let mut type_filter = use_signal(|| "all".to_string()); - // Track the last-clicked index for shift+click range selection. - let mut last_click_index = use_signal(|| Option::::None); - // True when all items across all pages have been selected. - let mut global_all_selected = use_signal(|| false); + let mut selected_ids = use_signal(Vec::::new); + let mut select_all = use_signal(|| false); + let mut confirm_delete = use_signal(|| Option::::None); + let mut confirm_batch_delete = use_signal(|| false); + let mut confirm_delete_all = use_signal(|| false); + let mut show_batch_tag = use_signal(|| false); + let mut batch_tag_selection = use_signal(Vec::::new); + let mut show_batch_collection = use_signal(|| false); + let mut batch_collection_id = use_signal(String::new); + let mut view_mode = use_signal(|| ViewMode::Grid); + let mut sort_field = use_signal(|| "created_at_desc".to_string()); + let mut type_filter = use_signal(|| "all".to_string()); + // Track the last-clicked index for shift+click range selection. + let mut last_click_index = use_signal(|| Option::::None); + // True when all items across all pages have been selected. + let mut global_all_selected = use_signal(|| false); - if media.is_empty() && total_count == 0 { - return rsx! { - div { class: "empty-state", - h3 { class: "empty-title", "No media found" } - p { class: "empty-subtitle", - "Import files or scan your root directories to get started." - } - } - }; - } - - // Apply client-side type filter. - let active_filter = type_filter.read().clone(); - let filtered_media: Vec = if active_filter == "all" { - media.clone() - } else { - media - .iter() - .filter(|m| media_category(&m.media_type) == active_filter.as_str()) - .cloned() - .collect() - }; - let filtered_count = filtered_media.len(); - - let all_ids: Vec = filtered_media.iter().map(|m| m.id.clone()).collect(); - // Read selection once to avoid repeated signal reads in loops - let current_selection: Vec = selected_ids.read().clone(); - let selection_count = current_selection.len(); - let has_selection = selection_count > 0; - let total_pages = total_count.div_ceil(page_size); - - let toggle_select_all = { - let all_ids = all_ids.clone(); - move |_| { - let new_val = !*select_all.read(); - select_all.set(new_val); - global_all_selected.set(false); - if new_val { - selected_ids.set(all_ids.clone()); - } else { - selected_ids.set(Vec::new()); + if media.is_empty() && total_count == 0 { + return rsx! { + div { class: "empty-state", + h3 { class: "empty-title", "No media found" } + p { class: "empty-subtitle", + "Import files or scan your root directories to get started." } } }; + } - let is_all_selected = *select_all.read(); - let current_mode = *view_mode.read(); - let current_sort = sort_field.read().clone(); + // Apply client-side type filter. + let active_filter = type_filter.read().clone(); + let filtered_media: Vec = if active_filter == "all" { + media.clone() + } else { + media + .iter() + .filter(|m| media_category(&m.media_type) == active_filter.as_str()) + .cloned() + .collect() + }; + let filtered_count = filtered_media.len(); - rsx! { - // Confirmation dialog for single delete - if confirm_delete.read().is_some() { - div { - class: "modal-overlay", - onclick: move |_| confirm_delete.set(None), - div { - class: "modal", - onclick: move |e: Event| e.stop_propagation(), - h3 { class: "modal-title", "Confirm Delete" } - p { class: "modal-body", - "Are you sure you want to delete this media item? This cannot be undone." - } - div { class: "modal-actions", - button { - class: "btn btn-ghost", - onclick: move |_| confirm_delete.set(None), - "Cancel" - } - button { - class: "btn btn-danger", - onclick: move |_| { - if let Some(id) = confirm_delete.read().clone() { - on_delete.call(id); - } - confirm_delete.set(None); - }, - "Delete" - } - } - } - } - } + let all_ids: Vec = + filtered_media.iter().map(|m| m.id.clone()).collect(); + // Read selection once to avoid repeated signal reads in loops + let current_selection: Vec = selected_ids.read().clone(); + let selection_count = current_selection.len(); + let has_selection = selection_count > 0; + let total_pages = total_count.div_ceil(page_size); - // Confirmation dialog for batch delete - if *confirm_batch_delete.read() { - div { - class: "modal-overlay", - onclick: move |_| confirm_batch_delete.set(false), - div { - class: "modal", - onclick: move |e: Event| e.stop_propagation(), - h3 { class: "modal-title", "Confirm Batch Delete" } - p { class: "modal-body", - "Are you sure you want to delete {selection_count} selected items? This cannot be undone." - } - div { class: "modal-actions", - button { - class: "btn btn-ghost", - onclick: move |_| confirm_batch_delete.set(false), - "Cancel" - } - button { - class: "btn btn-danger", - onclick: move |_| { - let ids = selected_ids.read().clone(); - on_batch_delete.call(ids); - selected_ids.set(Vec::new()); - select_all.set(false); - confirm_batch_delete.set(false); - }, - "Delete All" - } - } - } - } - } - - // Confirmation dialog for delete all - if *confirm_delete_all.read() { - div { - class: "modal-overlay", - onclick: move |_| confirm_delete_all.set(false), - div { - class: "modal", - onclick: move |e: Event| e.stop_propagation(), - h3 { class: "modal-title", "Delete All Media" } - p { class: "modal-body", - "Are you sure you want to delete ALL {total_count} items? This cannot be undone." - } - div { class: "modal-actions", - button { - class: "btn btn-ghost", - onclick: move |_| confirm_delete_all.set(false), - "Cancel" - } - button { - class: "btn btn-danger", - onclick: move |_| { - if let Some(handler) = on_delete_all { - handler.call(()); - } - selected_ids.set(Vec::new()); - select_all.set(false); - global_all_selected.set(false); - confirm_delete_all.set(false); - }, - "Delete Everything" - } - } - } - } - } - - // Batch tag dialog - if *show_batch_tag.read() { - div { - class: "modal-overlay", - onclick: move |_| { - show_batch_tag.set(false); - batch_tag_selection.set(Vec::new()); - }, - div { - class: "modal", - onclick: move |e: Event| e.stop_propagation(), - h3 { class: "modal-title", "Tag Selected Items" } - p { class: "modal-body text-muted text-sm", - "Select tags to apply to {selection_count} items:" - } - if tags.is_empty() { - p { class: "text-muted", "No tags available. Create tags first." } - } else { - div { class: "tag-list", style: "margin: 12px 0;", - for tag in tags.iter() { - { - let tag_id = tag.id.clone(); - let tag_name = tag.name.clone(); - let is_selected = batch_tag_selection.read().contains(&tag_id); - let badge_class = if is_selected { "tag-badge selected" } else { "tag-badge" }; - rsx! { - span { - class: "{badge_class}", - onclick: { - let tag_id = tag_id.clone(); - move |_| { - let mut current = batch_tag_selection.read().clone(); - if let Some(pos) = current.iter().position(|t| t == &tag_id) { - current.remove(pos); - } else { - current.push(tag_id.clone()); - } - batch_tag_selection.set(current); - } - }, - "{tag_name}" - } - } - } - } - } - } - div { class: "modal-actions", - button { - class: "btn btn-ghost", - onclick: move |_| { - show_batch_tag.set(false); - batch_tag_selection.set(Vec::new()); - }, - "Cancel" - } - button { - class: "btn btn-primary", - onclick: move |_| { - let ids = selected_ids.read().clone(); - let tag_ids = batch_tag_selection.read().clone(); - if !tag_ids.is_empty() { - on_batch_tag.call((ids, tag_ids)); - selected_ids.set(Vec::new()); - select_all.set(false); - } - show_batch_tag.set(false); - batch_tag_selection.set(Vec::new()); - }, - "Apply Tags" - } - } - } - } - } - - // Batch collection dialog - if *show_batch_collection.read() { - div { - class: "modal-overlay", - onclick: move |_| { - show_batch_collection.set(false); - batch_collection_id.set(String::new()); - }, - div { - class: "modal", - onclick: move |e: Event| e.stop_propagation(), - h3 { class: "modal-title", "Add to Collection" } - p { class: "modal-body text-muted text-sm", - "Choose a collection for {selection_count} items:" - } - if collections.is_empty() { - p { class: "text-muted", "No collections available. Create one first." } - } else { - select { - style: "width: 100%; margin: 12px 0;", - value: "{batch_collection_id}", - onchange: move |e: Event| batch_collection_id.set(e.value()), - option { value: "", "Select a collection..." } - for col in collections.iter() { - option { key: "{col.id}", value: "{col.id}", "{col.name}" } - } - } - } - div { class: "modal-actions", - button { - class: "btn btn-ghost", - onclick: move |_| { - show_batch_collection.set(false); - batch_collection_id.set(String::new()); - }, - "Cancel" - } - button { - class: "btn btn-primary", - onclick: move |_| { - let ids = selected_ids.read().clone(); - let col_id = batch_collection_id.read().clone(); - if !col_id.is_empty() { - on_batch_collection.call((ids, col_id)); - selected_ids.set(Vec::new()); - select_all.set(false); - } - show_batch_collection.set(false); - batch_collection_id.set(String::new()); - }, - "Add to Collection" - } - } - } - } - } - - // Toolbar: view toggle, sort, batch actions - div { class: "library-toolbar", - div { class: "toolbar-left", - // View mode toggle - div { class: "view-toggle", - button { - class: if current_mode == ViewMode::Grid { "view-btn active" } else { "view-btn" }, - onclick: move |_| view_mode.set(ViewMode::Grid), - title: "Grid view", - "\u{25a6}" - } - button { - class: if current_mode == ViewMode::Table { "view-btn active" } else { "view-btn" }, - onclick: move |_| view_mode.set(ViewMode::Table), - title: "Table view", - "\u{2630}" - } - } - - // Sort selector - div { class: "sort-control", - select { - value: "{sort_field}", - onchange: move |e: Event| { - let val = e.value(); - sort_field.set(val.clone()); - on_sort_change.call(val); - }, - option { value: "created_at_desc", "Newest first" } - option { value: "created_at_asc", "Oldest first" } - option { value: "file_name_asc", "Name A-Z" } - option { value: "file_name_desc", "Name Z-A" } - option { value: "file_size_desc", "Largest first" } - option { value: "file_size_asc", "Smallest first" } - option { value: "media_type_asc", "Type" } - } - } - - // Page size - div { class: "page-size-control", - span { class: "text-muted text-sm", "Show:" } - select { - value: "{page_size}", - onchange: move |e: Event| { - if let Ok(size) = e.value().parse::() { - on_page_size_change.call(size); - } - }, - option { value: "24", "24" } - option { value: "48", "48" } - option { value: "96", "96" } - option { value: "200", "200" } - } - } - } - - div { class: "toolbar-right", - // Select All / Deselect All toggle (works in both grid and table) - { - let all_ids2 = all_ids.clone(); - rsx! { - button { - class: "btn btn-sm btn-ghost", - onclick: move |_| { - if is_all_selected { - selected_ids.set(Vec::new()); - select_all.set(false); - global_all_selected.set(false); - } else { - selected_ids.set(all_ids2.clone()); - select_all.set(true); - } - }, - if is_all_selected { - "Deselect All" - } else { - "Select All" - } - } - } - } - - if has_selection { - div { class: "batch-actions", - span { "{selection_count} selected" } - button { - class: "btn btn-sm btn-secondary", - onclick: move |_| show_batch_tag.set(true), - "Tag" - } - button { - class: "btn btn-sm btn-secondary", - onclick: move |_| show_batch_collection.set(true), - "Collection" - } - button { - class: "btn btn-sm btn-danger", - onclick: move |_| confirm_batch_delete.set(true), - "Delete" - } - button { - class: "btn btn-sm btn-ghost", - onclick: move |_| { - selected_ids.set(Vec::new()); - select_all.set(false); - global_all_selected.set(false); - }, - "Clear" - } - } - } - if on_delete_all.is_some() && total_count > 0 { - button { - class: "btn btn-sm btn-danger", - onclick: move |_| confirm_delete_all.set(true), - "Delete All" - } - } - span { class: "text-muted text-sm", "{total_count} items" } - } - } - - // Type filter chips - div { class: "type-filter-row", - for filter in TYPE_FILTERS.iter() { - { - let f = (*filter).to_string(); - let is_active = active_filter == f; - let chip_class = if is_active { "filter-chip active" } else { "filter-chip" }; - let label = filter_label(filter); - rsx! { - button { - key: "{f}", - class: "{chip_class}", - onclick: { - let f = f.clone(); - move |_| { - type_filter.set(f.clone()); - } - }, - "{label}" - } - } - } - } - } - - // Stats summary row - div { class: "library-stats", - span { class: "text-muted text-sm", - if active_filter != "all" { - "Showing {filtered_count} of {total_count} items (filtered: {active_filter})" - } else { - "Showing {filtered_count} items" - } - } - span { class: "text-muted text-sm", "Page {current_page + 1} of {total_pages}" } - } - - // Select-all banner: when all items on this page are selected and there - // are more pages, offer to select everything across all pages. - if is_all_selected && total_count > page_size && !*global_all_selected.read() { - div { class: "select-all-banner", - "All {filtered_count} items on this page are selected." - if on_select_all_global.is_some() { - button { - onclick: move |_| { - if let Some(handler) = on_select_all_global { - handler - .call( - EventHandler::new(move |all_ids: Vec| { - selected_ids.set(all_ids); - global_all_selected.set(true); - }), - ); - } - }, - "Select all {total_count} items" - } - } - } - } - if *global_all_selected.read() { - div { class: "select-all-banner", - "All {selection_count} items across all pages are selected." - button { - onclick: move |_| { - selected_ids.set(Vec::new()); - select_all.set(false); - global_all_selected.set(false); - }, - "Clear selection" - } - } - } - - // Content: grid or table - match current_mode { - ViewMode::Grid => rsx! { - div { class: "media-grid", - for (idx , item) in filtered_media.iter().enumerate() { - { - let id = item.id.clone(); - let badge_class = type_badge_class(&item.media_type); - let is_checked = current_selection.contains(&id); - - // Build a list of all visible IDs for shift+click range selection. - - // Shift+click: select range from last_click_index to current idx. - // No previous click, just toggle this one. - - // Thumbnail with CSS fallback: both the icon and img - // are rendered. The img is absolutely positioned on - // top. If the image fails to load, the icon beneath - // shows through. - - // Thumbnail with CSS fallback: icon always - // rendered, img overlays when available. - - - - - - - - - let card_click = { - let id = item.id.clone(); - move |_| on_select.call(id.clone()) - }; - - let visible_ids: Vec = filtered_media - - .iter() - .map(|m| m.id.clone()) - .collect(); - let toggle_id = { - let id = id.clone(); - move |e: Event| { - e.stop_propagation(); - let shift = e.modifiers().shift(); - let mut ids = selected_ids.read().clone(); - if shift { - if let Some(last) = *last_click_index.read() { - let start = last.min(idx); - let end = last.max(idx); - for i in start..=end { - if let Some(range_id) = visible_ids.get(i) - && !ids.contains(range_id) - { - ids.push(range_id.clone()); - } - } - } else { - if !ids.contains(&id) { - ids.push(id.clone()); - } - } - } else if ids.contains(&id) { - ids.retain(|x| x != &id); - } else { - ids.push(id.clone()); - } - last_click_index.set(Some(idx)); - selected_ids.set(ids); - } - }; - let thumb_url = if item.has_thumbnail { - format!("{}/api/v1/media/{}/thumbnail", server_url, item.id) - } else { - String::new() - }; - let has_thumb = item.has_thumbnail; - let media_type = item.media_type.clone(); - let card_class = if is_checked { "media-card selected" } else { "media-card" }; - let title_text = item.title.clone().unwrap_or_default(); - let artist_text = item.artist.clone().unwrap_or_default(); - rsx! { - div { key: "{item.id}", class: "{card_class}", onclick: card_click, - - - - div { class: "card-checkbox", - input { r#type: "checkbox", checked: is_checked, onclick: toggle_id } - } - - div { class: "card-thumbnail", - div { class: "card-type-icon {badge_class}", "{type_icon(&media_type)}" } - if has_thumb { - img { - class: "card-thumb-img", - src: "{thumb_url}", - alt: "{item.file_name}", - loading: "lazy", - } - } - } - - div { class: "card-info", - div { class: "card-name", title: "{item.file_name}", "{item.file_name}" } - if !title_text.is_empty() { - div { class: "card-title text-muted text-xs", "{title_text}" } - } - if !artist_text.is_empty() { - div { class: "card-artist text-muted text-xs", "{artist_text}" } - } - div { class: "card-meta", - span { class: "type-badge {badge_class}", "{item.media_type}" } - span { class: "card-size", "{format_size(item.file_size)}" } - } - } - } - } - } - } - } - }, - ViewMode::Table => rsx! { - table { class: "data-table", - thead { - tr { - th { - input { - r#type: "checkbox", - checked: is_all_selected, - onclick: toggle_select_all, - } - } - th { "" } - th { - class: "sortable-header", - onclick: { - let cs = current_sort.clone(); - move |_| { - let val = next_sort(&cs, "file_name"); - sort_field.set(val.clone()); - on_sort_change.call(val); - } - }, - "Name{sort_arrow(¤t_sort, \"file_name\")}" - } - th { - class: "sortable-header", - onclick: { - let cs = current_sort.clone(); - move |_| { - let val = next_sort(&cs, "media_type"); - sort_field.set(val.clone()); - on_sort_change.call(val); - } - }, - "Type{sort_arrow(¤t_sort, \"media_type\")}" - } - th { "Artist" } - th { - class: "sortable-header", - onclick: { - let cs = current_sort.clone(); - move |_| { - let val = next_sort(&cs, "file_size"); - sort_field.set(val.clone()); - on_sort_change.call(val); - } - }, - "Size{sort_arrow(¤t_sort, \"file_size\")}" - } - th { "" } - } - } - tbody { - for (idx , item) in filtered_media.iter().enumerate() { - { - let id = item.id.clone(); - let artist = item.artist.clone().unwrap_or_default(); - let size = format_size(item.file_size); - let badge_class = type_badge_class(&item.media_type); - let is_checked = current_selection.contains(&id); - - let visible_ids: Vec = filtered_media - - .iter() - .map(|m| m.id.clone()) - .collect(); - let toggle_id = { - let id = id.clone(); - move |e: Event| { - e.stop_propagation(); - let shift = e.modifiers().shift(); - let mut ids = selected_ids.read().clone(); - if shift { - if let Some(last) = *last_click_index.read() { - let start = last.min(idx); - let end = last.max(idx); - for i in start..=end { - if let Some(range_id) = visible_ids.get(i) - && !ids.contains(range_id) - { - ids.push(range_id.clone()); - } - } - } else { - if !ids.contains(&id) { - ids.push(id.clone()); - } - } - } else if ids.contains(&id) { - ids.retain(|x| x != &id); - } else { - ids.push(id.clone()); - } - last_click_index.set(Some(idx)); - selected_ids.set(ids); - } - }; - let row_click = { - let id = item.id.clone(); - move |_| on_select.call(id.clone()) - }; - let delete_click = { - let id = item.id.clone(); - move |e: Event| { - e.stop_propagation(); - confirm_delete.set(Some(id.clone())); - } - }; - let thumb_url = if item.has_thumbnail { - format!("{}/api/v1/media/{}/thumbnail", server_url, item.id) - } else { - String::new() - }; - let has_thumb = item.has_thumbnail; - let media_type_str = item.media_type.clone(); - rsx! { - tr { key: "{item.id}", onclick: row_click, - td { - input { r#type: "checkbox", checked: is_checked, onclick: toggle_id } - } - td { class: "table-thumb-cell", - span { class: "table-type-icon {badge_class}", "{type_icon(&media_type_str)}" } - if has_thumb { - img { - class: "table-thumb table-thumb-overlay", - src: "{thumb_url}", - alt: "", - loading: "lazy", - } - } - } - td { "{item.file_name}" } - td { - span { class: "type-badge {badge_class}", "{item.media_type}" } - } - td { "{artist}" } - td { "{size}" } - td { - button { class: "btn btn-danger btn-sm", onclick: delete_click, "Delete" } - } - } - } - } - } - } - } - }, - } - - // Pagination controls - PaginationControls { - current_page, - total_pages, - on_page_change: move |page: u64| on_page_change.call(page), - } + let toggle_select_all = { + let all_ids = all_ids.clone(); + move |_| { + let new_val = !*select_all.read(); + select_all.set(new_val); + global_all_selected.set(false); + if new_val { + selected_ids.set(all_ids.clone()); + } else { + selected_ids.set(Vec::new()); + } } + }; + + let is_all_selected = *select_all.read(); + let current_mode = *view_mode.read(); + let current_sort = sort_field.read().clone(); + + rsx! { + // Confirmation dialog for single delete + if confirm_delete.read().is_some() { + div { + class: "modal-overlay", + onclick: move |_| confirm_delete.set(None), + div { + class: "modal", + onclick: move |e: Event| e.stop_propagation(), + h3 { class: "modal-title", "Confirm Delete" } + p { class: "modal-body", + "Are you sure you want to delete this media item? This cannot be undone." + } + div { class: "modal-actions", + button { + class: "btn btn-ghost", + onclick: move |_| confirm_delete.set(None), + "Cancel" + } + button { + class: "btn btn-danger", + onclick: move |_| { + if let Some(id) = confirm_delete.read().clone() { + on_delete.call(id); + } + confirm_delete.set(None); + }, + "Delete" + } + } + } + } + } + + // Confirmation dialog for batch delete + if *confirm_batch_delete.read() { + div { + class: "modal-overlay", + onclick: move |_| confirm_batch_delete.set(false), + div { + class: "modal", + onclick: move |e: Event| e.stop_propagation(), + h3 { class: "modal-title", "Confirm Batch Delete" } + p { class: "modal-body", + "Are you sure you want to delete {selection_count} selected items? This cannot be undone." + } + div { class: "modal-actions", + button { + class: "btn btn-ghost", + onclick: move |_| confirm_batch_delete.set(false), + "Cancel" + } + button { + class: "btn btn-danger", + onclick: move |_| { + let ids = selected_ids.read().clone(); + on_batch_delete.call(ids); + selected_ids.set(Vec::new()); + select_all.set(false); + confirm_batch_delete.set(false); + }, + "Delete All" + } + } + } + } + } + + // Confirmation dialog for delete all + if *confirm_delete_all.read() { + div { + class: "modal-overlay", + onclick: move |_| confirm_delete_all.set(false), + div { + class: "modal", + onclick: move |e: Event| e.stop_propagation(), + h3 { class: "modal-title", "Delete All Media" } + p { class: "modal-body", + "Are you sure you want to delete ALL {total_count} items? This cannot be undone." + } + div { class: "modal-actions", + button { + class: "btn btn-ghost", + onclick: move |_| confirm_delete_all.set(false), + "Cancel" + } + button { + class: "btn btn-danger", + onclick: move |_| { + if let Some(handler) = on_delete_all { + handler.call(()); + } + selected_ids.set(Vec::new()); + select_all.set(false); + global_all_selected.set(false); + confirm_delete_all.set(false); + }, + "Delete Everything" + } + } + } + } + } + + // Batch tag dialog + if *show_batch_tag.read() { + div { + class: "modal-overlay", + onclick: move |_| { + show_batch_tag.set(false); + batch_tag_selection.set(Vec::new()); + }, + div { + class: "modal", + onclick: move |e: Event| e.stop_propagation(), + h3 { class: "modal-title", "Tag Selected Items" } + p { class: "modal-body text-muted text-sm", + "Select tags to apply to {selection_count} items:" + } + if tags.is_empty() { + p { class: "text-muted", "No tags available. Create tags first." } + } else { + div { class: "tag-list", style: "margin: 12px 0;", + for tag in tags.iter() { + { + let tag_id = tag.id.clone(); + let tag_name = tag.name.clone(); + let is_selected = batch_tag_selection.read().contains(&tag_id); + let badge_class = if is_selected { "tag-badge selected" } else { "tag-badge" }; + rsx! { + span { + class: "{badge_class}", + onclick: { + let tag_id = tag_id.clone(); + move |_| { + let mut current = batch_tag_selection.read().clone(); + if let Some(pos) = current.iter().position(|t| t == &tag_id) { + current.remove(pos); + } else { + current.push(tag_id.clone()); + } + batch_tag_selection.set(current); + } + }, + "{tag_name}" + } + } + } + } + } + } + div { class: "modal-actions", + button { + class: "btn btn-ghost", + onclick: move |_| { + show_batch_tag.set(false); + batch_tag_selection.set(Vec::new()); + }, + "Cancel" + } + button { + class: "btn btn-primary", + onclick: move |_| { + let ids = selected_ids.read().clone(); + let tag_ids = batch_tag_selection.read().clone(); + if !tag_ids.is_empty() { + on_batch_tag.call((ids, tag_ids)); + selected_ids.set(Vec::new()); + select_all.set(false); + } + show_batch_tag.set(false); + batch_tag_selection.set(Vec::new()); + }, + "Apply Tags" + } + } + } + } + } + + // Batch collection dialog + if *show_batch_collection.read() { + div { + class: "modal-overlay", + onclick: move |_| { + show_batch_collection.set(false); + batch_collection_id.set(String::new()); + }, + div { + class: "modal", + onclick: move |e: Event| e.stop_propagation(), + h3 { class: "modal-title", "Add to Collection" } + p { class: "modal-body text-muted text-sm", + "Choose a collection for {selection_count} items:" + } + if collections.is_empty() { + p { class: "text-muted", "No collections available. Create one first." } + } else { + select { + style: "width: 100%; margin: 12px 0;", + value: "{batch_collection_id}", + onchange: move |e: Event| batch_collection_id.set(e.value()), + option { value: "", "Select a collection..." } + for col in collections.iter() { + option { key: "{col.id}", value: "{col.id}", "{col.name}" } + } + } + } + div { class: "modal-actions", + button { + class: "btn btn-ghost", + onclick: move |_| { + show_batch_collection.set(false); + batch_collection_id.set(String::new()); + }, + "Cancel" + } + button { + class: "btn btn-primary", + onclick: move |_| { + let ids = selected_ids.read().clone(); + let col_id = batch_collection_id.read().clone(); + if !col_id.is_empty() { + on_batch_collection.call((ids, col_id)); + selected_ids.set(Vec::new()); + select_all.set(false); + } + show_batch_collection.set(false); + batch_collection_id.set(String::new()); + }, + "Add to Collection" + } + } + } + } + } + + // Toolbar: view toggle, sort, batch actions + div { class: "library-toolbar", + div { class: "toolbar-left", + // View mode toggle + div { class: "view-toggle", + button { + class: if current_mode == ViewMode::Grid { "view-btn active" } else { "view-btn" }, + onclick: move |_| view_mode.set(ViewMode::Grid), + title: "Grid view", + "\u{25a6}" + } + button { + class: if current_mode == ViewMode::Table { "view-btn active" } else { "view-btn" }, + onclick: move |_| view_mode.set(ViewMode::Table), + title: "Table view", + "\u{2630}" + } + } + + // Sort selector + div { class: "sort-control", + select { + value: "{sort_field}", + onchange: move |e: Event| { + let val = e.value(); + sort_field.set(val.clone()); + on_sort_change.call(val); + }, + option { value: "created_at_desc", "Newest first" } + option { value: "created_at_asc", "Oldest first" } + option { value: "file_name_asc", "Name A-Z" } + option { value: "file_name_desc", "Name Z-A" } + option { value: "file_size_desc", "Largest first" } + option { value: "file_size_asc", "Smallest first" } + option { value: "media_type_asc", "Type" } + } + } + + // Page size + div { class: "page-size-control", + span { class: "text-muted text-sm", "Show:" } + select { + value: "{page_size}", + onchange: move |e: Event| { + if let Ok(size) = e.value().parse::() { + on_page_size_change.call(size); + } + }, + option { value: "24", "24" } + option { value: "48", "48" } + option { value: "96", "96" } + option { value: "200", "200" } + } + } + } + + div { class: "toolbar-right", + // Select All / Deselect All toggle (works in both grid and table) + { + let all_ids2 = all_ids.clone(); + rsx! { + button { + class: "btn btn-sm btn-ghost", + onclick: move |_| { + if is_all_selected { + selected_ids.set(Vec::new()); + select_all.set(false); + global_all_selected.set(false); + } else { + selected_ids.set(all_ids2.clone()); + select_all.set(true); + } + }, + if is_all_selected { + "Deselect All" + } else { + "Select All" + } + } + } + } + + if has_selection { + div { class: "batch-actions", + span { "{selection_count} selected" } + button { + class: "btn btn-sm btn-secondary", + onclick: move |_| show_batch_tag.set(true), + "Tag" + } + button { + class: "btn btn-sm btn-secondary", + onclick: move |_| show_batch_collection.set(true), + "Collection" + } + button { + class: "btn btn-sm btn-danger", + onclick: move |_| confirm_batch_delete.set(true), + "Delete" + } + button { + class: "btn btn-sm btn-ghost", + onclick: move |_| { + selected_ids.set(Vec::new()); + select_all.set(false); + global_all_selected.set(false); + }, + "Clear" + } + } + } + if on_delete_all.is_some() && total_count > 0 { + button { + class: "btn btn-sm btn-danger", + onclick: move |_| confirm_delete_all.set(true), + "Delete All" + } + } + span { class: "text-muted text-sm", "{total_count} items" } + } + } + + // Type filter chips + div { class: "type-filter-row", + for filter in TYPE_FILTERS.iter() { + { + let f = (*filter).to_string(); + let is_active = active_filter == f; + let chip_class = if is_active { "filter-chip active" } else { "filter-chip" }; + let label = filter_label(filter); + rsx! { + button { + key: "{f}", + class: "{chip_class}", + onclick: { + let f = f.clone(); + move |_| { + type_filter.set(f.clone()); + } + }, + "{label}" + } + } + } + } + } + + // Stats summary row + div { class: "library-stats", + span { class: "text-muted text-sm", + if active_filter != "all" { + "Showing {filtered_count} of {total_count} items (filtered: {active_filter})" + } else { + "Showing {filtered_count} items" + } + } + span { class: "text-muted text-sm", "Page {current_page + 1} of {total_pages}" } + } + + // Select-all banner: when all items on this page are selected and there + // are more pages, offer to select everything across all pages. + if is_all_selected && total_count > page_size && !*global_all_selected.read() { + div { class: "select-all-banner", + "All {filtered_count} items on this page are selected." + if on_select_all_global.is_some() { + button { + onclick: move |_| { + if let Some(handler) = on_select_all_global { + handler + .call( + EventHandler::new(move |all_ids: Vec| { + selected_ids.set(all_ids); + global_all_selected.set(true); + }), + ); + } + }, + "Select all {total_count} items" + } + } + } + } + if *global_all_selected.read() { + div { class: "select-all-banner", + "All {selection_count} items across all pages are selected." + button { + onclick: move |_| { + selected_ids.set(Vec::new()); + select_all.set(false); + global_all_selected.set(false); + }, + "Clear selection" + } + } + } + + // Content: grid or table + match current_mode { + ViewMode::Grid => rsx! { + div { class: "media-grid", + for (idx , item) in filtered_media.iter().enumerate() { + { + let id = item.id.clone(); + let badge_class = type_badge_class(&item.media_type); + let is_checked = current_selection.contains(&id); + + // Build a list of all visible IDs for shift+click range selection. + + // Shift+click: select range from last_click_index to current idx. + // No previous click, just toggle this one. + + // Thumbnail with CSS fallback: both the icon and img + // are rendered. The img is absolutely positioned on + // top. If the image fails to load, the icon beneath + // shows through. + + // Thumbnail with CSS fallback: icon always + // rendered, img overlays when available. + + + + + + + + + let card_click = { + let id = item.id.clone(); + move |_| on_select.call(id.clone()) + }; + + let visible_ids: Vec = filtered_media + + .iter() + .map(|m| m.id.clone()) + .collect(); + let toggle_id = { + let id = id.clone(); + move |e: Event| { + e.stop_propagation(); + let shift = e.modifiers().shift(); + let mut ids = selected_ids.read().clone(); + if shift { + if let Some(last) = *last_click_index.read() { + let start = last.min(idx); + let end = last.max(idx); + for i in start..=end { + if let Some(range_id) = visible_ids.get(i) + && !ids.contains(range_id) + { + ids.push(range_id.clone()); + } + } + } else { + if !ids.contains(&id) { + ids.push(id.clone()); + } + } + } else if ids.contains(&id) { + ids.retain(|x| x != &id); + } else { + ids.push(id.clone()); + } + last_click_index.set(Some(idx)); + selected_ids.set(ids); + } + }; + let thumb_url = if item.has_thumbnail { + format!("{}/api/v1/media/{}/thumbnail", server_url, item.id) + } else { + String::new() + }; + let has_thumb = item.has_thumbnail; + let media_type = item.media_type.clone(); + let card_class = if is_checked { "media-card selected" } else { "media-card" }; + let title_text = item.title.clone().unwrap_or_default(); + let artist_text = item.artist.clone().unwrap_or_default(); + rsx! { + div { key: "{item.id}", class: "{card_class}", onclick: card_click, + + + + div { class: "card-checkbox", + input { r#type: "checkbox", checked: is_checked, onclick: toggle_id } + } + + div { class: "card-thumbnail", + div { class: "card-type-icon {badge_class}", "{type_icon(&media_type)}" } + if has_thumb { + img { + class: "card-thumb-img", + src: "{thumb_url}", + alt: "{item.file_name}", + loading: "lazy", + } + } + } + + div { class: "card-info", + div { class: "card-name", title: "{item.file_name}", "{item.file_name}" } + if !title_text.is_empty() { + div { class: "card-title text-muted text-xs", "{title_text}" } + } + if !artist_text.is_empty() { + div { class: "card-artist text-muted text-xs", "{artist_text}" } + } + div { class: "card-meta", + span { class: "type-badge {badge_class}", "{item.media_type}" } + span { class: "card-size", "{format_size(item.file_size)}" } + } + } + } + } + } + } + } + }, + ViewMode::Table => rsx! { + table { class: "data-table", + thead { + tr { + th { + input { + r#type: "checkbox", + checked: is_all_selected, + onclick: toggle_select_all, + } + } + th { "" } + th { + class: "sortable-header", + onclick: { + let cs = current_sort.clone(); + move |_| { + let val = next_sort(&cs, "file_name"); + sort_field.set(val.clone()); + on_sort_change.call(val); + } + }, + "Name{sort_arrow(¤t_sort, \"file_name\")}" + } + th { + class: "sortable-header", + onclick: { + let cs = current_sort.clone(); + move |_| { + let val = next_sort(&cs, "media_type"); + sort_field.set(val.clone()); + on_sort_change.call(val); + } + }, + "Type{sort_arrow(¤t_sort, \"media_type\")}" + } + th { "Artist" } + th { + class: "sortable-header", + onclick: { + let cs = current_sort.clone(); + move |_| { + let val = next_sort(&cs, "file_size"); + sort_field.set(val.clone()); + on_sort_change.call(val); + } + }, + "Size{sort_arrow(¤t_sort, \"file_size\")}" + } + th { "" } + } + } + tbody { + for (idx , item) in filtered_media.iter().enumerate() { + { + let id = item.id.clone(); + let artist = item.artist.clone().unwrap_or_default(); + let size = format_size(item.file_size); + let badge_class = type_badge_class(&item.media_type); + let is_checked = current_selection.contains(&id); + + let visible_ids: Vec = filtered_media + + .iter() + .map(|m| m.id.clone()) + .collect(); + let toggle_id = { + let id = id.clone(); + move |e: Event| { + e.stop_propagation(); + let shift = e.modifiers().shift(); + let mut ids = selected_ids.read().clone(); + if shift { + if let Some(last) = *last_click_index.read() { + let start = last.min(idx); + let end = last.max(idx); + for i in start..=end { + if let Some(range_id) = visible_ids.get(i) + && !ids.contains(range_id) + { + ids.push(range_id.clone()); + } + } + } else { + if !ids.contains(&id) { + ids.push(id.clone()); + } + } + } else if ids.contains(&id) { + ids.retain(|x| x != &id); + } else { + ids.push(id.clone()); + } + last_click_index.set(Some(idx)); + selected_ids.set(ids); + } + }; + let row_click = { + let id = item.id.clone(); + move |_| on_select.call(id.clone()) + }; + let delete_click = { + let id = item.id.clone(); + move |e: Event| { + e.stop_propagation(); + confirm_delete.set(Some(id.clone())); + } + }; + let thumb_url = if item.has_thumbnail { + format!("{}/api/v1/media/{}/thumbnail", server_url, item.id) + } else { + String::new() + }; + let has_thumb = item.has_thumbnail; + let media_type_str = item.media_type.clone(); + rsx! { + tr { key: "{item.id}", onclick: row_click, + td { + input { r#type: "checkbox", checked: is_checked, onclick: toggle_id } + } + td { class: "table-thumb-cell", + span { class: "table-type-icon {badge_class}", "{type_icon(&media_type_str)}" } + if has_thumb { + img { + class: "table-thumb table-thumb-overlay", + src: "{thumb_url}", + alt: "", + loading: "lazy", + } + } + } + td { "{item.file_name}" } + td { + span { class: "type-badge {badge_class}", "{item.media_type}" } + } + td { "{artist}" } + td { "{size}" } + td { + button { class: "btn btn-danger btn-sm", onclick: delete_click, "Delete" } + } + } + } + } + } + } + } + }, + } + + // Pagination controls + PaginationControls { + current_page, + total_pages, + on_page_change: move |page: u64| on_page_change.call(page), + } + } } diff --git a/crates/pinakes-ui/src/components/loading.rs b/crates/pinakes-ui/src/components/loading.rs index b92c723..c31cd84 100644 --- a/crates/pinakes-ui/src/components/loading.rs +++ b/crates/pinakes-ui/src/components/loading.rs @@ -2,58 +2,58 @@ use dioxus::prelude::*; #[component] pub fn SkeletonCard() -> Element { - rsx! { - div { class: "skeleton-card", - div { class: "skeleton-thumb skeleton-pulse" } - div { class: "skeleton-text skeleton-pulse" } - div { class: "skeleton-text skeleton-text-short skeleton-pulse" } - } - } + rsx! { + div { class: "skeleton-card", + div { class: "skeleton-thumb skeleton-pulse" } + div { class: "skeleton-text skeleton-pulse" } + div { class: "skeleton-text skeleton-text-short skeleton-pulse" } + } + } } #[component] pub fn SkeletonRow() -> Element { - rsx! { - div { class: "skeleton-row", - div { class: "skeleton-cell skeleton-cell-icon skeleton-pulse" } - div { class: "skeleton-cell skeleton-cell-wide skeleton-pulse" } - div { class: "skeleton-cell skeleton-pulse" } - div { class: "skeleton-cell skeleton-pulse" } - } - } + rsx! { + div { class: "skeleton-row", + div { class: "skeleton-cell skeleton-cell-icon skeleton-pulse" } + div { class: "skeleton-cell skeleton-cell-wide skeleton-pulse" } + div { class: "skeleton-cell skeleton-pulse" } + div { class: "skeleton-cell skeleton-pulse" } + } + } } #[component] pub fn LoadingOverlay(message: Option) -> Element { - let msg = message.unwrap_or_else(|| "Loading...".to_string()); - rsx! { - div { class: "loading-overlay", - div { class: "loading-spinner" } - span { class: "loading-message", "{msg}" } - } - } + let msg = message.unwrap_or_else(|| "Loading...".to_string()); + rsx! { + div { class: "loading-overlay", + div { class: "loading-spinner" } + span { class: "loading-message", "{msg}" } + } + } } #[component] pub fn SkeletonGrid(count: Option) -> Element { - let n = count.unwrap_or(12); - rsx! { - div { class: "media-grid", - for i in 0..n { - SkeletonCard { key: "skel-{i}" } - } - } - } + let n = count.unwrap_or(12); + rsx! { + div { class: "media-grid", + for i in 0..n { + SkeletonCard { key: "skel-{i}" } + } + } + } } #[component] pub fn SkeletonList(count: Option) -> Element { - let n = count.unwrap_or(10); - rsx! { - div { class: "media-list", - for i in 0..n { - SkeletonRow { key: "skel-row-{i}" } - } - } - } + let n = count.unwrap_or(10); + rsx! { + div { class: "media-list", + for i in 0..n { + SkeletonRow { key: "skel-row-{i}" } + } + } + } } diff --git a/crates/pinakes-ui/src/components/login.rs b/crates/pinakes-ui/src/components/login.rs index 170f8ca..d8dbee1 100644 --- a/crates/pinakes-ui/src/components/login.rs +++ b/crates/pinakes-ui/src/components/login.rs @@ -2,78 +2,78 @@ use dioxus::prelude::*; #[component] pub fn Login( - on_login: EventHandler<(String, String)>, - #[props(default)] error: Option, - #[props(default = false)] loading: bool, + on_login: EventHandler<(String, String)>, + #[props(default)] error: Option, + #[props(default = false)] loading: bool, ) -> Element { - let mut username = use_signal(String::new); - let mut password = use_signal(String::new); + let mut username = use_signal(String::new); + let mut password = use_signal(String::new); - let on_submit = { - move |_| { - let u = username.read().clone(); - let p = password.read().clone(); - if !u.is_empty() && !p.is_empty() { - on_login.call((u, p)); - } - } - }; - - let on_key = move |e: KeyboardEvent| { - if e.key() == Key::Enter { - let u = username.read().clone(); - let p = password.read().clone(); - if !u.is_empty() && !p.is_empty() { - on_login.call((u, p)); - } - } - }; - - rsx! { - div { class: "login-container", - div { class: "login-card", - h2 { class: "login-title", "Pinakes" } - p { class: "login-subtitle", "Sign in to continue" } - - if let Some(ref err) = error { - div { class: "login-error", "{err}" } - } - - div { class: "login-form", - div { class: "form-group", - label { class: "form-label", "Username" } - input { - r#type: "text", - placeholder: "Enter username", - value: "{username}", - disabled: loading, - oninput: move |e: Event| username.set(e.value()), - onkeypress: on_key, - } - } - div { class: "form-group", - label { class: "form-label", "Password" } - input { - r#type: "password", - placeholder: "Enter password", - value: "{password}", - disabled: loading, - oninput: move |e: Event| password.set(e.value()), - onkeypress: on_key, - } - } - button { - class: "btn btn-primary login-btn", - disabled: loading, - onclick: on_submit, - if loading { - "Signing in..." - } else { - "Sign In" - } - } - } - } - } + let on_submit = { + move |_| { + let u = username.read().clone(); + let p = password.read().clone(); + if !u.is_empty() && !p.is_empty() { + on_login.call((u, p)); + } } + }; + + let on_key = move |e: KeyboardEvent| { + if e.key() == Key::Enter { + let u = username.read().clone(); + let p = password.read().clone(); + if !u.is_empty() && !p.is_empty() { + on_login.call((u, p)); + } + } + }; + + rsx! { + div { class: "login-container", + div { class: "login-card", + h2 { class: "login-title", "Pinakes" } + p { class: "login-subtitle", "Sign in to continue" } + + if let Some(ref err) = error { + div { class: "login-error", "{err}" } + } + + div { class: "login-form", + div { class: "form-group", + label { class: "form-label", "Username" } + input { + r#type: "text", + placeholder: "Enter username", + value: "{username}", + disabled: loading, + oninput: move |e: Event| username.set(e.value()), + onkeypress: on_key, + } + } + div { class: "form-group", + label { class: "form-label", "Password" } + input { + r#type: "password", + placeholder: "Enter password", + value: "{password}", + disabled: loading, + oninput: move |e: Event| password.set(e.value()), + onkeypress: on_key, + } + } + button { + class: "btn btn-primary login-btn", + disabled: loading, + onclick: on_submit, + if loading { + "Signing in..." + } else { + "Sign In" + } + } + } + } + } + } } diff --git a/crates/pinakes-ui/src/components/markdown_viewer.rs b/crates/pinakes-ui/src/components/markdown_viewer.rs index 477d91e..b8d96a1 100644 --- a/crates/pinakes-ui/src/components/markdown_viewer.rs +++ b/crates/pinakes-ui/src/components/markdown_viewer.rs @@ -1,73 +1,75 @@ -use dioxus::document::eval; -use dioxus::prelude::*; +use dioxus::{document::eval, prelude::*}; /// Event handler for wikilink clicks. Called with the target note name. pub type WikilinkClickHandler = EventHandler; #[component] pub fn MarkdownViewer( - content_url: String, - media_type: String, - #[props(default)] on_wikilink_click: Option, + content_url: String, + media_type: String, + #[props(default)] on_wikilink_click: Option, ) -> Element { - let mut rendered_html = use_signal(String::new); - let mut frontmatter_html = use_signal(|| Option::::None); - let mut raw_content = use_signal(String::new); - let mut loading = use_signal(|| true); - let mut error = use_signal(|| Option::::None); - let mut show_preview = use_signal(|| true); + let mut rendered_html = use_signal(String::new); + let mut frontmatter_html = use_signal(|| Option::::None); + let mut raw_content = use_signal(String::new); + let mut loading = use_signal(|| true); + let mut error = use_signal(|| Option::::None); + let mut show_preview = use_signal(|| true); - // Fetch content on mount - let url = content_url.clone(); - let mtype = media_type.clone(); - use_effect(move || { - let url = url.clone(); - let mtype = mtype.clone(); - spawn(async move { - loading.set(true); - error.set(None); - match reqwest::get(&url).await { - Ok(resp) => match resp.text().await { - Ok(text) => { - raw_content.set(text.clone()); - if mtype == "md" || mtype == "markdown" { - let (fm_html, body_html) = render_markdown_with_frontmatter(&text); - frontmatter_html.set(fm_html); - rendered_html.set(body_html); - } else { - frontmatter_html.set(None); - rendered_html.set(render_plaintext(&text)); - }; - } - Err(e) => error.set(Some(format!("Failed to read content: {e}"))), - }, - Err(e) => error.set(Some(format!("Failed to fetch: {e}"))), - } - loading.set(false); - }); + // Fetch content on mount + let url = content_url.clone(); + let mtype = media_type.clone(); + use_effect(move || { + let url = url.clone(); + let mtype = mtype.clone(); + spawn(async move { + loading.set(true); + error.set(None); + match reqwest::get(&url).await { + Ok(resp) => { + match resp.text().await { + Ok(text) => { + raw_content.set(text.clone()); + if mtype == "md" || mtype == "markdown" { + let (fm_html, body_html) = + render_markdown_with_frontmatter(&text); + frontmatter_html.set(fm_html); + rendered_html.set(body_html); + } else { + frontmatter_html.set(None); + rendered_html.set(render_plaintext(&text)); + }; + }, + Err(e) => error.set(Some(format!("Failed to read content: {e}"))), + } + }, + Err(e) => error.set(Some(format!("Failed to fetch: {e}"))), + } + loading.set(false); }); + }); - // Set up global wikilink click handler that the inline onclick attributes will call - // This bridges JavaScript → Rust communication - use_effect(move || { - if let Some(handler) = on_wikilink_click { - spawn(async move { - // Set up a global function that wikilink onclick handlers can call - // The function stores the clicked target in localStorage - let setup_js = r#" + // Set up global wikilink click handler that the inline onclick attributes + // will call This bridges JavaScript → Rust communication + use_effect(move || { + if let Some(handler) = on_wikilink_click { + spawn(async move { + // Set up a global function that wikilink onclick handlers can call + // The function stores the clicked target in localStorage + let setup_js = r#" window.__dioxus_wikilink_click = function(target) { console.log('Wikilink clicked:', target); localStorage.setItem('__wikilink_clicked', target); }; "#; - let _ = eval(setup_js).await; + let _ = eval(setup_js).await; - // Poll localStorage to detect wikilink clicks - loop { - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + // Poll localStorage to detect wikilink clicks + loop { + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - let check_js = r#" + let check_js = r#" (function() { const target = localStorage.getItem('__wikilink_clicked'); if (target) { @@ -78,313 +80,321 @@ pub fn MarkdownViewer( })(); "#; - if let Ok(result) = eval(check_js).await { - if let Some(target) = result.as_str() { - if !target.is_empty() { - handler.call(target.to_string()); - } - } - } - } - }); - } - }); - - let is_loading = *loading.read(); - let is_preview = *show_preview.read(); - - rsx! { - div { class: "markdown-viewer", - // View toggle toolbar - if !is_loading && error.read().is_none() { - div { class: "markdown-toolbar", - button { - class: if is_preview { "toolbar-btn active" } else { "toolbar-btn" }, - onclick: move |_| show_preview.set(true), - title: "Preview Mode", - "Preview" - } - button { - class: if !is_preview { "toolbar-btn active" } else { "toolbar-btn" }, - onclick: move |_| show_preview.set(false), - title: "Source Mode", - "Source" - } - } - } - - if is_loading { - div { class: "loading-overlay", - div { class: "spinner" } - "Loading content..." - } - } - - if let Some(ref err) = *error.read() { - div { class: "error-banner", - span { class: "error-icon", "\u{26a0}" } - "{err}" - } - } - - if !is_loading && error.read().is_none() { - if is_preview { - // Preview mode - show rendered markdown - if let Some(ref fm) = *frontmatter_html.read() { - div { - class: "frontmatter-card", - dangerous_inner_html: "{fm}", - } - } - div { - class: "markdown-content", - dangerous_inner_html: "{rendered_html}", - } - } else { - // Source mode - show raw markdown - pre { class: "markdown-source", - code { "{raw_content}" } - } - } - } + if let Ok(result) = eval(check_js).await { + if let Some(target) = result.as_str() { + if !target.is_empty() { + handler.call(target.to_string()); + } + } + } } + }); } + }); + + let is_loading = *loading.read(); + let is_preview = *show_preview.read(); + + rsx! { + div { class: "markdown-viewer", + // View toggle toolbar + if !is_loading && error.read().is_none() { + div { class: "markdown-toolbar", + button { + class: if is_preview { "toolbar-btn active" } else { "toolbar-btn" }, + onclick: move |_| show_preview.set(true), + title: "Preview Mode", + "Preview" + } + button { + class: if !is_preview { "toolbar-btn active" } else { "toolbar-btn" }, + onclick: move |_| show_preview.set(false), + title: "Source Mode", + "Source" + } + } + } + + if is_loading { + div { class: "loading-overlay", + div { class: "spinner" } + "Loading content..." + } + } + + if let Some(ref err) = *error.read() { + div { class: "error-banner", + span { class: "error-icon", "\u{26a0}" } + "{err}" + } + } + + if !is_loading && error.read().is_none() { + if is_preview { + // Preview mode - show rendered markdown + if let Some(ref fm) = *frontmatter_html.read() { + div { + class: "frontmatter-card", + dangerous_inner_html: "{fm}", + } + } + div { + class: "markdown-content", + dangerous_inner_html: "{rendered_html}", + } + } else { + // Source mode - show raw markdown + pre { class: "markdown-source", + code { "{raw_content}" } + } + } + } + } + } } -/// Parse frontmatter and render markdown body. Returns (frontmatter_html, body_html). +/// Parse frontmatter and render markdown body. Returns (frontmatter_html, +/// body_html). fn render_markdown_with_frontmatter(text: &str) -> (Option, String) { - use gray_matter::Matter; - use gray_matter::engine::YAML; + use gray_matter::{Matter, engine::YAML}; - let matter = Matter::::new(); - let Ok(result) = matter.parse(text) else { - // If frontmatter parsing fails, just render the whole text as markdown - return (None, render_markdown(text)); - }; + let matter = Matter::::new(); + let Ok(result) = matter.parse(text) else { + // If frontmatter parsing fails, just render the whole text as markdown + return (None, render_markdown(text)); + }; - let fm_html = result.data.and_then(|data| render_frontmatter_card(&data)); + let fm_html = result.data.and_then(|data| render_frontmatter_card(&data)); - let body_html = render_markdown(&result.content); - (fm_html, body_html) + let body_html = render_markdown(&result.content); + (fm_html, body_html) } /// Render frontmatter fields as an HTML card. fn render_frontmatter_card(data: &gray_matter::Pod) -> Option { - let gray_matter::Pod::Hash(map) = data else { - return None; - }; + let gray_matter::Pod::Hash(map) = data else { + return None; + }; - if map.is_empty() { - return None; - } + if map.is_empty() { + return None; + } - let mut html = String::from("
"); + let mut html = String::from("
"); - for (key, value) in map { - let display_value = pod_to_display(value); - let escaped_key = escape_html(key); - html.push_str(&format!("
{escaped_key}
{display_value}
")); - } + for (key, value) in map { + let display_value = pod_to_display(value); + let escaped_key = escape_html(key); + html.push_str(&format!("
{escaped_key}
{display_value}
")); + } - html.push_str("
"); - Some(html) + html.push_str("
"); + Some(html) } fn pod_to_display(pod: &gray_matter::Pod) -> String { - match pod { - gray_matter::Pod::String(s) => escape_html(s), - gray_matter::Pod::Integer(n) => n.to_string(), - gray_matter::Pod::Float(f) => f.to_string(), - gray_matter::Pod::Boolean(b) => b.to_string(), - gray_matter::Pod::Array(arr) => { - let items: Vec = arr.iter().map(pod_to_display).collect(); - items.join(", ") - } - gray_matter::Pod::Hash(map) => { - let items: Vec = map - .iter() - .map(|(k, v)| format!("{}: {}", escape_html(k), pod_to_display(v))) - .collect(); - items.join("; ") - } - gray_matter::Pod::Null => String::new(), - } + match pod { + gray_matter::Pod::String(s) => escape_html(s), + gray_matter::Pod::Integer(n) => n.to_string(), + gray_matter::Pod::Float(f) => f.to_string(), + gray_matter::Pod::Boolean(b) => b.to_string(), + gray_matter::Pod::Array(arr) => { + let items: Vec = arr.iter().map(pod_to_display).collect(); + items.join(", ") + }, + gray_matter::Pod::Hash(map) => { + let items: Vec = map + .iter() + .map(|(k, v)| format!("{}: {}", escape_html(k), pod_to_display(v))) + .collect(); + items.join("; ") + }, + gray_matter::Pod::Null => String::new(), + } } fn render_markdown(text: &str) -> String { - use pulldown_cmark::{Options, Parser, html}; + use pulldown_cmark::{Options, Parser, html}; - // First, convert wikilinks to standard markdown links - let text_with_links = convert_wikilinks(text); + // First, convert wikilinks to standard markdown links + let text_with_links = convert_wikilinks(text); - let mut options = Options::empty(); - options.insert(Options::ENABLE_TABLES); - options.insert(Options::ENABLE_STRIKETHROUGH); - options.insert(Options::ENABLE_TASKLISTS); - options.insert(Options::ENABLE_FOOTNOTES); - options.insert(Options::ENABLE_HEADING_ATTRIBUTES); + let mut options = Options::empty(); + options.insert(Options::ENABLE_TABLES); + options.insert(Options::ENABLE_STRIKETHROUGH); + options.insert(Options::ENABLE_TASKLISTS); + options.insert(Options::ENABLE_FOOTNOTES); + options.insert(Options::ENABLE_HEADING_ATTRIBUTES); - let parser = Parser::new_ext(&text_with_links, options); - let mut html_output = String::new(); - html::push_html(&mut html_output, parser); + let parser = Parser::new_ext(&text_with_links, options); + let mut html_output = String::new(); + html::push_html(&mut html_output, parser); - // Sanitize HTML using ammonia with a safe allowlist - sanitize_html(&html_output) + // Sanitize HTML using ammonia with a safe allowlist + sanitize_html(&html_output) } /// Convert wikilinks [[target]] and [[target|display]] to styled HTML links. /// Uses a special URL scheme that can be intercepted by click handlers. fn convert_wikilinks(text: &str) -> String { - use regex::Regex; + use regex::Regex; - // Match embeds ![[target]] first, convert to a placeholder image/embed span - let embed_re = Regex::new(r"!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]").unwrap(); - let text = embed_re.replace_all(text, |caps: ®ex::Captures| { - let target = caps.get(1).unwrap().as_str().trim(); - let alt = caps.get(2).map(|m| m.as_str().trim()).unwrap_or(target); - format!( - "[Embed: {}]", - escape_html_attr(target), - escape_html_attr(target), - escape_html(alt) - ) - }); + // Match embeds ![[target]] first, convert to a placeholder image/embed span + let embed_re = Regex::new(r"!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]").unwrap(); + let text = embed_re.replace_all(text, |caps: ®ex::Captures| { + let target = caps.get(1).unwrap().as_str().trim(); + let alt = caps.get(2).map(|m| m.as_str().trim()).unwrap_or(target); + format!( + "[Embed: {}]", + escape_html_attr(target), + escape_html_attr(target), + escape_html(alt) + ) + }); - // Match wikilinks [[target]] or [[target|display]] - let wikilink_re = Regex::new(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]").unwrap(); - let text = wikilink_re.replace_all(&text, |caps: ®ex::Captures| { - let target = caps.get(1).unwrap().as_str().trim(); - let display = caps.get(2).map(|m| m.as_str().trim()).unwrap_or(target); - // Create a styled link that uses a special pseudo-protocol scheme - // This makes it easier to intercept clicks via JavaScript - format!( - "{display}", - target = escape_html_attr(target), - target_escaped = escape_html_attr(&target.replace('\\', "\\\\").replace('\'', "\\'")), - display = escape_html(display) - ) - }); + // Match wikilinks [[target]] or [[target|display]] + let wikilink_re = Regex::new(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]").unwrap(); + let text = wikilink_re.replace_all(&text, |caps: ®ex::Captures| { + let target = caps.get(1).unwrap().as_str().trim(); + let display = caps.get(2).map(|m| m.as_str().trim()).unwrap_or(target); + // Create a styled link that uses a special pseudo-protocol scheme + // This makes it easier to intercept clicks via JavaScript + format!( + "{display}", + target = escape_html_attr(target), + target_escaped = + escape_html_attr(&target.replace('\\', "\\\\").replace('\'', "\\'")), + display = escape_html(display) + ) + }); - text.to_string() + text.to_string() } fn render_plaintext(text: &str) -> String { - let escaped = escape_html(text); - format!("
{escaped}
") + let escaped = escape_html(text); + format!("
{escaped}
") } /// Escape text for display in HTML content. fn escape_html(text: &str) -> String { - text.replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) + text + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) } /// Escape text for use in HTML attributes (includes single quotes). fn escape_html_attr(text: &str) -> String { - text.replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") + text + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") } /// Sanitize HTML using ammonia with a safe allowlist. /// This prevents XSS attacks by removing dangerous elements and attributes. fn sanitize_html(html: &str) -> String { - use ammonia::Builder; - use std::collections::HashSet; + use std::collections::HashSet; - // Build a custom sanitizer that allows safe markdown elements - // but strips all event handlers and dangerous elements - let mut builder = Builder::default(); + use ammonia::Builder; - // Allow common markdown elements - let allowed_tags: HashSet<&str> = [ - "a", - "abbr", - "acronym", - "b", - "blockquote", - "br", - "code", - "dd", - "del", - "details", - "div", - "dl", - "dt", - "em", - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "hr", - "i", - "img", - "ins", - "kbd", - "li", - "mark", - "ol", - "p", - "pre", - "q", - "s", - "samp", - "small", - "span", - "strong", - "sub", - "summary", - "sup", - "table", - "tbody", - "td", - "tfoot", - "th", - "thead", - "tr", - "u", - "ul", - "var", - // Task list support - "input", - ] - .into_iter() - .collect(); + // Build a custom sanitizer that allows safe markdown elements + // but strips all event handlers and dangerous elements + let mut builder = Builder::default(); - // Allow safe attributes - let allowed_attrs: HashSet<&str> = [ - "href", - "src", - "alt", - "title", - "class", - "id", - "name", - "width", - "height", - "align", - "valign", - "colspan", - "rowspan", - "scope", - // Data attributes for wikilinks (safe - no code execution) - "data-target", - "data-wikilink-target", - // Task list checkbox support - "type", - "checked", - "disabled", - ] - .into_iter() - .collect(); + // Allow common markdown elements + let allowed_tags: HashSet<&str> = [ + "a", + "abbr", + "acronym", + "b", + "blockquote", + "br", + "code", + "dd", + "del", + "details", + "div", + "dl", + "dt", + "em", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "i", + "img", + "ins", + "kbd", + "li", + "mark", + "ol", + "p", + "pre", + "q", + "s", + "samp", + "small", + "span", + "strong", + "sub", + "summary", + "sup", + "table", + "tbody", + "td", + "tfoot", + "th", + "thead", + "tr", + "u", + "ul", + "var", + // Task list support + "input", + ] + .into_iter() + .collect(); - builder + // Allow safe attributes + let allowed_attrs: HashSet<&str> = [ + "href", + "src", + "alt", + "title", + "class", + "id", + "name", + "width", + "height", + "align", + "valign", + "colspan", + "rowspan", + "scope", + // Data attributes for wikilinks (safe - no code execution) + "data-target", + "data-wikilink-target", + // Task list checkbox support + "type", + "checked", + "disabled", + ] + .into_iter() + .collect(); + + builder .tags(allowed_tags) .generic_attributes(allowed_attrs) // Allow relative URLs and fragment-only URLs for internal links diff --git a/crates/pinakes-ui/src/components/media_player.rs b/crates/pinakes-ui/src/components/media_player.rs index 5afd87f..73d0e01 100644 --- a/crates/pinakes-ui/src/components/media_player.rs +++ b/crates/pinakes-ui/src/components/media_player.rs @@ -1,163 +1,164 @@ -use dioxus::document::eval; -use dioxus::prelude::*; +use dioxus::{document::eval, prelude::*}; use super::utils::format_duration; #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct QueueItem { - pub media_id: String, - pub title: String, - pub artist: Option, - pub duration_secs: Option, - pub media_type: String, - pub stream_url: String, - pub thumbnail_url: Option, + pub media_id: String, + pub title: String, + pub artist: Option, + pub duration_secs: Option, + pub media_type: String, + pub stream_url: String, + pub thumbnail_url: Option, } -#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +#[derive( + Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize, +)] pub enum RepeatMode { - Off, - One, - All, + Off, + One, + All, } #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct PlayQueue { - pub items: Vec, - pub current_index: usize, - pub repeat: RepeatMode, - pub shuffle: bool, + pub items: Vec, + pub current_index: usize, + pub repeat: RepeatMode, + pub shuffle: bool, } impl Default for PlayQueue { - fn default() -> Self { - Self { - items: Vec::new(), - current_index: 0, - repeat: RepeatMode::Off, - shuffle: false, - } + fn default() -> Self { + Self { + items: Vec::new(), + current_index: 0, + repeat: RepeatMode::Off, + shuffle: false, } + } } impl PlayQueue { - /// Check if the queue is empty. - pub fn is_empty(&self) -> bool { - self.items.is_empty() - } + /// Check if the queue is empty. + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } - /// Get the current item in the queue. - pub fn current(&self) -> Option<&QueueItem> { + /// Get the current item in the queue. + pub fn current(&self) -> Option<&QueueItem> { + self.items.get(self.current_index) + } + + /// Advance to the next item based on repeat mode. + pub fn next(&mut self) -> Option<&QueueItem> { + if self.items.is_empty() { + return None; + } + match self.repeat { + RepeatMode::One => self.items.get(self.current_index), + RepeatMode::All => { + self.current_index = (self.current_index + 1) % self.items.len(); self.items.get(self.current_index) - } - - /// Advance to the next item based on repeat mode. - pub fn next(&mut self) -> Option<&QueueItem> { - if self.items.is_empty() { - return None; - } - match self.repeat { - RepeatMode::One => self.items.get(self.current_index), - RepeatMode::All => { - self.current_index = (self.current_index + 1) % self.items.len(); - self.items.get(self.current_index) - } - RepeatMode::Off => { - if self.current_index + 1 < self.items.len() { - self.current_index += 1; - self.items.get(self.current_index) - } else { - None - } - } + }, + RepeatMode::Off => { + if self.current_index + 1 < self.items.len() { + self.current_index += 1; + self.items.get(self.current_index) + } else { + None } + }, } + } - /// Go to the previous item based on repeat mode. - pub fn previous(&mut self) -> Option<&QueueItem> { - if self.items.is_empty() { - return None; - } - if self.current_index > 0 { - self.current_index -= 1; - } else if self.repeat == RepeatMode::All { - self.current_index = self.items.len() - 1; - } - self.items.get(self.current_index) + /// Go to the previous item based on repeat mode. + pub fn previous(&mut self) -> Option<&QueueItem> { + if self.items.is_empty() { + return None; } + if self.current_index > 0 { + self.current_index -= 1; + } else if self.repeat == RepeatMode::All { + self.current_index = self.items.len() - 1; + } + self.items.get(self.current_index) + } - /// Add an item to the queue. - pub fn add(&mut self, item: QueueItem) { - self.items.push(item); - } + /// Add an item to the queue. + pub fn add(&mut self, item: QueueItem) { + self.items.push(item); + } - /// Remove an item from the queue by index. - pub fn remove(&mut self, index: usize) { - if index < self.items.len() { - self.items.remove(index); - if self.current_index >= self.items.len() && !self.items.is_empty() { - self.current_index = self.items.len() - 1; - } - } + /// Remove an item from the queue by index. + pub fn remove(&mut self, index: usize) { + if index < self.items.len() { + self.items.remove(index); + if self.current_index >= self.items.len() && !self.items.is_empty() { + self.current_index = self.items.len() - 1; + } } + } - /// Clear all items from the queue. - pub fn clear(&mut self) { - self.items.clear(); - self.current_index = 0; - } + /// Clear all items from the queue. + pub fn clear(&mut self) { + self.items.clear(); + self.current_index = 0; + } - /// Toggle between repeat modes: Off -> All -> One -> Off. - pub fn toggle_repeat(&mut self) { - self.repeat = match self.repeat { - RepeatMode::Off => RepeatMode::All, - RepeatMode::All => RepeatMode::One, - RepeatMode::One => RepeatMode::Off, - }; - } + /// Toggle between repeat modes: Off -> All -> One -> Off. + pub fn toggle_repeat(&mut self) { + self.repeat = match self.repeat { + RepeatMode::Off => RepeatMode::All, + RepeatMode::All => RepeatMode::One, + RepeatMode::One => RepeatMode::Off, + }; + } - /// Toggle shuffle mode on/off. - pub fn toggle_shuffle(&mut self) { - self.shuffle = !self.shuffle; - } + /// Toggle shuffle mode on/off. + pub fn toggle_shuffle(&mut self) { + self.shuffle = !self.shuffle; + } } #[component] pub fn MediaPlayer( - src: String, - media_type: String, - #[props(default)] title: Option, - #[props(default)] thumbnail_url: Option, - #[props(default = false)] autoplay: bool, - #[props(default)] on_track_ended: Option>, + src: String, + media_type: String, + #[props(default)] title: Option, + #[props(default)] thumbnail_url: Option, + #[props(default = false)] autoplay: bool, + #[props(default)] on_track_ended: Option>, ) -> Element { - let mut playing = use_signal(|| false); - let mut current_time = use_signal(|| 0.0f64); - let mut duration = use_signal(|| 0.0f64); - let mut volume = use_signal(|| 1.0f64); - let mut muted = use_signal(|| false); + let mut playing = use_signal(|| false); + let mut current_time = use_signal(|| 0.0f64); + let mut duration = use_signal(|| 0.0f64); + let mut volume = use_signal(|| 1.0f64); + let mut muted = use_signal(|| false); - let is_video = media_type == "video"; - let is_playing = *playing.read(); - let cur_time = *current_time.read(); - let dur = *duration.read(); - let vol = *volume.read(); - let is_muted = *muted.read(); - let time_str = format_duration(cur_time); - let dur_str = format_duration(dur); - let vol_pct = (vol * 100.0) as u32; + let is_video = media_type == "video"; + let is_playing = *playing.read(); + let cur_time = *current_time.read(); + let dur = *duration.read(); + let vol = *volume.read(); + let is_muted = *muted.read(); + let time_str = format_duration(cur_time); + let dur_str = format_duration(dur); + let vol_pct = (vol * 100.0) as u32; - // Poll playback state every 250ms - let src_clone = src.clone(); - let on_ended = on_track_ended; - use_effect(move || { - let _ = &src_clone; - let on_ended = on_ended; - spawn(async move { - loop { - tokio::time::sleep(std::time::Duration::from_millis(250)).await; - let result = eval( - r#" + // Poll playback state every 250ms + let src_clone = src.clone(); + let on_ended = on_track_ended; + use_effect(move || { + let _ = &src_clone; + let on_ended = on_ended; + spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + let result = eval( + r#" let el = document.getElementById('pinakes-player'); if (el) { return JSON.stringify({ @@ -171,534 +172,558 @@ pub fn MediaPlayer( } return "null"; "#, - ) - .await; - if let Ok(val) = result - && let Some(s) = val.as_str() - && s != "null" - && let Ok(state) = serde_json::from_str::(s) - { - if let Some(ct) = state["currentTime"].as_f64() { - current_time.set(ct); - } - if let Some(d) = state["duration"].as_f64() - && d.is_finite() - { - duration.set(d); - } - if let Some(p) = state["paused"].as_bool() { - playing.set(!p); - } - if let Some(true) = state["ended"].as_bool() - && let Some(ref handler) = on_ended - { - handler.call(()); - } - } - } - }); + ) + .await; + if let Ok(val) = result + && let Some(s) = val.as_str() + && s != "null" + && let Ok(state) = serde_json::from_str::(s) + { + if let Some(ct) = state["currentTime"].as_f64() { + current_time.set(ct); + } + if let Some(d) = state["duration"].as_f64() + && d.is_finite() + { + duration.set(d); + } + if let Some(p) = state["paused"].as_bool() { + playing.set(!p); + } + if let Some(true) = state["ended"].as_bool() + && let Some(ref handler) = on_ended + { + handler.call(()); + } + } + } }); + }); - // Autoplay on mount - if autoplay { - let src_auto = src.clone(); - use_effect(move || { - let _ = &src_auto; - spawn(async move { - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let _ = eval("document.getElementById('pinakes-player')?.play()").await; - }); - }); + // Autoplay on mount + if autoplay { + let src_auto = src.clone(); + use_effect(move || { + let _ = &src_auto; + spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + let _ = eval("document.getElementById('pinakes-player')?.play()").await; + }); + }); + } + + let toggle_play = move |_| { + spawn(async move { + if *playing.read() { + let _ = + eval("document.getElementById('pinakes-player')?.pause()").await; + } else { + let _ = eval("document.getElementById('pinakes-player')?.play()").await; + } + }); + }; + + let toggle_mute = move |_| { + let new_muted = !*muted.read(); + muted.set(new_muted); + let js = format!( + "let e = document.getElementById('pinakes-player'); if(e) e.muted = {};", + new_muted + ); + spawn(async move { + let _ = eval(&js).await; + }); + }; + + let on_seek = move |e: Event| { + if let Ok(t) = e.value().parse::() { + current_time.set(t); + let js = format!( + "let e = document.getElementById('pinakes-player'); if(e) \ + e.currentTime = {};", + t + ); + spawn(async move { + let _ = eval(&js).await; + }); } + }; - let toggle_play = move |_| { + let on_volume = move |e: Event| { + if let Ok(v) = e.value().parse::() { + let vol_val = v / 100.0; + volume.set(vol_val); + let js = format!( + "let e = document.getElementById('pinakes-player'); if(e) e.volume = \ + {};", + vol_val + ); + spawn(async move { + let _ = eval(&js).await; + }); + } + }; + + let on_fullscreen = move |_| { + spawn(async move { + let _ = eval( + "let e = document.getElementById('pinakes-player'); if(e) { \ + if(document.fullscreenElement) document.exitFullscreen(); else \ + e.requestFullscreen(); }", + ) + .await; + }); + }; + + // Keyboard controls + let on_keydown = move |evt: KeyboardEvent| { + let key = evt.key(); + match key { + Key::Character(ref c) if c == " " => { + evt.prevent_default(); spawn(async move { - if *playing.read() { - let _ = eval("document.getElementById('pinakes-player')?.pause()").await; - } else { - let _ = eval("document.getElementById('pinakes-player')?.play()").await; - } + if *playing.read() { + let _ = + eval("document.getElementById('pinakes-player')?.pause()").await; + } else { + let _ = + eval("document.getElementById('pinakes-player')?.play()").await; + } }); - }; - - let toggle_mute = move |_| { + }, + Key::ArrowLeft => { + evt.prevent_default(); + spawn(async move { + let _ = eval( + "let e = document.getElementById('pinakes-player'); if(e) \ + e.currentTime = Math.max(0, e.currentTime - 5);", + ) + .await; + }); + }, + Key::ArrowRight => { + evt.prevent_default(); + spawn(async move { + let _ = eval( + "let e = document.getElementById('pinakes-player'); if(e) \ + e.currentTime = Math.min(e.duration || 0, e.currentTime + 5);", + ) + .await; + }); + }, + Key::ArrowUp => { + evt.prevent_default(); + let new_vol = (vol + 0.1).min(1.0); + volume.set(new_vol); + let js = format!( + "let e = document.getElementById('pinakes-player'); if(e) e.volume \ + = {};", + new_vol + ); + spawn(async move { + let _ = eval(&js).await; + }); + }, + Key::ArrowDown => { + evt.prevent_default(); + let new_vol = (vol - 0.1).max(0.0); + volume.set(new_vol); + let js = format!( + "let e = document.getElementById('pinakes-player'); if(e) e.volume \ + = {};", + new_vol + ); + spawn(async move { + let _ = eval(&js).await; + }); + }, + Key::Character(ref c) if c == "m" || c == "M" => { let new_muted = !*muted.read(); muted.set(new_muted); let js = format!( - "let e = document.getElementById('pinakes-player'); if(e) e.muted = {};", - new_muted + "let e = document.getElementById('pinakes-player'); if(e) e.muted = \ + {};", + new_muted ); spawn(async move { - let _ = eval(&js).await; + let _ = eval(&js).await; }); - }; - - let on_seek = move |e: Event| { - if let Ok(t) = e.value().parse::() { - current_time.set(t); - let js = format!( - "let e = document.getElementById('pinakes-player'); if(e) e.currentTime = {};", - t - ); - spawn(async move { - let _ = eval(&js).await; - }); - } - }; - - let on_volume = move |e: Event| { - if let Ok(v) = e.value().parse::() { - let vol_val = v / 100.0; - volume.set(vol_val); - let js = format!( - "let e = document.getElementById('pinakes-player'); if(e) e.volume = {};", - vol_val - ); - spawn(async move { - let _ = eval(&js).await; - }); - } - }; - - let on_fullscreen = move |_| { + }, + Key::Character(ref c) if c == "f" || c == "F" => { spawn(async move { - let _ = eval( - "let e = document.getElementById('pinakes-player'); if(e) { if(document.fullscreenElement) document.exitFullscreen(); else e.requestFullscreen(); }", - ).await; + let _ = eval( + "let e = document.getElementById('pinakes-player'); if(e) { \ + if(document.fullscreenElement) document.exitFullscreen(); else \ + e.requestFullscreen(); }", + ) + .await; }); - }; - - // Keyboard controls - let on_keydown = move |evt: KeyboardEvent| { - let key = evt.key(); - match key { - Key::Character(ref c) if c == " " => { - evt.prevent_default(); - spawn(async move { - if *playing.read() { - let _ = eval("document.getElementById('pinakes-player')?.pause()").await; - } else { - let _ = eval("document.getElementById('pinakes-player')?.play()").await; - } - }); - } - Key::ArrowLeft => { - evt.prevent_default(); - spawn(async move { - let _ = eval("let e = document.getElementById('pinakes-player'); if(e) e.currentTime = Math.max(0, e.currentTime - 5);").await; - }); - } - Key::ArrowRight => { - evt.prevent_default(); - spawn(async move { - let _ = eval("let e = document.getElementById('pinakes-player'); if(e) e.currentTime = Math.min(e.duration || 0, e.currentTime + 5);").await; - }); - } - Key::ArrowUp => { - evt.prevent_default(); - let new_vol = (vol + 0.1).min(1.0); - volume.set(new_vol); - let js = format!( - "let e = document.getElementById('pinakes-player'); if(e) e.volume = {};", - new_vol - ); - spawn(async move { - let _ = eval(&js).await; - }); - } - Key::ArrowDown => { - evt.prevent_default(); - let new_vol = (vol - 0.1).max(0.0); - volume.set(new_vol); - let js = format!( - "let e = document.getElementById('pinakes-player'); if(e) e.volume = {};", - new_vol - ); - spawn(async move { - let _ = eval(&js).await; - }); - } - Key::Character(ref c) if c == "m" || c == "M" => { - let new_muted = !*muted.read(); - muted.set(new_muted); - let js = format!( - "let e = document.getElementById('pinakes-player'); if(e) e.muted = {};", - new_muted - ); - spawn(async move { - let _ = eval(&js).await; - }); - } - Key::Character(ref c) if c == "f" || c == "F" => { - spawn(async move { - let _ = eval("let e = document.getElementById('pinakes-player'); if(e) { if(document.fullscreenElement) document.exitFullscreen(); else e.requestFullscreen(); }").await; - }); - } - _ => {} - } - }; - - let play_icon = if is_playing { "\u{23f8}" } else { "\u{25b6}" }; - let mute_icon = if is_muted { "\u{1f507}" } else { "\u{1f50a}" }; - - rsx! { - div { - class: if is_video { "media-player media-player-video" } else { "media-player media-player-audio" }, - tabindex: "0", - onkeydown: on_keydown, - - // Hidden native element - if is_video { - video { - id: "pinakes-player", - src: "{src}", - style: if is_video { "width: 100%; display: block;" } else { "display: none;" }, - preload: "metadata", - } - } else { - audio { - id: "pinakes-player", - src: "{src}", - style: "display: none;", - preload: "metadata", - } - } - - // Album art for audio - if !is_video { - div { class: "player-artwork", - if let Some(ref thumb) = thumbnail_url { - img { src: "{thumb}", alt: "Cover art" } - } else { - div { class: "player-artwork-placeholder", "\u{266b}" } - } - if let Some(ref t) = title { - div { class: "player-title", "{t}" } - } - } - } - - // Custom controls - div { class: "player-controls", - button { - class: "play-btn", - onclick: toggle_play, - title: if is_playing { "Pause" } else { "Play" }, - "{play_icon}" - } - span { class: "player-time", "{time_str}" } - input { - r#type: "range", - class: "seek-bar", - min: "0", - max: "{dur}", - step: "0.1", - value: "{cur_time}", - oninput: on_seek, - } - span { class: "player-time", "{dur_str}" } - button { - class: "mute-btn", - onclick: toggle_mute, - title: if is_muted { "Unmute" } else { "Mute" }, - "{mute_icon}" - } - input { - r#type: "range", - class: "volume-slider", - min: "0", - max: "100", - value: "{vol_pct}", - oninput: on_volume, - } - if is_video { - button { - class: "fullscreen-btn", - onclick: on_fullscreen, - title: "Fullscreen", - "\u{26f6}" - } - } - } - } + }, + _ => {}, } + }; + + let play_icon = if is_playing { "\u{23f8}" } else { "\u{25b6}" }; + let mute_icon = if is_muted { "\u{1f507}" } else { "\u{1f50a}" }; + + rsx! { + div { + class: if is_video { "media-player media-player-video" } else { "media-player media-player-audio" }, + tabindex: "0", + onkeydown: on_keydown, + + // Hidden native element + if is_video { + video { + id: "pinakes-player", + src: "{src}", + style: if is_video { "width: 100%; display: block;" } else { "display: none;" }, + preload: "metadata", + } + } else { + audio { + id: "pinakes-player", + src: "{src}", + style: "display: none;", + preload: "metadata", + } + } + + // Album art for audio + if !is_video { + div { class: "player-artwork", + if let Some(ref thumb) = thumbnail_url { + img { src: "{thumb}", alt: "Cover art" } + } else { + div { class: "player-artwork-placeholder", "\u{266b}" } + } + if let Some(ref t) = title { + div { class: "player-title", "{t}" } + } + } + } + + // Custom controls + div { class: "player-controls", + button { + class: "play-btn", + onclick: toggle_play, + title: if is_playing { "Pause" } else { "Play" }, + "{play_icon}" + } + span { class: "player-time", "{time_str}" } + input { + r#type: "range", + class: "seek-bar", + min: "0", + max: "{dur}", + step: "0.1", + value: "{cur_time}", + oninput: on_seek, + } + span { class: "player-time", "{dur_str}" } + button { + class: "mute-btn", + onclick: toggle_mute, + title: if is_muted { "Unmute" } else { "Mute" }, + "{mute_icon}" + } + input { + r#type: "range", + class: "volume-slider", + min: "0", + max: "100", + value: "{vol_pct}", + oninput: on_volume, + } + if is_video { + button { + class: "fullscreen-btn", + onclick: on_fullscreen, + title: "Fullscreen", + "\u{26f6}" + } + } + } + } + } } #[component] pub fn QueuePanel( - queue: PlayQueue, - on_select: EventHandler, - on_remove: EventHandler, - on_clear: EventHandler<()>, - on_toggle_repeat: EventHandler<()>, - on_toggle_shuffle: EventHandler<()>, - on_next: EventHandler<()>, - on_previous: EventHandler<()>, + queue: PlayQueue, + on_select: EventHandler, + on_remove: EventHandler, + on_clear: EventHandler<()>, + on_toggle_repeat: EventHandler<()>, + on_toggle_shuffle: EventHandler<()>, + on_next: EventHandler<()>, + on_previous: EventHandler<()>, ) -> Element { - let repeat_label = match queue.repeat { - RepeatMode::Off => "Repeat: Off", - RepeatMode::One => "Repeat: One", - RepeatMode::All => "Repeat: All", - }; - let shuffle_label = if queue.shuffle { - "Shuffle: On" - } else { - "Shuffle: Off" - }; - let current_idx = queue.current_index; + let repeat_label = match queue.repeat { + RepeatMode::Off => "Repeat: Off", + RepeatMode::One => "Repeat: One", + RepeatMode::All => "Repeat: All", + }; + let shuffle_label = if queue.shuffle { + "Shuffle: On" + } else { + "Shuffle: Off" + }; + let current_idx = queue.current_index; - rsx! { - div { class: "queue-panel", - div { class: "queue-header", - h3 { "Play Queue ({queue.items.len()})" } - div { class: "queue-controls", - button { - class: "btn btn-sm btn-ghost", - onclick: move |_| on_previous.call(()), - title: "Previous (P)", - "\u{23ee}" - } - button { - class: "btn btn-sm btn-ghost", - onclick: move |_| on_next.call(()), - title: "Next (N)", - "\u{23ed}" - } - button { - class: "btn btn-sm btn-ghost", - onclick: move |_| on_toggle_repeat.call(()), - title: "{repeat_label}", - "\u{1f501}" - } - button { - class: "btn btn-sm btn-ghost", - onclick: move |_| on_toggle_shuffle.call(()), - title: "{shuffle_label}", - "\u{1f500}" - } - button { - class: "btn btn-sm btn-ghost", - onclick: move |_| on_clear.call(()), - title: "Clear Queue", - "\u{1f5d1}" - } - } - } + rsx! { + div { class: "queue-panel", + div { class: "queue-header", + h3 { "Play Queue ({queue.items.len()})" } + div { class: "queue-controls", + button { + class: "btn btn-sm btn-ghost", + onclick: move |_| on_previous.call(()), + title: "Previous (P)", + "\u{23ee}" + } + button { + class: "btn btn-sm btn-ghost", + onclick: move |_| on_next.call(()), + title: "Next (N)", + "\u{23ed}" + } + button { + class: "btn btn-sm btn-ghost", + onclick: move |_| on_toggle_repeat.call(()), + title: "{repeat_label}", + "\u{1f501}" + } + button { + class: "btn btn-sm btn-ghost", + onclick: move |_| on_toggle_shuffle.call(()), + title: "{shuffle_label}", + "\u{1f500}" + } + button { + class: "btn btn-sm btn-ghost", + onclick: move |_| on_clear.call(()), + title: "Clear Queue", + "\u{1f5d1}" + } + } + } - if queue.items.is_empty() { - div { class: "queue-empty", "Queue is empty. Add items from the library." } - } else { - div { class: "queue-list", - for (i , item) in queue.items.iter().enumerate() { - { - let is_current = i == current_idx; - let item_class = if is_current { - "queue-item queue-item-active" - } else { - "queue-item" - }; - let title = item.title.clone(); - let artist = item.artist.clone().unwrap_or_default(); - rsx! { - div { - key: "q-{i}", - class: "{item_class}", - onclick: move |_| on_select.call(i), - div { class: "queue-item-info", - span { class: "queue-item-title", "{title}" } - if !artist.is_empty() { - span { class: "queue-item-artist", "{artist}" } - } - } - button { - class: "btn btn-sm btn-ghost queue-item-remove", - onclick: move |e: Event| { - e.stop_propagation(); - on_remove.call(i); - }, - "\u{2715}" - } - } - } - } - } - } - } - } - } + if queue.items.is_empty() { + div { class: "queue-empty", "Queue is empty. Add items from the library." } + } else { + div { class: "queue-list", + for (i , item) in queue.items.iter().enumerate() { + { + let is_current = i == current_idx; + let item_class = if is_current { + "queue-item queue-item-active" + } else { + "queue-item" + }; + let title = item.title.clone(); + let artist = item.artist.clone().unwrap_or_default(); + rsx! { + div { + key: "q-{i}", + class: "{item_class}", + onclick: move |_| on_select.call(i), + div { class: "queue-item-info", + span { class: "queue-item-title", "{title}" } + if !artist.is_empty() { + span { class: "queue-item-artist", "{artist}" } + } + } + button { + class: "btn btn-sm btn-ghost queue-item-remove", + onclick: move |e: Event| { + e.stop_propagation(); + on_remove.call(i); + }, + "\u{2715}" + } + } + } + } + } + } + } + } + } } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn test_play_queue_is_empty() { - let queue = PlayQueue::default(); - assert!(queue.is_empty()); - } + #[test] + fn test_play_queue_is_empty() { + let queue = PlayQueue::default(); + assert!(queue.is_empty()); + } - #[test] - fn test_play_queue_add() { - let mut queue = PlayQueue::default(); - queue.add(QueueItem { - media_id: "test1".to_string(), - title: "Test Song".to_string(), - artist: Some("Test Artist".to_string()), - duration_secs: Some(180.0), - media_type: "audio".to_string(), - stream_url: "/stream/test1".to_string(), - thumbnail_url: None, - }); - assert!(!queue.is_empty()); - assert_eq!(queue.items.len(), 1); - } + #[test] + fn test_play_queue_add() { + let mut queue = PlayQueue::default(); + queue.add(QueueItem { + media_id: "test1".to_string(), + title: "Test Song".to_string(), + artist: Some("Test Artist".to_string()), + duration_secs: Some(180.0), + media_type: "audio".to_string(), + stream_url: "/stream/test1".to_string(), + thumbnail_url: None, + }); + assert!(!queue.is_empty()); + assert_eq!(queue.items.len(), 1); + } - #[test] - fn test_play_queue_current() { - let mut queue = PlayQueue::default(); - assert!(queue.current().is_none()); + #[test] + fn test_play_queue_current() { + let mut queue = PlayQueue::default(); + assert!(queue.current().is_none()); - queue.add(QueueItem { - media_id: "test1".to_string(), - title: "Test Song".to_string(), - artist: None, - duration_secs: None, - media_type: "audio".to_string(), - stream_url: "/stream/test1".to_string(), - thumbnail_url: None, - }); + queue.add(QueueItem { + media_id: "test1".to_string(), + title: "Test Song".to_string(), + artist: None, + duration_secs: None, + media_type: "audio".to_string(), + stream_url: "/stream/test1".to_string(), + thumbnail_url: None, + }); - assert!(queue.current().is_some()); - assert_eq!(queue.current().unwrap().media_id, "test1"); - } + assert!(queue.current().is_some()); + assert_eq!(queue.current().unwrap().media_id, "test1"); + } - #[test] - fn test_play_queue_next() { - let mut queue = PlayQueue::default(); - queue.repeat = RepeatMode::Off; + #[test] + fn test_play_queue_next() { + let mut queue = PlayQueue::default(); + queue.repeat = RepeatMode::Off; - queue.add(QueueItem { - media_id: "test1".to_string(), - title: "Song 1".to_string(), - artist: None, - duration_secs: None, - media_type: "audio".to_string(), - stream_url: "/stream/test1".to_string(), - thumbnail_url: None, - }); - queue.add(QueueItem { - media_id: "test2".to_string(), - title: "Song 2".to_string(), - artist: None, - duration_secs: None, - media_type: "audio".to_string(), - stream_url: "/stream/test2".to_string(), - thumbnail_url: None, - }); + queue.add(QueueItem { + media_id: "test1".to_string(), + title: "Song 1".to_string(), + artist: None, + duration_secs: None, + media_type: "audio".to_string(), + stream_url: "/stream/test1".to_string(), + thumbnail_url: None, + }); + queue.add(QueueItem { + media_id: "test2".to_string(), + title: "Song 2".to_string(), + artist: None, + duration_secs: None, + media_type: "audio".to_string(), + stream_url: "/stream/test2".to_string(), + thumbnail_url: None, + }); - let next = queue.next(); - assert!(next.is_some()); - assert_eq!(next.unwrap().media_id, "test2"); - } + let next = queue.next(); + assert!(next.is_some()); + assert_eq!(next.unwrap().media_id, "test2"); + } - #[test] - fn test_play_queue_previous() { - let mut queue = PlayQueue::default(); - queue.add(QueueItem { - media_id: "test1".to_string(), - title: "Song 1".to_string(), - artist: None, - duration_secs: None, - media_type: "audio".to_string(), - stream_url: "/stream/test1".to_string(), - thumbnail_url: None, - }); - queue.add(QueueItem { - media_id: "test2".to_string(), - title: "Song 2".to_string(), - artist: None, - duration_secs: None, - media_type: "audio".to_string(), - stream_url: "/stream/test2".to_string(), - thumbnail_url: None, - }); + #[test] + fn test_play_queue_previous() { + let mut queue = PlayQueue::default(); + queue.add(QueueItem { + media_id: "test1".to_string(), + title: "Song 1".to_string(), + artist: None, + duration_secs: None, + media_type: "audio".to_string(), + stream_url: "/stream/test1".to_string(), + thumbnail_url: None, + }); + queue.add(QueueItem { + media_id: "test2".to_string(), + title: "Song 2".to_string(), + artist: None, + duration_secs: None, + media_type: "audio".to_string(), + stream_url: "/stream/test2".to_string(), + thumbnail_url: None, + }); - queue.current_index = 1; - let prev = queue.previous(); - assert!(prev.is_some()); - assert_eq!(prev.unwrap().media_id, "test1"); - } + queue.current_index = 1; + let prev = queue.previous(); + assert!(prev.is_some()); + assert_eq!(prev.unwrap().media_id, "test1"); + } - #[test] - fn test_play_queue_remove() { - let mut queue = PlayQueue::default(); - queue.add(QueueItem { - media_id: "test1".to_string(), - title: "Song 1".to_string(), - artist: None, - duration_secs: None, - media_type: "audio".to_string(), - stream_url: "/stream/test1".to_string(), - thumbnail_url: None, - }); - queue.add(QueueItem { - media_id: "test2".to_string(), - title: "Song 2".to_string(), - artist: None, - duration_secs: None, - media_type: "audio".to_string(), - stream_url: "/stream/test2".to_string(), - thumbnail_url: None, - }); + #[test] + fn test_play_queue_remove() { + let mut queue = PlayQueue::default(); + queue.add(QueueItem { + media_id: "test1".to_string(), + title: "Song 1".to_string(), + artist: None, + duration_secs: None, + media_type: "audio".to_string(), + stream_url: "/stream/test1".to_string(), + thumbnail_url: None, + }); + queue.add(QueueItem { + media_id: "test2".to_string(), + title: "Song 2".to_string(), + artist: None, + duration_secs: None, + media_type: "audio".to_string(), + stream_url: "/stream/test2".to_string(), + thumbnail_url: None, + }); - queue.remove(0); - assert_eq!(queue.items.len(), 1); - assert_eq!(queue.items[0].media_id, "test2"); - } + queue.remove(0); + assert_eq!(queue.items.len(), 1); + assert_eq!(queue.items[0].media_id, "test2"); + } - #[test] - fn test_play_queue_clear() { - let mut queue = PlayQueue::default(); - queue.add(QueueItem { - media_id: "test1".to_string(), - title: "Song 1".to_string(), - artist: None, - duration_secs: None, - media_type: "audio".to_string(), - stream_url: "/stream/test1".to_string(), - thumbnail_url: None, - }); + #[test] + fn test_play_queue_clear() { + let mut queue = PlayQueue::default(); + queue.add(QueueItem { + media_id: "test1".to_string(), + title: "Song 1".to_string(), + artist: None, + duration_secs: None, + media_type: "audio".to_string(), + stream_url: "/stream/test1".to_string(), + thumbnail_url: None, + }); - queue.clear(); - assert!(queue.is_empty()); - assert_eq!(queue.current_index, 0); - } + queue.clear(); + assert!(queue.is_empty()); + assert_eq!(queue.current_index, 0); + } - #[test] - fn test_play_queue_toggle_repeat() { - let mut queue = PlayQueue::default(); - assert_eq!(queue.repeat, RepeatMode::Off); + #[test] + fn test_play_queue_toggle_repeat() { + let mut queue = PlayQueue::default(); + assert_eq!(queue.repeat, RepeatMode::Off); - queue.toggle_repeat(); - assert_eq!(queue.repeat, RepeatMode::All); + queue.toggle_repeat(); + assert_eq!(queue.repeat, RepeatMode::All); - queue.toggle_repeat(); - assert_eq!(queue.repeat, RepeatMode::One); + queue.toggle_repeat(); + assert_eq!(queue.repeat, RepeatMode::One); - queue.toggle_repeat(); - assert_eq!(queue.repeat, RepeatMode::Off); - } + queue.toggle_repeat(); + assert_eq!(queue.repeat, RepeatMode::Off); + } - #[test] - fn test_play_queue_toggle_shuffle() { - let mut queue = PlayQueue::default(); - assert!(!queue.shuffle); + #[test] + fn test_play_queue_toggle_shuffle() { + let mut queue = PlayQueue::default(); + assert!(!queue.shuffle); - queue.toggle_shuffle(); - assert!(queue.shuffle); + queue.toggle_shuffle(); + assert!(queue.shuffle); - queue.toggle_shuffle(); - assert!(!queue.shuffle); - } + queue.toggle_shuffle(); + assert!(!queue.shuffle); + } } diff --git a/crates/pinakes-ui/src/components/pagination.rs b/crates/pinakes-ui/src/components/pagination.rs index 4db096c..c39dc0e 100644 --- a/crates/pinakes-ui/src/components/pagination.rs +++ b/crates/pinakes-ui/src/components/pagination.rs @@ -2,101 +2,101 @@ use dioxus::prelude::*; #[component] pub fn Pagination( - current_page: u64, - total_pages: u64, - on_page_change: EventHandler, + current_page: u64, + total_pages: u64, + on_page_change: EventHandler, ) -> Element { - if total_pages <= 1 { - return rsx! {}; - } + if total_pages <= 1 { + return rsx! {}; + } - let pages = pagination_range(current_page, total_pages); + let pages = pagination_range(current_page, total_pages); - rsx! { - div { class: "pagination", - button { - class: "btn btn-sm btn-secondary", - disabled: current_page == 0, - onclick: move |_| { - if current_page > 0 { - on_page_change.call(current_page - 1); - } - }, - "Prev" - } + rsx! { + div { class: "pagination", + button { + class: "btn btn-sm btn-secondary", + disabled: current_page == 0, + onclick: move |_| { + if current_page > 0 { + on_page_change.call(current_page - 1); + } + }, + "Prev" + } - for page in pages { - if page == u64::MAX { - span { class: "page-ellipsis", "..." } - } else { - { - let btn_class = if page == current_page { - "btn btn-sm btn-primary page-btn" - } else { - "btn btn-sm btn-ghost page-btn" - }; - rsx! { - button { - key: "page-{page}", - class: "{btn_class}", - onclick: move |_| on_page_change.call(page), - "{page + 1}" - } - } - } - } - } + for page in pages { + if page == u64::MAX { + span { class: "page-ellipsis", "..." } + } else { + { + let btn_class = if page == current_page { + "btn btn-sm btn-primary page-btn" + } else { + "btn btn-sm btn-ghost page-btn" + }; + rsx! { + button { + key: "page-{page}", + class: "{btn_class}", + onclick: move |_| on_page_change.call(page), + "{page + 1}" + } + } + } + } + } - button { - class: "btn btn-sm btn-secondary", - disabled: current_page >= total_pages - 1, - onclick: move |_| { - if current_page < total_pages - 1 { - on_page_change.call(current_page + 1); - } - }, - "Next" - } - } - } + button { + class: "btn btn-sm btn-secondary", + disabled: current_page >= total_pages - 1, + onclick: move |_| { + if current_page < total_pages - 1 { + on_page_change.call(current_page + 1); + } + }, + "Next" + } + } + } } /// Compute a range of page numbers to display (with ellipsis as u64::MAX). pub fn pagination_range(current: u64, total: u64) -> Vec { - let mut pages = Vec::new(); - if total <= 7 { - for i in 0..total { - pages.push(i); - } - return pages; + let mut pages = Vec::new(); + if total <= 7 { + for i in 0..total { + pages.push(i); } + return pages; + } - pages.push(0); + pages.push(0); - if current > 2 { - pages.push(u64::MAX); + if current > 2 { + pages.push(u64::MAX); + } + + let start = if current <= 2 { 1 } else { current - 1 }; + let end = if current >= total - 3 { + total - 1 + } else { + current + 2 + }; + + for i in start..end { + if !pages.contains(&i) { + pages.push(i); } + } - let start = if current <= 2 { 1 } else { current - 1 }; - let end = if current >= total - 3 { - total - 1 - } else { - current + 2 - }; + if current < total - 3 { + pages.push(u64::MAX); + } - for i in start..end { - if !pages.contains(&i) { - pages.push(i); - } - } + if !pages.contains(&(total - 1)) { + pages.push(total - 1); + } - if current < total - 3 { - pages.push(u64::MAX); - } - - if !pages.contains(&(total - 1)) { - pages.push(total - 1); - } - - pages + pages } diff --git a/crates/pinakes-ui/src/components/pdf_viewer.rs b/crates/pinakes-ui/src/components/pdf_viewer.rs index 9625076..1d41800 100644 --- a/crates/pinakes-ui/src/components/pdf_viewer.rs +++ b/crates/pinakes-ui/src/components/pdf_viewer.rs @@ -2,111 +2,111 @@ use dioxus::prelude::*; #[component] pub fn PdfViewer( - src: String, - #[props(default = 1)] initial_page: usize, - #[props(default = 100)] initial_zoom: usize, + src: String, + #[props(default = 1)] initial_page: usize, + #[props(default = 100)] initial_zoom: usize, ) -> Element { - let current_page = use_signal(|| initial_page); - let mut zoom_level = use_signal(|| initial_zoom); - let mut loading = use_signal(|| true); - let mut error = use_signal(|| Option::::None); + let current_page = use_signal(|| initial_page); + let mut zoom_level = use_signal(|| initial_zoom); + let mut loading = use_signal(|| true); + let mut error = use_signal(|| Option::::None); - // For navigation controls - let zoom = *zoom_level.read(); - let page = *current_page.read(); + // For navigation controls + let zoom = *zoom_level.read(); + let page = *current_page.read(); - rsx! { - div { class: "pdf-viewer", - // Toolbar - div { class: "pdf-toolbar", - div { class: "pdf-toolbar-group", - button { - class: "pdf-toolbar-btn", - title: "Zoom out", - disabled: zoom <= 50, - onclick: move |_| { - let new_zoom = (*zoom_level.read()).saturating_sub(25).max(50); - zoom_level.set(new_zoom); - }, - "\u{2212}" // minus - } - span { class: "pdf-zoom-label", "{zoom}%" } - button { - class: "pdf-toolbar-btn", - title: "Zoom in", - disabled: zoom >= 200, - onclick: move |_| { - let new_zoom = (*zoom_level.read() + 25).min(200); - zoom_level.set(new_zoom); - }, - "+" // plus - } - } - div { class: "pdf-toolbar-group", - button { - class: "pdf-toolbar-btn", - title: "Fit to width", - onclick: move |_| zoom_level.set(100), - "\u{2194}" // left-right arrow - } - } - } + rsx! { + div { class: "pdf-viewer", + // Toolbar + div { class: "pdf-toolbar", + div { class: "pdf-toolbar-group", + button { + class: "pdf-toolbar-btn", + title: "Zoom out", + disabled: zoom <= 50, + onclick: move |_| { + let new_zoom = (*zoom_level.read()).saturating_sub(25).max(50); + zoom_level.set(new_zoom); + }, + "\u{2212}" // minus + } + span { class: "pdf-zoom-label", "{zoom}%" } + button { + class: "pdf-toolbar-btn", + title: "Zoom in", + disabled: zoom >= 200, + onclick: move |_| { + let new_zoom = (*zoom_level.read() + 25).min(200); + zoom_level.set(new_zoom); + }, + "+" // plus + } + } + div { class: "pdf-toolbar-group", + button { + class: "pdf-toolbar-btn", + title: "Fit to width", + onclick: move |_| zoom_level.set(100), + "\u{2194}" // left-right arrow + } + } + } - // PDF embed container - div { class: "pdf-container", - if *loading.read() { - div { class: "pdf-loading", - div { class: "spinner" } - span { "Loading PDF..." } - } - } + // PDF embed container + div { class: "pdf-container", + if *loading.read() { + div { class: "pdf-loading", + div { class: "spinner" } + span { "Loading PDF..." } + } + } - if let Some(ref err) = *error.read() { - div { class: "pdf-error", - p { "{err}" } - a { - href: "{src}", - target: "_blank", - class: "btn btn-primary", - "Download PDF" - } - } - } + if let Some(ref err) = *error.read() { + div { class: "pdf-error", + p { "{err}" } + a { + href: "{src}", + target: "_blank", + class: "btn btn-primary", + "Download PDF" + } + } + } - // Use object/embed for PDF rendering - // The webview should handle PDF rendering natively - object { - class: "pdf-object", - r#type: "application/pdf", - data: "{src}#zoom={zoom}&page={page}", - width: "100%", - height: "100%", - onload: move |_| { - loading.set(false); - error.set(None); - }, - onerror: move |_| { - loading.set(false); - error - .set( - Some( - "Unable to display PDF. Your browser may not support embedded PDF viewing." - .to_string(), - ), - ); - }, - // Fallback content - div { class: "pdf-fallback", - p { "PDF preview is not available in this browser." } - a { - href: "{src}", - target: "_blank", - class: "btn btn-primary", - "Download PDF" - } - } - } - } - } - } + // Use object/embed for PDF rendering + // The webview should handle PDF rendering natively + object { + class: "pdf-object", + r#type: "application/pdf", + data: "{src}#zoom={zoom}&page={page}", + width: "100%", + height: "100%", + onload: move |_| { + loading.set(false); + error.set(None); + }, + onerror: move |_| { + loading.set(false); + error + .set( + Some( + "Unable to display PDF. Your browser may not support embedded PDF viewing." + .to_string(), + ), + ); + }, + // Fallback content + div { class: "pdf-fallback", + p { "PDF preview is not available in this browser." } + a { + href: "{src}", + target: "_blank", + class: "btn btn-primary", + "Download PDF" + } + } + } + } + } + } } diff --git a/crates/pinakes-ui/src/components/search.rs b/crates/pinakes-ui/src/components/search.rs index 9b7ea61..704f4a2 100644 --- a/crates/pinakes-ui/src/components/search.rs +++ b/crates/pinakes-ui/src/components/search.rs @@ -1,427 +1,433 @@ use dioxus::prelude::*; -use super::pagination::Pagination as PaginationControls; -use super::utils::{format_size, type_badge_class, type_icon}; +use super::{ + pagination::Pagination as PaginationControls, + utils::{format_size, type_badge_class, type_icon}, +}; use crate::client::{MediaResponse, SavedSearchResponse}; #[component] pub fn Search( - results: Vec, - total_count: u64, - search_page: u64, - page_size: u64, - on_search: EventHandler<(String, Option)>, - on_select: EventHandler, - on_page_change: EventHandler, - server_url: String, - #[props(default)] saved_searches: Vec, - #[props(default)] on_save_search: Option)>>, - #[props(default)] on_delete_saved_search: Option>, - #[props(default)] on_load_saved_search: Option>, + results: Vec, + total_count: u64, + search_page: u64, + page_size: u64, + on_search: EventHandler<(String, Option)>, + on_select: EventHandler, + on_page_change: EventHandler, + server_url: String, + #[props(default)] saved_searches: Vec, + #[props(default)] on_save_search: Option< + EventHandler<(String, String, Option)>, + >, + #[props(default)] on_delete_saved_search: Option>, + #[props(default)] on_load_saved_search: Option< + EventHandler, + >, ) -> Element { - let mut query = use_signal(String::new); - let mut sort_by = use_signal(|| String::from("relevance")); - let mut show_help = use_signal(|| false); - let mut show_save_dialog = use_signal(|| false); - let mut save_name = use_signal(String::new); - let mut show_saved_list = use_signal(|| false); - // 0 = table, 1 = grid - let mut view_mode = use_signal(|| 0u8); + let mut query = use_signal(String::new); + let mut sort_by = use_signal(|| String::from("relevance")); + let mut show_help = use_signal(|| false); + let mut show_save_dialog = use_signal(|| false); + let mut save_name = use_signal(String::new); + let mut show_saved_list = use_signal(|| false); + // 0 = table, 1 = grid + let mut view_mode = use_signal(|| 0u8); - let do_search = { - let query = query; - let sort_by = sort_by; - move |_| { - let q = query.read().clone(); - let s = sort_by.read().clone(); - let sort = if s == "relevance" || s.is_empty() { - None - } else { - Some(s) - }; - on_search.call((q, sort)); - } - }; - - let on_key = { - let query = query; - let sort_by = sort_by; - move |e: KeyboardEvent| { - if e.key() == Key::Enter { - let q = query.read().clone(); - let s = sort_by.read().clone(); - let sort = if s == "relevance" || s.is_empty() { - None - } else { - Some(s) - }; - on_search.call((q, sort)); - } - } - }; - - let toggle_help = move |_| { - let current = *show_help.read(); - show_help.set(!current); - }; - - let help_visible = *show_help.read(); - let current_mode = *view_mode.read(); - let total_pages = if page_size > 0 { - total_count.div_ceil(page_size) - } else { - 1 - }; - - rsx! { - div { class: "form-row mb-16", - input { - r#type: "text", - placeholder: "Search media...", - value: "{query}", - oninput: move |e| query.set(e.value()), - onkeypress: on_key, - } - select { value: "{sort_by}", onchange: move |e| sort_by.set(e.value()), - option { value: "relevance", "Relevance" } - option { value: "date_desc", "Newest" } - option { value: "date_asc", "Oldest" } - option { value: "name_asc", "Name A-Z" } - option { value: "name_desc", "Name Z-A" } - option { value: "size_desc", "Size (largest)" } - option { value: "size_asc", "Size (smallest)" } - } - button { class: "btn btn-primary", onclick: do_search, "Search" } - button { class: "btn btn-ghost", onclick: toggle_help, "Syntax Help" } - - // Save/Load search buttons - if on_save_search.is_some() { - button { - class: "btn btn-secondary", - disabled: query.read().is_empty(), - onclick: move |_| show_save_dialog.set(true), - "Save" - } - } - if !saved_searches.is_empty() { - button { - class: "btn btn-ghost", - onclick: move |_| show_saved_list.toggle(), - "Saved ({saved_searches.len()})" - } - } - - // View mode toggle - div { class: "view-toggle", - button { - class: if current_mode == 1 { "view-btn active" } else { "view-btn" }, - onclick: move |_| view_mode.set(1), - title: "Grid view", - "\u{25a6}" - } - button { - class: if current_mode == 0 { "view-btn active" } else { "view-btn" }, - onclick: move |_| view_mode.set(0), - title: "Table view", - "\u{2630}" - } - } - } - - if help_visible { - div { class: "card mb-16", - h4 { "Search Syntax" } - ul { - li { - code { "hello world" } - " -- full text search (implicit AND)" - } - li { - code { "artist:Beatles" } - " -- field match" - } - li { - code { "type:pdf" } - " -- filter by media type" - } - li { - code { "tag:music" } - " -- filter by tag" - } - li { - code { "hello OR world" } - " -- OR operator" - } - li { - code { "-excluded" } - " -- NOT (exclude term)" - } - li { - code { "hel*" } - " -- prefix search" - } - li { - code { "hello~" } - " -- fuzzy search" - } - li { - code { "\"exact phrase\"" } - " -- quoted exact match" - } - } - } - } - - // Save search dialog - if *show_save_dialog.read() { - div { - class: "modal-overlay", - onclick: move |_| show_save_dialog.set(false), - div { - class: "modal-content", - onclick: move |evt: MouseEvent| evt.stop_propagation(), - h3 { "Save Search" } - div { class: "form-field", - label { "Name" } - input { - r#type: "text", - placeholder: "Enter a name for this search...", - value: "{save_name}", - oninput: move |e| save_name.set(e.value()), - onkeypress: { - let query = query.read().clone(); - let sort = sort_by.read().clone(); - let handler = on_save_search; - move |e: KeyboardEvent| { - if e.key() == Key::Enter { - let name = save_name.read().clone(); - if !name.is_empty() { - let sort_opt = if sort == "relevance" { - None - } else { - Some(sort.clone()) - }; - if let Some(ref h) = handler { - h.call((name, query.clone(), sort_opt)); - } - show_save_dialog.set(false); - save_name.set(String::new()); - } - } - } - }, - } - } - p { class: "text-muted text-sm", "Query: {query}" } - div { class: "modal-actions", - button { - class: "btn btn-ghost", - onclick: move |_| { - show_save_dialog.set(false); - save_name.set(String::new()); - }, - "Cancel" - } - button { - class: "btn btn-primary", - disabled: save_name.read().is_empty(), - onclick: { - let query_val = query.read().clone(); - let sort_val = sort_by.read().clone(); - let handler = on_save_search; - move |_| { - let name = save_name.read().clone(); - if !name.is_empty() { - let sort_opt = if sort_val == "relevance" { - None - } else { - Some(sort_val.clone()) - }; - if let Some(ref h) = handler { - h.call((name, query_val.clone(), sort_opt)); - } - show_save_dialog.set(false); - save_name.set(String::new()); - } - } - }, - "Save" - } - } - } - } - } - - // Saved searches list - if *show_saved_list.read() && !saved_searches.is_empty() { - div { class: "card mb-16", - div { class: "card-header", - h4 { "Saved Searches" } - button { - class: "btn btn-ghost btn-sm", - onclick: move |_| show_saved_list.set(false), - "Close" - } - } - div { class: "saved-searches-list", - for search in saved_searches.iter() { - { - let search_clone = search.clone(); - let id_for_delete = search.id.clone(); - let load_handler = on_load_saved_search; - let delete_handler = on_delete_saved_search; - rsx! { - div { class: "saved-search-item", key: "{search.id}", - div { - class: "saved-search-info", - onclick: { - let sc = search_clone.clone(); - move |_| { - if let Some(ref h) = load_handler { - h.call(sc.clone()); - } - query.set(sc.query.clone()); - if let Some(ref s) = sc.sort_order { - sort_by.set(s.clone()); - } else { - sort_by.set("relevance".to_string()); - } - show_saved_list.set(false); - } - }, - span { class: "saved-search-name", "{search.name}" } - span { class: "saved-search-query text-muted", "{search.query}" } - } - button { - class: "btn btn-danger btn-sm", - onclick: { - let id = id_for_delete.clone(); - move |evt: MouseEvent| { - evt.stop_propagation(); - if let Some(ref h) = delete_handler { - h.call(id.clone()); - } - } - }, - "Delete" - } - } - } - } - } - } - } - } - - p { class: "text-muted text-sm mb-8", "Results: {total_count}" } - - if results.is_empty() && query.read().is_empty() { - div { class: "empty-state", - h3 { class: "empty-title", "Search your media" } - p { class: "empty-subtitle", - "Enter a query above to find files by name, metadata, tags, or type." - } - } - } - - if results.is_empty() && !query.read().is_empty() { - div { class: "empty-state", - h3 { class: "empty-title", "No results found" } - p { class: "empty-subtitle", "Try a different query or check the syntax help." } - } - } - - // Content: grid or table - match current_mode { - 1 => rsx! { - div { class: "media-grid", - for item in results.iter() { - { - let badge_class = type_badge_class(&item.media_type); - let card_click = { - let id = item.id.clone(); - move |_| on_select.call(id.clone()) - }; - - - - let thumb_url = if item.has_thumbnail { - format!("{}/api/v1/media/{}/thumbnail", server_url, item.id) - } else { - String::new() - }; - let has_thumb = item.has_thumbnail; - let media_type = item.media_type.clone(); - - rsx! { - - - - div { key: "{item.id}", class: "media-card", onclick: card_click, - - div { class: "card-thumbnail", - if has_thumb { - img { - src: "{thumb_url}", - alt: "{item.file_name}", - loading: "lazy", - } - } else { - div { class: "card-type-icon {badge_class}", "{type_icon(&media_type)}" } - } - } - - div { class: "card-info", - div { class: "card-name", title: "{item.file_name}", "{item.file_name}" } - div { class: "card-meta", - span { class: "type-badge {badge_class}", "{item.media_type}" } - span { class: "card-size", "{format_size(item.file_size)}" } - } - } - } - } - } - } - } - }, - _ => rsx! { - table { class: "data-table", - thead { - tr { - th { "Name" } - th { "Type" } - th { "Artist" } - th { "Size" } - } - } - tbody { - for item in results.iter() { - { - let artist = item.artist.clone().unwrap_or_default(); - let size = format_size(item.file_size); - let badge_class = type_badge_class(&item.media_type); - let row_click = { - let id = item.id.clone(); - move |_| on_select.call(id.clone()) - }; - rsx! { - tr { key: "{item.id}", onclick: row_click, - td { "{item.file_name}" } - td { - span { class: "type-badge {badge_class}", "{item.media_type}" } - } - td { "{artist}" } - td { "{size}" } - } - } - } - } - } - } - }, - } - - // Pagination controls - PaginationControls { current_page: search_page, total_pages, on_page_change } + let do_search = { + let query = query; + let sort_by = sort_by; + move |_| { + let q = query.read().clone(); + let s = sort_by.read().clone(); + let sort = if s == "relevance" || s.is_empty() { + None + } else { + Some(s) + }; + on_search.call((q, sort)); } + }; + + let on_key = { + let query = query; + let sort_by = sort_by; + move |e: KeyboardEvent| { + if e.key() == Key::Enter { + let q = query.read().clone(); + let s = sort_by.read().clone(); + let sort = if s == "relevance" || s.is_empty() { + None + } else { + Some(s) + }; + on_search.call((q, sort)); + } + } + }; + + let toggle_help = move |_| { + let current = *show_help.read(); + show_help.set(!current); + }; + + let help_visible = *show_help.read(); + let current_mode = *view_mode.read(); + let total_pages = if page_size > 0 { + total_count.div_ceil(page_size) + } else { + 1 + }; + + rsx! { + div { class: "form-row mb-16", + input { + r#type: "text", + placeholder: "Search media...", + value: "{query}", + oninput: move |e| query.set(e.value()), + onkeypress: on_key, + } + select { value: "{sort_by}", onchange: move |e| sort_by.set(e.value()), + option { value: "relevance", "Relevance" } + option { value: "date_desc", "Newest" } + option { value: "date_asc", "Oldest" } + option { value: "name_asc", "Name A-Z" } + option { value: "name_desc", "Name Z-A" } + option { value: "size_desc", "Size (largest)" } + option { value: "size_asc", "Size (smallest)" } + } + button { class: "btn btn-primary", onclick: do_search, "Search" } + button { class: "btn btn-ghost", onclick: toggle_help, "Syntax Help" } + + // Save/Load search buttons + if on_save_search.is_some() { + button { + class: "btn btn-secondary", + disabled: query.read().is_empty(), + onclick: move |_| show_save_dialog.set(true), + "Save" + } + } + if !saved_searches.is_empty() { + button { + class: "btn btn-ghost", + onclick: move |_| show_saved_list.toggle(), + "Saved ({saved_searches.len()})" + } + } + + // View mode toggle + div { class: "view-toggle", + button { + class: if current_mode == 1 { "view-btn active" } else { "view-btn" }, + onclick: move |_| view_mode.set(1), + title: "Grid view", + "\u{25a6}" + } + button { + class: if current_mode == 0 { "view-btn active" } else { "view-btn" }, + onclick: move |_| view_mode.set(0), + title: "Table view", + "\u{2630}" + } + } + } + + if help_visible { + div { class: "card mb-16", + h4 { "Search Syntax" } + ul { + li { + code { "hello world" } + " -- full text search (implicit AND)" + } + li { + code { "artist:Beatles" } + " -- field match" + } + li { + code { "type:pdf" } + " -- filter by media type" + } + li { + code { "tag:music" } + " -- filter by tag" + } + li { + code { "hello OR world" } + " -- OR operator" + } + li { + code { "-excluded" } + " -- NOT (exclude term)" + } + li { + code { "hel*" } + " -- prefix search" + } + li { + code { "hello~" } + " -- fuzzy search" + } + li { + code { "\"exact phrase\"" } + " -- quoted exact match" + } + } + } + } + + // Save search dialog + if *show_save_dialog.read() { + div { + class: "modal-overlay", + onclick: move |_| show_save_dialog.set(false), + div { + class: "modal-content", + onclick: move |evt: MouseEvent| evt.stop_propagation(), + h3 { "Save Search" } + div { class: "form-field", + label { "Name" } + input { + r#type: "text", + placeholder: "Enter a name for this search...", + value: "{save_name}", + oninput: move |e| save_name.set(e.value()), + onkeypress: { + let query = query.read().clone(); + let sort = sort_by.read().clone(); + let handler = on_save_search; + move |e: KeyboardEvent| { + if e.key() == Key::Enter { + let name = save_name.read().clone(); + if !name.is_empty() { + let sort_opt = if sort == "relevance" { + None + } else { + Some(sort.clone()) + }; + if let Some(ref h) = handler { + h.call((name, query.clone(), sort_opt)); + } + show_save_dialog.set(false); + save_name.set(String::new()); + } + } + } + }, + } + } + p { class: "text-muted text-sm", "Query: {query}" } + div { class: "modal-actions", + button { + class: "btn btn-ghost", + onclick: move |_| { + show_save_dialog.set(false); + save_name.set(String::new()); + }, + "Cancel" + } + button { + class: "btn btn-primary", + disabled: save_name.read().is_empty(), + onclick: { + let query_val = query.read().clone(); + let sort_val = sort_by.read().clone(); + let handler = on_save_search; + move |_| { + let name = save_name.read().clone(); + if !name.is_empty() { + let sort_opt = if sort_val == "relevance" { + None + } else { + Some(sort_val.clone()) + }; + if let Some(ref h) = handler { + h.call((name, query_val.clone(), sort_opt)); + } + show_save_dialog.set(false); + save_name.set(String::new()); + } + } + }, + "Save" + } + } + } + } + } + + // Saved searches list + if *show_saved_list.read() && !saved_searches.is_empty() { + div { class: "card mb-16", + div { class: "card-header", + h4 { "Saved Searches" } + button { + class: "btn btn-ghost btn-sm", + onclick: move |_| show_saved_list.set(false), + "Close" + } + } + div { class: "saved-searches-list", + for search in saved_searches.iter() { + { + let search_clone = search.clone(); + let id_for_delete = search.id.clone(); + let load_handler = on_load_saved_search; + let delete_handler = on_delete_saved_search; + rsx! { + div { class: "saved-search-item", key: "{search.id}", + div { + class: "saved-search-info", + onclick: { + let sc = search_clone.clone(); + move |_| { + if let Some(ref h) = load_handler { + h.call(sc.clone()); + } + query.set(sc.query.clone()); + if let Some(ref s) = sc.sort_order { + sort_by.set(s.clone()); + } else { + sort_by.set("relevance".to_string()); + } + show_saved_list.set(false); + } + }, + span { class: "saved-search-name", "{search.name}" } + span { class: "saved-search-query text-muted", "{search.query}" } + } + button { + class: "btn btn-danger btn-sm", + onclick: { + let id = id_for_delete.clone(); + move |evt: MouseEvent| { + evt.stop_propagation(); + if let Some(ref h) = delete_handler { + h.call(id.clone()); + } + } + }, + "Delete" + } + } + } + } + } + } + } + } + + p { class: "text-muted text-sm mb-8", "Results: {total_count}" } + + if results.is_empty() && query.read().is_empty() { + div { class: "empty-state", + h3 { class: "empty-title", "Search your media" } + p { class: "empty-subtitle", + "Enter a query above to find files by name, metadata, tags, or type." + } + } + } + + if results.is_empty() && !query.read().is_empty() { + div { class: "empty-state", + h3 { class: "empty-title", "No results found" } + p { class: "empty-subtitle", "Try a different query or check the syntax help." } + } + } + + // Content: grid or table + match current_mode { + 1 => rsx! { + div { class: "media-grid", + for item in results.iter() { + { + let badge_class = type_badge_class(&item.media_type); + let card_click = { + let id = item.id.clone(); + move |_| on_select.call(id.clone()) + }; + + + + let thumb_url = if item.has_thumbnail { + format!("{}/api/v1/media/{}/thumbnail", server_url, item.id) + } else { + String::new() + }; + let has_thumb = item.has_thumbnail; + let media_type = item.media_type.clone(); + + rsx! { + + + + div { key: "{item.id}", class: "media-card", onclick: card_click, + + div { class: "card-thumbnail", + if has_thumb { + img { + src: "{thumb_url}", + alt: "{item.file_name}", + loading: "lazy", + } + } else { + div { class: "card-type-icon {badge_class}", "{type_icon(&media_type)}" } + } + } + + div { class: "card-info", + div { class: "card-name", title: "{item.file_name}", "{item.file_name}" } + div { class: "card-meta", + span { class: "type-badge {badge_class}", "{item.media_type}" } + span { class: "card-size", "{format_size(item.file_size)}" } + } + } + } + } + } + } + } + }, + _ => rsx! { + table { class: "data-table", + thead { + tr { + th { "Name" } + th { "Type" } + th { "Artist" } + th { "Size" } + } + } + tbody { + for item in results.iter() { + { + let artist = item.artist.clone().unwrap_or_default(); + let size = format_size(item.file_size); + let badge_class = type_badge_class(&item.media_type); + let row_click = { + let id = item.id.clone(); + move |_| on_select.call(id.clone()) + }; + rsx! { + tr { key: "{item.id}", onclick: row_click, + td { "{item.file_name}" } + td { + span { class: "type-badge {badge_class}", "{item.media_type}" } + } + td { "{artist}" } + td { "{size}" } + } + } + } + } + } + } + }, + } + + // Pagination controls + PaginationControls { current_page: search_page, total_pages, on_page_change } + } } diff --git a/crates/pinakes-ui/src/components/settings.rs b/crates/pinakes-ui/src/components/settings.rs index 3ab9737..5d3fa56 100644 --- a/crates/pinakes-ui/src/components/settings.rs +++ b/crates/pinakes-ui/src/components/settings.rs @@ -4,566 +4,568 @@ use crate::client::ConfigResponse; #[component] pub fn Settings( - config: ConfigResponse, - on_add_root: EventHandler, - on_remove_root: EventHandler, - on_toggle_watch: EventHandler, - on_update_poll_interval: EventHandler, - on_update_ignore_patterns: EventHandler>, - #[props(default)] on_update_ui_config: Option>, + config: ConfigResponse, + on_add_root: EventHandler, + on_remove_root: EventHandler, + on_toggle_watch: EventHandler, + on_update_poll_interval: EventHandler, + on_update_ignore_patterns: EventHandler>, + #[props(default)] on_update_ui_config: Option< + EventHandler, + >, ) -> Element { - let mut new_root = use_signal(String::new); - let mut editing_poll = use_signal(|| false); - let mut poll_input = use_signal(String::new); - let mut poll_error = use_signal(|| Option::::None); - let mut editing_patterns = use_signal(|| false); - let mut patterns_input = use_signal(String::new); + let mut new_root = use_signal(String::new); + let mut editing_poll = use_signal(|| false); + let mut poll_input = use_signal(String::new); + let mut poll_error = use_signal(|| Option::::None); + let mut editing_patterns = use_signal(|| false); + let mut patterns_input = use_signal(String::new); - let writable = config.config_writable; - let watch_enabled = config.scanning.watch; - let host_port = format!("{}:{}", config.server.host, config.server.port); - let db_path = config.database_path.clone().unwrap_or_default(); - let root_count = config.roots.len(); + let writable = config.config_writable; + let watch_enabled = config.scanning.watch; + let host_port = format!("{}:{}", config.server.host, config.server.port); + let db_path = config.database_path.clone().unwrap_or_default(); + let root_count = config.roots.len(); - rsx! { - div { class: "settings-layout", + rsx! { + div { class: "settings-layout", - // ── Configuration Source ── - div { class: "settings-card", - div { class: "settings-card-header", - h3 { class: "settings-card-title", "Configuration Source" } - if writable { - span { class: "badge badge-success", "Writable" } - } else { - span { class: "badge badge-warning", "Read-only" } - } - } - div { class: "settings-card-body", - if let Some(ref path) = config.config_path { - div { class: "info-row", - label { class: "form-label", "Config Path" } - span { class: "info-value mono", "{path}" } - } - } - if !writable { - div { class: "settings-notice settings-notice-warning", - "Configuration is read-only. Changes cannot be persisted to disk. " - "To enable editing, ensure the config file exists and is writable by the server process." - } - } - } - } + // ── Configuration Source ── + div { class: "settings-card", + div { class: "settings-card-header", + h3 { class: "settings-card-title", "Configuration Source" } + if writable { + span { class: "badge badge-success", "Writable" } + } else { + span { class: "badge badge-warning", "Read-only" } + } + } + div { class: "settings-card-body", + if let Some(ref path) = config.config_path { + div { class: "info-row", + label { class: "form-label", "Config Path" } + span { class: "info-value mono", "{path}" } + } + } + if !writable { + div { class: "settings-notice settings-notice-warning", + "Configuration is read-only. Changes cannot be persisted to disk. " + "To enable editing, ensure the config file exists and is writable by the server process." + } + } + } + } - // ── Server Health ── - div { class: "settings-card", - div { class: "settings-card-header", - h3 { class: "settings-card-title", "Server Info" } - } - div { class: "settings-card-body", - div { class: "info-row", - div { class: "form-label-row", - label { class: "form-label", "Backend" } - span { class: "tooltip-trigger", - "?" - span { class: "tooltip-text", - "The storage backend used by the server (SQLite or PostgreSQL)." - } - } - } - span { class: "info-value badge badge-neutral", "{config.backend}" } - } - div { class: "info-row", - div { class: "form-label-row", - label { class: "form-label", "Server Address" } - span { class: "tooltip-trigger", - "?" - span { class: "tooltip-text", - "The address and port the server is listening on." - } - } - } - span { class: "info-value mono", "{host_port}" } - } - div { class: "info-row", - div { class: "form-label-row", - label { class: "form-label", "Database Path" } - span { class: "tooltip-trigger", - "?" - span { class: "tooltip-text", - "File path to the SQLite database, or connection info for PostgreSQL." - } - } - } - span { class: "info-value mono", "{db_path}" } - } - } - } + // ── Server Health ── + div { class: "settings-card", + div { class: "settings-card-header", + h3 { class: "settings-card-title", "Server Info" } + } + div { class: "settings-card-body", + div { class: "info-row", + div { class: "form-label-row", + label { class: "form-label", "Backend" } + span { class: "tooltip-trigger", + "?" + span { class: "tooltip-text", + "The storage backend used by the server (SQLite or PostgreSQL)." + } + } + } + span { class: "info-value badge badge-neutral", "{config.backend}" } + } + div { class: "info-row", + div { class: "form-label-row", + label { class: "form-label", "Server Address" } + span { class: "tooltip-trigger", + "?" + span { class: "tooltip-text", + "The address and port the server is listening on." + } + } + } + span { class: "info-value mono", "{host_port}" } + } + div { class: "info-row", + div { class: "form-label-row", + label { class: "form-label", "Database Path" } + span { class: "tooltip-trigger", + "?" + span { class: "tooltip-text", + "File path to the SQLite database, or connection info for PostgreSQL." + } + } + } + span { class: "info-value mono", "{db_path}" } + } + } + } - // ── Root Directories ── - div { class: "settings-card", - div { class: "settings-card-header", - div { class: "form-label-row", - h3 { class: "settings-card-title", "Root Directories" } - span { class: "badge badge-neutral", "{root_count}" } - } - span { class: "tooltip-trigger", - "?" - span { class: "tooltip-text", - "Directories that Pinakes scans for media files. Only existing directories can be added." - } - } - } - div { class: "settings-card-body", - if config.roots.is_empty() { - p { class: "text-muted", "No root directories configured." } - } else { - div { class: "root-list", - for root in config.roots.iter() { - div { class: "root-item", key: "{root}", - span { class: "mono root-path", "{root}" } - button { - class: "btn btn-danger btn-sm", - disabled: !writable, - onclick: { - let root = root.clone(); - move |_| { - if writable { - on_remove_root.call(root.clone()); - } - } - }, - "Remove" - } - } - } - } - } + // ── Root Directories ── + div { class: "settings-card", + div { class: "settings-card-header", + div { class: "form-label-row", + h3 { class: "settings-card-title", "Root Directories" } + span { class: "badge badge-neutral", "{root_count}" } + } + span { class: "tooltip-trigger", + "?" + span { class: "tooltip-text", + "Directories that Pinakes scans for media files. Only existing directories can be added." + } + } + } + div { class: "settings-card-body", + if config.roots.is_empty() { + p { class: "text-muted", "No root directories configured." } + } else { + div { class: "root-list", + for root in config.roots.iter() { + div { class: "root-item", key: "{root}", + span { class: "mono root-path", "{root}" } + button { + class: "btn btn-danger btn-sm", + disabled: !writable, + onclick: { + let root = root.clone(); + move |_| { + if writable { + on_remove_root.call(root.clone()); + } + } + }, + "Remove" + } + } + } + } + } - div { class: "form-row", - input { - r#type: "text", - placeholder: "/path/to/root...", - value: "{new_root}", - disabled: !writable, - oninput: move |e| new_root.set(e.value()), - onkeypress: move |e: KeyboardEvent| { - if writable && e.key() == Key::Enter { - let path = new_root.read().clone(); - if !path.is_empty() { - on_add_root.call(path); - new_root.set(String::new()); - } - } - }, - } - button { - class: "btn btn-secondary", - disabled: !writable, - onclick: move |_| { - if writable { - let mut new_root = new_root; - spawn(async move { - if let Some(handle) = rfd::AsyncFileDialog::new().pick_folder().await { - new_root.set(handle.path().to_string_lossy().to_string()); - } - }); - } - }, - "Browse..." - } - button { - class: "btn btn-primary", - disabled: !writable, - onclick: move |_| { - if writable { - let path = new_root.read().clone(); - if !path.is_empty() { - on_add_root.call(path); - new_root.set(String::new()); - } - } - }, - "Add Root" - } - } - } - } + div { class: "form-row", + input { + r#type: "text", + placeholder: "/path/to/root...", + value: "{new_root}", + disabled: !writable, + oninput: move |e| new_root.set(e.value()), + onkeypress: move |e: KeyboardEvent| { + if writable && e.key() == Key::Enter { + let path = new_root.read().clone(); + if !path.is_empty() { + on_add_root.call(path); + new_root.set(String::new()); + } + } + }, + } + button { + class: "btn btn-secondary", + disabled: !writable, + onclick: move |_| { + if writable { + let mut new_root = new_root; + spawn(async move { + if let Some(handle) = rfd::AsyncFileDialog::new().pick_folder().await { + new_root.set(handle.path().to_string_lossy().to_string()); + } + }); + } + }, + "Browse..." + } + button { + class: "btn btn-primary", + disabled: !writable, + onclick: move |_| { + if writable { + let path = new_root.read().clone(); + if !path.is_empty() { + on_add_root.call(path); + new_root.set(String::new()); + } + } + }, + "Add Root" + } + } + } + } - // ── Scanning ── - div { class: "settings-card", - div { class: "settings-card-header", - h3 { class: "settings-card-title", "Scanning" } - } - div { class: "settings-card-body", + // ── Scanning ── + div { class: "settings-card", + div { class: "settings-card-header", + h3 { class: "settings-card-title", "Scanning" } + } + div { class: "settings-card-body", - // File watching toggle - div { class: "settings-field", - div { class: "form-label-row", - label { class: "form-label", "File Watching" } - span { class: "tooltip-trigger", - "?" - span { class: "tooltip-text", - "When enabled, Pinakes monitors root directories for new, modified, or deleted files in real time using filesystem events." - } - } - } - div { - class: if writable { "toggle" } else { "toggle toggle-disabled" }, - onclick: move |_| { - if writable { - on_toggle_watch.call(!watch_enabled); - } - }, - div { class: if watch_enabled { "toggle-track active" } else { "toggle-track" }, - div { class: "toggle-thumb" } - } - } - } + // File watching toggle + div { class: "settings-field", + div { class: "form-label-row", + label { class: "form-label", "File Watching" } + span { class: "tooltip-trigger", + "?" + span { class: "tooltip-text", + "When enabled, Pinakes monitors root directories for new, modified, or deleted files in real time using filesystem events." + } + } + } + div { + class: if writable { "toggle" } else { "toggle toggle-disabled" }, + onclick: move |_| { + if writable { + on_toggle_watch.call(!watch_enabled); + } + }, + div { class: if watch_enabled { "toggle-track active" } else { "toggle-track" }, + div { class: "toggle-thumb" } + } + } + } - // Poll interval - div { class: "settings-field", - div { class: "form-label-row", - label { class: "form-label", "Poll Interval" } - span { class: "tooltip-trigger", - "?" - span { class: "tooltip-text", - "How often (in seconds) Pinakes polls for file changes when watch mode is not available or as a fallback." - } - } - } - if *editing_poll.read() { - div { class: "settings-inline-edit", - input { - r#type: "number", - min: "1", - value: "{poll_input}", - class: "input-sm", - oninput: move |e| { - poll_input.set(e.value()); - // Clear error on new input - poll_error.set(None); - }, - onkeypress: move |e: KeyboardEvent| { - if e.key() == Key::Enter { - let raw = poll_input.read().clone(); - match raw.parse::() { - Ok(secs) if secs > 0 => { - on_update_poll_interval.call(secs); - editing_poll.set(false); - poll_error.set(None); - } - _ => { - poll_error - .set(Some("Enter a positive integer (seconds).".to_string())); - } - } - } - }, - } - span { class: "input-suffix", "seconds" } - button { - class: "btn btn-primary btn-sm", - onclick: move |_| { - let raw = poll_input.read().clone(); - match raw.parse::() { - Ok(secs) if secs > 0 => { - on_update_poll_interval.call(secs); - editing_poll.set(false); - poll_error.set(None); - } - _ => { - poll_error.set(Some("Enter a positive integer (seconds).".to_string())); - } - } - }, - "Save" - } - button { - class: "btn btn-ghost btn-sm", - onclick: move |_| { - editing_poll.set(false); - poll_error.set(None); - }, - "Cancel" - } - } - if let Some(ref err) = *poll_error.read() { - p { class: "field-error", "{err}" } - } - } else { - div { class: "flex-row", - span { class: "info-value", "{config.scanning.poll_interval_secs}s" } - button { - class: "btn btn-ghost btn-sm", - disabled: !writable, - onclick: { - let current = config.scanning.poll_interval_secs; - move |_| { - if writable { - poll_input.set(current.to_string()); - poll_error.set(None); - editing_poll.set(true); - } - } - }, - "Edit" - } - } - } - } + // Poll interval + div { class: "settings-field", + div { class: "form-label-row", + label { class: "form-label", "Poll Interval" } + span { class: "tooltip-trigger", + "?" + span { class: "tooltip-text", + "How often (in seconds) Pinakes polls for file changes when watch mode is not available or as a fallback." + } + } + } + if *editing_poll.read() { + div { class: "settings-inline-edit", + input { + r#type: "number", + min: "1", + value: "{poll_input}", + class: "input-sm", + oninput: move |e| { + poll_input.set(e.value()); + // Clear error on new input + poll_error.set(None); + }, + onkeypress: move |e: KeyboardEvent| { + if e.key() == Key::Enter { + let raw = poll_input.read().clone(); + match raw.parse::() { + Ok(secs) if secs > 0 => { + on_update_poll_interval.call(secs); + editing_poll.set(false); + poll_error.set(None); + } + _ => { + poll_error + .set(Some("Enter a positive integer (seconds).".to_string())); + } + } + } + }, + } + span { class: "input-suffix", "seconds" } + button { + class: "btn btn-primary btn-sm", + onclick: move |_| { + let raw = poll_input.read().clone(); + match raw.parse::() { + Ok(secs) if secs > 0 => { + on_update_poll_interval.call(secs); + editing_poll.set(false); + poll_error.set(None); + } + _ => { + poll_error.set(Some("Enter a positive integer (seconds).".to_string())); + } + } + }, + "Save" + } + button { + class: "btn btn-ghost btn-sm", + onclick: move |_| { + editing_poll.set(false); + poll_error.set(None); + }, + "Cancel" + } + } + if let Some(ref err) = *poll_error.read() { + p { class: "field-error", "{err}" } + } + } else { + div { class: "flex-row", + span { class: "info-value", "{config.scanning.poll_interval_secs}s" } + button { + class: "btn btn-ghost btn-sm", + disabled: !writable, + onclick: { + let current = config.scanning.poll_interval_secs; + move |_| { + if writable { + poll_input.set(current.to_string()); + poll_error.set(None); + editing_poll.set(true); + } + } + }, + "Edit" + } + } + } + } - // Ignore patterns - div { class: "settings-field", - div { class: "settings-field-header", - div { class: "form-label-row", - label { class: "form-label", "Ignore Patterns" } - span { class: "tooltip-trigger", - "?" - span { class: "tooltip-text", - "Glob patterns for files and directories to skip during scanning. One pattern per line." - } - } - } - if *editing_patterns.read() { - div { class: "flex-row", - button { - class: "btn btn-primary btn-sm", - onclick: move |_| { - let input = patterns_input.read().clone(); - let patterns: Vec = input - .lines() - .map(|l| l.trim().to_string()) - .filter(|l| !l.is_empty()) - .collect(); - on_update_ignore_patterns.call(patterns); - editing_patterns.set(false); - }, - "Save" - } - button { - class: "btn btn-ghost btn-sm", - onclick: move |_| editing_patterns.set(false), - "Cancel" - } - } - } else { - button { - class: "btn btn-ghost btn-sm", - disabled: !writable, - onclick: { - let patterns = config.scanning.ignore_patterns.clone(); - move |_| { - if writable { - patterns_input.set(patterns.join("\n")); - editing_patterns.set(true); - } - } - }, - "Edit" - } - } - } + // Ignore patterns + div { class: "settings-field", + div { class: "settings-field-header", + div { class: "form-label-row", + label { class: "form-label", "Ignore Patterns" } + span { class: "tooltip-trigger", + "?" + span { class: "tooltip-text", + "Glob patterns for files and directories to skip during scanning. One pattern per line." + } + } + } + if *editing_patterns.read() { + div { class: "flex-row", + button { + class: "btn btn-primary btn-sm", + onclick: move |_| { + let input = patterns_input.read().clone(); + let patterns: Vec = input + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect(); + on_update_ignore_patterns.call(patterns); + editing_patterns.set(false); + }, + "Save" + } + button { + class: "btn btn-ghost btn-sm", + onclick: move |_| editing_patterns.set(false), + "Cancel" + } + } + } else { + button { + class: "btn btn-ghost btn-sm", + disabled: !writable, + onclick: { + let patterns = config.scanning.ignore_patterns.clone(); + move |_| { + if writable { + patterns_input.set(patterns.join("\n")); + editing_patterns.set(true); + } + } + }, + "Edit" + } + } + } - if *editing_patterns.read() { - div { class: "settings-patterns-edit", - textarea { - value: "{patterns_input}", - oninput: move |e| patterns_input.set(e.value()), - rows: "8", - class: "patterns-textarea", - placeholder: "One pattern per line, e.g.:\n*.tmp\n.git/**\nnode_modules/**", - } - p { class: "text-muted text-sm", - "Enter one glob pattern per line. Empty lines are ignored." - } - } - } else { - if config.scanning.ignore_patterns.is_empty() { - p { class: "text-muted text-sm", "No ignore patterns configured." } - } else { - div { class: "patterns-list", - for pattern in config.scanning.ignore_patterns.iter() { - span { class: "pattern-chip mono", "{pattern}" } - } - } - } - } - } - } - } + if *editing_patterns.read() { + div { class: "settings-patterns-edit", + textarea { + value: "{patterns_input}", + oninput: move |e| patterns_input.set(e.value()), + rows: "8", + class: "patterns-textarea", + placeholder: "One pattern per line, e.g.:\n*.tmp\n.git/**\nnode_modules/**", + } + p { class: "text-muted text-sm", + "Enter one glob pattern per line. Empty lines are ignored." + } + } + } else { + if config.scanning.ignore_patterns.is_empty() { + p { class: "text-muted text-sm", "No ignore patterns configured." } + } else { + div { class: "patterns-list", + for pattern in config.scanning.ignore_patterns.iter() { + span { class: "pattern-chip mono", "{pattern}" } + } + } + } + } + } + } + } - // ── UI Preferences ── - div { class: "settings-card", - div { class: "settings-card-header", - h3 { class: "settings-card-title", "UI Preferences" } - } - div { class: "settings-card-body", - // Theme - div { class: "settings-field", - div { class: "form-label-row", - label { class: "form-label", "Theme" } - span { class: "tooltip-trigger", - "?" - span { class: "tooltip-text", "Choose between dark and light themes." } - } - } - select { - value: "{config.ui.theme}", - onchange: { - let handler = on_update_ui_config; - move |e: Event| { - if let Some(ref h) = handler { - h.call(serde_json::json!({ "theme" : e.value() })); - } - } - }, - option { value: "dark", "Dark" } - option { value: "light", "Light" } - option { value: "system", "System" } - } - } + // ── UI Preferences ── + div { class: "settings-card", + div { class: "settings-card-header", + h3 { class: "settings-card-title", "UI Preferences" } + } + div { class: "settings-card-body", + // Theme + div { class: "settings-field", + div { class: "form-label-row", + label { class: "form-label", "Theme" } + span { class: "tooltip-trigger", + "?" + span { class: "tooltip-text", "Choose between dark and light themes." } + } + } + select { + value: "{config.ui.theme}", + onchange: { + let handler = on_update_ui_config; + move |e: Event| { + if let Some(ref h) = handler { + h.call(serde_json::json!({ "theme" : e.value() })); + } + } + }, + option { value: "dark", "Dark" } + option { value: "light", "Light" } + option { value: "system", "System" } + } + } - // Default view - div { class: "settings-field", - div { class: "form-label-row", - label { class: "form-label", "Default View" } - span { class: "tooltip-trigger", - "?" - span { class: "tooltip-text", - "The view shown when the application starts." - } - } - } - select { - value: "{config.ui.default_view}", - onchange: { - let handler = on_update_ui_config; - move |e: Event| { - if let Some(ref h) = handler { - h.call(serde_json::json!({ "default_view" : e.value() })); - } - } - }, - option { value: "library", "Library" } - option { value: "search", "Search" } - } - } + // Default view + div { class: "settings-field", + div { class: "form-label-row", + label { class: "form-label", "Default View" } + span { class: "tooltip-trigger", + "?" + span { class: "tooltip-text", + "The view shown when the application starts." + } + } + } + select { + value: "{config.ui.default_view}", + onchange: { + let handler = on_update_ui_config; + move |e: Event| { + if let Some(ref h) = handler { + h.call(serde_json::json!({ "default_view" : e.value() })); + } + } + }, + option { value: "library", "Library" } + option { value: "search", "Search" } + } + } - // Default page size - div { class: "settings-field", - div { class: "form-label-row", - label { class: "form-label", "Default Page Size" } - span { class: "tooltip-trigger", - "?" - span { class: "tooltip-text", - "Number of items shown per page by default." - } - } - } - select { - value: "{config.ui.default_page_size}", - onchange: { - let handler = on_update_ui_config; - move |e: Event| { - if let Some(ref h) = handler && let Ok(size) = e.value().parse::() { - h.call(serde_json::json!({ "default_page_size" : size })); - } - } - }, - option { value: "24", "24" } - option { value: "48", "48" } - option { value: "96", "96" } - option { value: "200", "200" } - } - } + // Default page size + div { class: "settings-field", + div { class: "form-label-row", + label { class: "form-label", "Default Page Size" } + span { class: "tooltip-trigger", + "?" + span { class: "tooltip-text", + "Number of items shown per page by default." + } + } + } + select { + value: "{config.ui.default_page_size}", + onchange: { + let handler = on_update_ui_config; + move |e: Event| { + if let Some(ref h) = handler && let Ok(size) = e.value().parse::() { + h.call(serde_json::json!({ "default_page_size" : size })); + } + } + }, + option { value: "24", "24" } + option { value: "48", "48" } + option { value: "96", "96" } + option { value: "200", "200" } + } + } - // Default view mode - div { class: "settings-field", - div { class: "form-label-row", - label { class: "form-label", "Default View Mode" } - span { class: "tooltip-trigger", - "?" - span { class: "tooltip-text", - "Whether to show items in a grid or table layout." - } - } - } - select { - value: "{config.ui.default_view_mode}", - onchange: { - let handler = on_update_ui_config; - move |e: Event| { - if let Some(ref h) = handler { - h.call(serde_json::json!({ "default_view_mode" : e.value() })); - } - } - }, - option { value: "grid", "Grid" } - option { value: "table", "Table" } - } - } + // Default view mode + div { class: "settings-field", + div { class: "form-label-row", + label { class: "form-label", "Default View Mode" } + span { class: "tooltip-trigger", + "?" + span { class: "tooltip-text", + "Whether to show items in a grid or table layout." + } + } + } + select { + value: "{config.ui.default_view_mode}", + onchange: { + let handler = on_update_ui_config; + move |e: Event| { + if let Some(ref h) = handler { + h.call(serde_json::json!({ "default_view_mode" : e.value() })); + } + } + }, + option { value: "grid", "Grid" } + option { value: "table", "Table" } + } + } - // Auto-play media - div { class: "settings-field", - div { class: "form-label-row", - label { class: "form-label", "Auto-play Media" } - span { class: "tooltip-trigger", - "?" - span { class: "tooltip-text", - "Automatically start playback when opening audio or video." - } - } - } - { - let autoplay = config.ui.auto_play_media; - let handler = on_update_ui_config; - rsx! { - div { - class: "toggle", - onclick: move |_| { - if let Some(ref h) = handler { - h.call(serde_json::json!({ "auto_play_media" : ! autoplay })); - } - }, - div { class: if autoplay { "toggle-track active" } else { "toggle-track" }, - div { class: "toggle-thumb" } - } - } - } - } - } + // Auto-play media + div { class: "settings-field", + div { class: "form-label-row", + label { class: "form-label", "Auto-play Media" } + span { class: "tooltip-trigger", + "?" + span { class: "tooltip-text", + "Automatically start playback when opening audio or video." + } + } + } + { + let autoplay = config.ui.auto_play_media; + let handler = on_update_ui_config; + rsx! { + div { + class: "toggle", + onclick: move |_| { + if let Some(ref h) = handler { + h.call(serde_json::json!({ "auto_play_media" : ! autoplay })); + } + }, + div { class: if autoplay { "toggle-track active" } else { "toggle-track" }, + div { class: "toggle-thumb" } + } + } + } + } + } - // Show thumbnails - div { class: "settings-field", - div { class: "form-label-row", - label { class: "form-label", "Show Thumbnails" } - span { class: "tooltip-trigger", - "?" - span { class: "tooltip-text", - "Display thumbnail previews in library and search views." - } - } - } - { - let show_thumbs = config.ui.show_thumbnails; - let handler = on_update_ui_config; - rsx! { - div { - class: "toggle", - onclick: move |_| { - if let Some(ref h) = handler { - h.call(serde_json::json!({ "show_thumbnails" : ! show_thumbs })); - } - }, - div { class: if show_thumbs { "toggle-track active" } else { "toggle-track" }, - div { class: "toggle-thumb" } - } - } - } - } - } - } - } - } - } + // Show thumbnails + div { class: "settings-field", + div { class: "form-label-row", + label { class: "form-label", "Show Thumbnails" } + span { class: "tooltip-trigger", + "?" + span { class: "tooltip-text", + "Display thumbnail previews in library and search views." + } + } + } + { + let show_thumbs = config.ui.show_thumbnails; + let handler = on_update_ui_config; + rsx! { + div { + class: "toggle", + onclick: move |_| { + if let Some(ref h) = handler { + h.call(serde_json::json!({ "show_thumbnails" : ! show_thumbs })); + } + }, + div { class: if show_thumbs { "toggle-track active" } else { "toggle-track" }, + div { class: "toggle-thumb" } + } + } + } + } + } + } + } + } + } } diff --git a/crates/pinakes-ui/src/components/statistics.rs b/crates/pinakes-ui/src/components/statistics.rs index 134e7c8..559e099 100644 --- a/crates/pinakes-ui/src/components/statistics.rs +++ b/crates/pinakes-ui/src/components/statistics.rs @@ -1,7 +1,15 @@ use dioxus::prelude::*; -use dioxus_free_icons::Icon; -use dioxus_free_icons::icons::fa_solid_icons::{ - FaChartBar, FaCircle, FaClock, FaDatabase, FaFolder, FaLink, FaTags, +use dioxus_free_icons::{ + Icon, + icons::fa_solid_icons::{ + FaChartBar, + FaCircle, + FaClock, + FaDatabase, + FaFolder, + FaLink, + FaTags, + }, }; use super::utils::format_size; @@ -9,262 +17,262 @@ use crate::client::LibraryStatisticsResponse; #[component] pub fn Statistics( - stats: Option, - #[props(default)] error: Option, - on_refresh: EventHandler<()>, + stats: Option, + #[props(default)] error: Option, + on_refresh: EventHandler<()>, ) -> Element { - rsx! { - div { class: "statistics-page", - div { class: "card", - div { class: "card-header", - h3 { class: "card-title", "Library Statistics" } - button { - class: "btn btn-sm btn-secondary", - onclick: move |_| on_refresh.call(()), - "\u{21bb} Refresh" - } - } + rsx! { + div { class: "statistics-page", + div { class: "card", + div { class: "card-header", + h3 { class: "card-title", "Library Statistics" } + button { + class: "btn btn-sm btn-secondary", + onclick: move |_| on_refresh.call(()), + "\u{21bb} Refresh" + } + } - if let Some(ref err) = error { - div { class: "alert alert-error mb-8", - span { "{err}" } - button { - class: "btn btn-sm btn-secondary ml-8", - onclick: move |_| on_refresh.call(()), - "Retry" - } - } - } + if let Some(ref err) = error { + div { class: "alert alert-error mb-8", + span { "{err}" } + button { + class: "btn btn-sm btn-secondary ml-8", + onclick: move |_| on_refresh.call(()), + "Retry" + } + } + } - match stats.as_ref() { - Some(s) => { - let total_size = format_size(s.total_size_bytes); - let avg_size = format_size(s.avg_file_size_bytes); - rsx! { - div { class: "stats-overview", - div { class: "stat-card stat-primary", - div { class: "stat-icon", - Icon { icon: FaFolder, width: 20, height: 20 } - } - div { class: "stat-content", - div { class: "stat-value", "{s.total_media}" } - div { class: "stat-label", "Total Media" } - } - } - div { class: "stat-card stat-success", - div { class: "stat-icon", - Icon { icon: FaDatabase, width: 20, height: 20 } - } - div { class: "stat-content", - div { class: "stat-value", "{total_size}" } - div { class: "stat-label", "Total Size" } - } - } - div { class: "stat-card stat-info", - div { class: "stat-icon", - Icon { icon: FaChartBar, width: 20, height: 20 } - } - div { class: "stat-content", - div { class: "stat-value", "{avg_size}" } - div { class: "stat-label", "Average Size" } - } - } - div { class: "stat-card stat-warning", - div { class: "stat-icon", - Icon { icon: FaTags, width: 20, height: 20 } - } - div { class: "stat-content", - div { class: "stat-value", "{s.total_tags}" } - div { class: "stat-label", "Tags" } - } - } - div { class: "stat-card stat-purple", - div { class: "stat-icon", - Icon { icon: FaCircle, width: 20, height: 20 } - } - div { class: "stat-content", - div { class: "stat-value", "{s.total_collections}" } - div { class: "stat-label", "Collections" } - } - } - div { class: "stat-card stat-danger", - div { class: "stat-icon", - Icon { icon: FaLink, width: 20, height: 20 } - } - div { class: "stat-content", - div { class: "stat-value", "{s.total_duplicates}" } - div { class: "stat-label", "Duplicates" } - } - } - } + match stats.as_ref() { + Some(s) => { + let total_size = format_size(s.total_size_bytes); + let avg_size = format_size(s.avg_file_size_bytes); + rsx! { + div { class: "stats-overview", + div { class: "stat-card stat-primary", + div { class: "stat-icon", + Icon { icon: FaFolder, width: 20, height: 20 } + } + div { class: "stat-content", + div { class: "stat-value", "{s.total_media}" } + div { class: "stat-label", "Total Media" } + } + } + div { class: "stat-card stat-success", + div { class: "stat-icon", + Icon { icon: FaDatabase, width: 20, height: 20 } + } + div { class: "stat-content", + div { class: "stat-value", "{total_size}" } + div { class: "stat-label", "Total Size" } + } + } + div { class: "stat-card stat-info", + div { class: "stat-icon", + Icon { icon: FaChartBar, width: 20, height: 20 } + } + div { class: "stat-content", + div { class: "stat-value", "{avg_size}" } + div { class: "stat-label", "Average Size" } + } + } + div { class: "stat-card stat-warning", + div { class: "stat-icon", + Icon { icon: FaTags, width: 20, height: 20 } + } + div { class: "stat-content", + div { class: "stat-value", "{s.total_tags}" } + div { class: "stat-label", "Tags" } + } + } + div { class: "stat-card stat-purple", + div { class: "stat-icon", + Icon { icon: FaCircle, width: 20, height: 20 } + } + div { class: "stat-content", + div { class: "stat-value", "{s.total_collections}" } + div { class: "stat-label", "Collections" } + } + } + div { class: "stat-card stat-danger", + div { class: "stat-icon", + Icon { icon: FaLink, width: 20, height: 20 } + } + div { class: "stat-content", + div { class: "stat-value", "{s.total_duplicates}" } + div { class: "stat-label", "Duplicates" } + } + } + } - if !s.media_by_type.is_empty() { - { - let max_count = s.media_by_type.iter().map(|i| i.count).max().unwrap_or(1) + if !s.media_by_type.is_empty() { + { + let max_count = s.media_by_type.iter().map(|i| i.count).max().unwrap_or(1) - as f64; - rsx! { - div { class: "stats-section", - h4 { class: "section-title", - Icon { - icon: FaChartBar, - width: 16, - height: 16, - style: "margin-right: 8px; vertical-align: middle;", - } - "Media by Type" - } - div { class: "chart-bars", - for item in s.media_by_type.iter() { - { - let percentage = (item.count as f64 / max_count) * 100.0; - let name = item.name.clone(); - let count = item.count; - rsx! { - div { key: "{name}", class: "bar-item", - div { class: "bar-label", "{name}" } - div { class: "bar-track", - div { class: "bar-fill bar-primary", style: "width: {percentage}%" } - } - div { class: "bar-value", "{count}" } - } - } - } - } - } - } - } - } - } + as f64; + rsx! { + div { class: "stats-section", + h4 { class: "section-title", + Icon { + icon: FaChartBar, + width: 16, + height: 16, + style: "margin-right: 8px; vertical-align: middle;", + } + "Media by Type" + } + div { class: "chart-bars", + for item in s.media_by_type.iter() { + { + let percentage = (item.count as f64 / max_count) * 100.0; + let name = item.name.clone(); + let count = item.count; + rsx! { + div { key: "{name}", class: "bar-item", + div { class: "bar-label", "{name}" } + div { class: "bar-track", + div { class: "bar-fill bar-primary", style: "width: {percentage}%" } + } + div { class: "bar-value", "{count}" } + } + } + } + } + } + } + } + } + } - if !s.storage_by_type.is_empty() { - { - let max_size = s.storage_by_type.iter().map(|i| i.count).max().unwrap_or(1) + if !s.storage_by_type.is_empty() { + { + let max_size = s.storage_by_type.iter().map(|i| i.count).max().unwrap_or(1) - as f64; - rsx! { - div { class: "stats-section", - h4 { class: "section-title", - Icon { - icon: FaDatabase, - width: 16, - height: 16, - style: "margin-right: 8px; vertical-align: middle;", - } - "Storage by Type" - } - div { class: "chart-bars", - for item in s.storage_by_type.iter() { - { - let percentage = (item.count as f64 / max_size) * 100.0; - let name = item.name.clone(); - let size_str = format_size(item.count); - rsx! { - div { key: "{name}", class: "bar-item", - div { class: "bar-label", "{name}" } - div { class: "bar-track", - div { class: "bar-fill bar-success", style: "width: {percentage}%" } - } - div { class: "bar-value", "{size_str}" } - } - } - } - } - } - } - } - } - } + as f64; + rsx! { + div { class: "stats-section", + h4 { class: "section-title", + Icon { + icon: FaDatabase, + width: 16, + height: 16, + style: "margin-right: 8px; vertical-align: middle;", + } + "Storage by Type" + } + div { class: "chart-bars", + for item in s.storage_by_type.iter() { + { + let percentage = (item.count as f64 / max_size) * 100.0; + let name = item.name.clone(); + let size_str = format_size(item.count); + rsx! { + div { key: "{name}", class: "bar-item", + div { class: "bar-label", "{name}" } + div { class: "bar-track", + div { class: "bar-fill bar-success", style: "width: {percentage}%" } + } + div { class: "bar-value", "{size_str}" } + } + } + } + } + } + } + } + } + } - if !s.top_tags.is_empty() { - div { class: "stats-section", - h4 { class: "section-title", - Icon { - icon: FaTags, - width: 16, - height: 16, - style: "margin-right: 8px; vertical-align: middle;", - } - "Top Tags" - } - div { class: "tag-list", - for item in s.top_tags.iter() { - div { class: "tag-item", - span { class: "tag-badge", "{item.name}" } - span { class: "tag-count", "{item.count}" } - } - } - } - } - } + if !s.top_tags.is_empty() { + div { class: "stats-section", + h4 { class: "section-title", + Icon { + icon: FaTags, + width: 16, + height: 16, + style: "margin-right: 8px; vertical-align: middle;", + } + "Top Tags" + } + div { class: "tag-list", + for item in s.top_tags.iter() { + div { class: "tag-item", + span { class: "tag-badge", "{item.name}" } + span { class: "tag-count", "{item.count}" } + } + } + } + } + } - if !s.top_collections.is_empty() { - div { class: "stats-section", - h4 { class: "section-title", - Icon { - icon: FaCircle, - width: 16, - height: 16, - style: "margin-right: 8px; vertical-align: middle;", - } - "Top Collections" - } - div { class: "collection-list", - for item in s.top_collections.iter() { - div { class: "collection-item", - Icon { - icon: FaFolder, - width: 16, - height: 16, - class: "collection-icon", - } - span { class: "collection-name", "{item.name}" } - span { class: "collection-count", "{item.count}" } - } - } - } - } - } + if !s.top_collections.is_empty() { + div { class: "stats-section", + h4 { class: "section-title", + Icon { + icon: FaCircle, + width: 16, + height: 16, + style: "margin-right: 8px; vertical-align: middle;", + } + "Top Collections" + } + div { class: "collection-list", + for item in s.top_collections.iter() { + div { class: "collection-item", + Icon { + icon: FaFolder, + width: 16, + height: 16, + class: "collection-icon", + } + span { class: "collection-name", "{item.name}" } + span { class: "collection-count", "{item.count}" } + } + } + } + } + } - div { class: "stats-section", - h4 { class: "section-title", - Icon { - icon: FaClock, - width: 16, - height: 16, - style: "margin-right: 8px; vertical-align: middle;", - } - "Date Range" - } - div { class: "date-range", - div { class: "date-item", - Icon { icon: FaClock, width: 16, height: 16 } - div { class: "date-content", - div { class: "date-label", "Oldest Item" } - div { class: "date-value", "{s.oldest_item.as_deref().unwrap_or(\"N/A\")}" } - } - } - div { class: "date-item", - Icon { icon: FaClock, width: 16, height: 16 } - div { class: "date-content", - div { class: "date-label", "Newest Item" } - div { class: "date-value", "{s.newest_item.as_deref().unwrap_or(\"N/A\")}" } - } - } - } - } - } - } - None => rsx! { - div { class: "empty-state", - div { class: "spinner" } - p { "Loading statistics..." } - } - }, - } - } - } - } + div { class: "stats-section", + h4 { class: "section-title", + Icon { + icon: FaClock, + width: 16, + height: 16, + style: "margin-right: 8px; vertical-align: middle;", + } + "Date Range" + } + div { class: "date-range", + div { class: "date-item", + Icon { icon: FaClock, width: 16, height: 16 } + div { class: "date-content", + div { class: "date-label", "Oldest Item" } + div { class: "date-value", "{s.oldest_item.as_deref().unwrap_or(\"N/A\")}" } + } + } + div { class: "date-item", + Icon { icon: FaClock, width: 16, height: 16 } + div { class: "date-content", + div { class: "date-label", "Newest Item" } + div { class: "date-value", "{s.newest_item.as_deref().unwrap_or(\"N/A\")}" } + } + } + } + } + } + } + None => rsx! { + div { class: "empty-state", + div { class: "spinner" } + p { "Loading statistics..." } + } + }, + } + } + } + } } diff --git a/crates/pinakes-ui/src/components/tags.rs b/crates/pinakes-ui/src/components/tags.rs index bb41e03..20c14d2 100644 --- a/crates/pinakes-ui/src/components/tags.rs +++ b/crates/pinakes-ui/src/components/tags.rs @@ -4,273 +4,275 @@ use crate::client::TagResponse; #[component] pub fn Tags( - tags: Vec, - on_create: EventHandler<(String, Option)>, - on_delete: EventHandler, + tags: Vec, + on_create: EventHandler<(String, Option)>, + on_delete: EventHandler, ) -> Element { - let mut new_tag_name = use_signal(String::new); - let mut parent_tag = use_signal(String::new); - let mut confirm_delete: Signal> = use_signal(|| None); + let mut new_tag_name = use_signal(String::new); + let mut parent_tag = use_signal(String::new); + let mut confirm_delete: Signal> = use_signal(|| None); - let create_click = move |_| { - let name = new_tag_name.read().clone(); - if name.is_empty() { - return; - } - let parent = { - let p = parent_tag.read().clone(); - if p.is_empty() { None } else { Some(p) } - }; - on_create.call((name, parent)); - new_tag_name.set(String::new()); - parent_tag.set(String::new()); - }; - - let create_key = move |e: KeyboardEvent| { - if e.key() == Key::Enter { - let name = new_tag_name.read().clone(); - if name.is_empty() { - return; - } - let parent = { - let p = parent_tag.read().clone(); - if p.is_empty() { None } else { Some(p) } - }; - on_create.call((name, parent)); - new_tag_name.set(String::new()); - parent_tag.set(String::new()); - } - }; - - // Separate root tags and child tags - let root_tags: Vec<&TagResponse> = tags.iter().filter(|t| t.parent_id.is_none()).collect(); - let child_tags: Vec<&TagResponse> = tags.iter().filter(|t| t.parent_id.is_some()).collect(); - - rsx! { - div { class: "card", - div { class: "card-header", - h3 { class: "card-title", "Tags" } - } - - div { class: "form-row mb-16", - input { - r#type: "text", - placeholder: "New tag name...", - value: "{new_tag_name}", - oninput: move |e| new_tag_name.set(e.value()), - onkeypress: create_key, - } - select { - value: "{parent_tag}", - onchange: move |e| parent_tag.set(e.value()), - option { value: "", "No Parent" } - for tag in tags.iter() { - option { key: "{tag.id}", value: "{tag.id}", "{tag.name}" } - } - } - button { class: "btn btn-primary", onclick: create_click, "Create" } - } - - if tags.is_empty() { - div { class: "empty-state", - p { class: "empty-subtitle", "No tags yet. Create one above." } - } - } else { - div { class: "tag-list", - // Root tags - for tag in root_tags.iter() { - { - let tag_id = tag.id.clone(); - let tag_name = tag.name.clone(); - let children: Vec<&TagResponse> = child_tags - .iter() - .filter(|c| c.parent_id.as_deref() == Some(tag_id.as_str())) - .copied() - .collect(); - - let is_confirming = confirm_delete.read().as_deref() == Some(tag_id.as_str()); - - rsx! { - div { key: "{tag_id}", class: "tag-group", - span { class: "tag-badge", - "{tag_name}" - if is_confirming { - { - let confirm_id = tag_id.clone(); - rsx! { - span { class: "tag-confirm-delete", - " Are you sure? " - span { - class: "tag-confirm-yes", - onclick: move |_| { - on_delete.call(confirm_id.clone()); - confirm_delete.set(None); - }, - "Confirm" - } - " " - span { - class: "tag-confirm-no", - onclick: move |_| { - confirm_delete.set(None); - }, - "Cancel" - } - } - } - } - } else { - { - let remove_id = tag_id.clone(); - rsx! { - span { - class: "tag-remove", - onclick: move |_| { - confirm_delete.set(Some(remove_id.clone())); - }, - "\u{00d7}" - } - } - } - } - } - if !children.is_empty() { - div { - - - - class: "tag-children", - style: "margin-left: 16px; margin-top: 4px;", - for child in children.iter() { - { - let child_id = child.id.clone(); - let child_name = child.name.clone(); - let child_is_confirming = confirm_delete.read().as_deref() - - == Some(child_id.as_str()); - rsx! { - span { key: "{child_id}", class: "tag-badge", - "{child_name}" - if child_is_confirming { - { - let confirm_id = child_id.clone(); - rsx! { - span { class: "tag-confirm-delete", - " Are you sure? " - span { - class: "tag-confirm-yes", - onclick: move |_| { - on_delete.call(confirm_id.clone()); - confirm_delete.set(None); - }, - "Confirm" - } - " " - span { - class: "tag-confirm-no", - onclick: move |_| { - confirm_delete.set(None); - }, - "Cancel" - } - } - } - } - } else { - { - let remove_id = child_id.clone(); - rsx! { - span { - class: "tag-remove", - onclick: move |_| { - confirm_delete.set(Some(remove_id.clone())); - }, - "\u{00d7}" - } - } - } - } - } - } - } - } - } - } - } - } - } - } - - // Orphan child tags (parent not found in current list) - for tag in child_tags.iter() { - { - let parent_exists = root_tags - .iter() - - .any(|r| Some(r.id.as_str()) == tag.parent_id.as_deref()) - || child_tags - .iter() - .any(|c| { - c.id != tag.id && Some(c.id.as_str()) == tag.parent_id.as_deref() - }); - if !parent_exists { - let orphan_id = tag.id.clone(); - let orphan_name = tag.name.clone(); - let parent_label = tag.parent_id.clone().unwrap_or_default(); - let is_confirming = confirm_delete.read().as_deref() - == Some(orphan_id.as_str()); - rsx! { - span { key: "{orphan_id}", class: "tag-badge", - "{orphan_name}" - span { class: "text-muted text-sm", " (parent: {parent_label})" } - if is_confirming { - { - let confirm_id = orphan_id.clone(); - rsx! { - span { class: "tag-confirm-delete", - " Are you sure? " - span { - class: "tag-confirm-yes", - onclick: move |_| { - on_delete.call(confirm_id.clone()); - confirm_delete.set(None); - }, - "Confirm" - } - " " - span { - class: "tag-confirm-no", - onclick: move |_| { - confirm_delete.set(None); - }, - "Cancel" - } - } - } - } - } else { - { - let remove_id = orphan_id.clone(); - rsx! { - span { - class: "tag-remove", - onclick: move |_| { - confirm_delete.set(Some(remove_id.clone())); - }, - "\u{00d7}" - } - } - } - } - } - } - } else { - rsx! {} - } - } - } - } - } - } + let create_click = move |_| { + let name = new_tag_name.read().clone(); + if name.is_empty() { + return; } + let parent = { + let p = parent_tag.read().clone(); + if p.is_empty() { None } else { Some(p) } + }; + on_create.call((name, parent)); + new_tag_name.set(String::new()); + parent_tag.set(String::new()); + }; + + let create_key = move |e: KeyboardEvent| { + if e.key() == Key::Enter { + let name = new_tag_name.read().clone(); + if name.is_empty() { + return; + } + let parent = { + let p = parent_tag.read().clone(); + if p.is_empty() { None } else { Some(p) } + }; + on_create.call((name, parent)); + new_tag_name.set(String::new()); + parent_tag.set(String::new()); + } + }; + + // Separate root tags and child tags + let root_tags: Vec<&TagResponse> = + tags.iter().filter(|t| t.parent_id.is_none()).collect(); + let child_tags: Vec<&TagResponse> = + tags.iter().filter(|t| t.parent_id.is_some()).collect(); + + rsx! { + div { class: "card", + div { class: "card-header", + h3 { class: "card-title", "Tags" } + } + + div { class: "form-row mb-16", + input { + r#type: "text", + placeholder: "New tag name...", + value: "{new_tag_name}", + oninput: move |e| new_tag_name.set(e.value()), + onkeypress: create_key, + } + select { + value: "{parent_tag}", + onchange: move |e| parent_tag.set(e.value()), + option { value: "", "No Parent" } + for tag in tags.iter() { + option { key: "{tag.id}", value: "{tag.id}", "{tag.name}" } + } + } + button { class: "btn btn-primary", onclick: create_click, "Create" } + } + + if tags.is_empty() { + div { class: "empty-state", + p { class: "empty-subtitle", "No tags yet. Create one above." } + } + } else { + div { class: "tag-list", + // Root tags + for tag in root_tags.iter() { + { + let tag_id = tag.id.clone(); + let tag_name = tag.name.clone(); + let children: Vec<&TagResponse> = child_tags + .iter() + .filter(|c| c.parent_id.as_deref() == Some(tag_id.as_str())) + .copied() + .collect(); + + let is_confirming = confirm_delete.read().as_deref() == Some(tag_id.as_str()); + + rsx! { + div { key: "{tag_id}", class: "tag-group", + span { class: "tag-badge", + "{tag_name}" + if is_confirming { + { + let confirm_id = tag_id.clone(); + rsx! { + span { class: "tag-confirm-delete", + " Are you sure? " + span { + class: "tag-confirm-yes", + onclick: move |_| { + on_delete.call(confirm_id.clone()); + confirm_delete.set(None); + }, + "Confirm" + } + " " + span { + class: "tag-confirm-no", + onclick: move |_| { + confirm_delete.set(None); + }, + "Cancel" + } + } + } + } + } else { + { + let remove_id = tag_id.clone(); + rsx! { + span { + class: "tag-remove", + onclick: move |_| { + confirm_delete.set(Some(remove_id.clone())); + }, + "\u{00d7}" + } + } + } + } + } + if !children.is_empty() { + div { + + + + class: "tag-children", + style: "margin-left: 16px; margin-top: 4px;", + for child in children.iter() { + { + let child_id = child.id.clone(); + let child_name = child.name.clone(); + let child_is_confirming = confirm_delete.read().as_deref() + + == Some(child_id.as_str()); + rsx! { + span { key: "{child_id}", class: "tag-badge", + "{child_name}" + if child_is_confirming { + { + let confirm_id = child_id.clone(); + rsx! { + span { class: "tag-confirm-delete", + " Are you sure? " + span { + class: "tag-confirm-yes", + onclick: move |_| { + on_delete.call(confirm_id.clone()); + confirm_delete.set(None); + }, + "Confirm" + } + " " + span { + class: "tag-confirm-no", + onclick: move |_| { + confirm_delete.set(None); + }, + "Cancel" + } + } + } + } + } else { + { + let remove_id = child_id.clone(); + rsx! { + span { + class: "tag-remove", + onclick: move |_| { + confirm_delete.set(Some(remove_id.clone())); + }, + "\u{00d7}" + } + } + } + } + } + } + } + } + } + } + } + } + } + } + + // Orphan child tags (parent not found in current list) + for tag in child_tags.iter() { + { + let parent_exists = root_tags + .iter() + + .any(|r| Some(r.id.as_str()) == tag.parent_id.as_deref()) + || child_tags + .iter() + .any(|c| { + c.id != tag.id && Some(c.id.as_str()) == tag.parent_id.as_deref() + }); + if !parent_exists { + let orphan_id = tag.id.clone(); + let orphan_name = tag.name.clone(); + let parent_label = tag.parent_id.clone().unwrap_or_default(); + let is_confirming = confirm_delete.read().as_deref() + == Some(orphan_id.as_str()); + rsx! { + span { key: "{orphan_id}", class: "tag-badge", + "{orphan_name}" + span { class: "text-muted text-sm", " (parent: {parent_label})" } + if is_confirming { + { + let confirm_id = orphan_id.clone(); + rsx! { + span { class: "tag-confirm-delete", + " Are you sure? " + span { + class: "tag-confirm-yes", + onclick: move |_| { + on_delete.call(confirm_id.clone()); + confirm_delete.set(None); + }, + "Confirm" + } + " " + span { + class: "tag-confirm-no", + onclick: move |_| { + confirm_delete.set(None); + }, + "Cancel" + } + } + } + } + } else { + { + let remove_id = orphan_id.clone(); + rsx! { + span { + class: "tag-remove", + onclick: move |_| { + confirm_delete.set(Some(remove_id.clone())); + }, + "\u{00d7}" + } + } + } + } + } + } + } else { + rsx! {} + } + } + } + } + } + } + } } diff --git a/crates/pinakes-ui/src/components/tasks.rs b/crates/pinakes-ui/src/components/tasks.rs index a472ee6..d14f7b5 100644 --- a/crates/pinakes-ui/src/components/tasks.rs +++ b/crates/pinakes-ui/src/components/tasks.rs @@ -1,166 +1,175 @@ use dioxus::prelude::*; -use dioxus_free_icons::Icon; -use dioxus_free_icons::icons::fa_solid_icons::{ - FaArrowsRotate, FaCalendar, FaCircleCheck, FaClock, FaPause, FaPlay, +use dioxus_free_icons::{ + Icon, + icons::fa_solid_icons::{ + FaArrowsRotate, + FaCalendar, + FaCircleCheck, + FaClock, + FaPause, + FaPlay, + }, }; use crate::client::ScheduledTaskResponse; #[component] pub fn Tasks( - tasks: Vec, - #[props(default)] error: Option, - on_refresh: EventHandler<()>, - on_toggle: EventHandler, - on_run_now: EventHandler, + tasks: Vec, + #[props(default)] error: Option, + on_refresh: EventHandler<()>, + on_toggle: EventHandler, + on_run_now: EventHandler, ) -> Element { - rsx! { - div { class: "tasks-container", - div { class: "card mb-16", - div { class: "card-header", - h3 { class: "card-title", "Scheduled Tasks" } - button { - class: "btn btn-sm btn-secondary", - onclick: move |_| on_refresh.call(()), - Icon { icon: FaArrowsRotate, width: 14, height: 14 } - " Refresh" - } - } + rsx! { + div { class: "tasks-container", + div { class: "card mb-16", + div { class: "card-header", + h3 { class: "card-title", "Scheduled Tasks" } + button { + class: "btn btn-sm btn-secondary", + onclick: move |_| on_refresh.call(()), + Icon { icon: FaArrowsRotate, width: 14, height: 14 } + " Refresh" + } + } - if let Some(ref err) = error { - div { class: "alert alert-error mb-8", - span { "{err}" } - button { - class: "btn btn-sm btn-secondary ml-8", - onclick: move |_| on_refresh.call(()), - "Retry" - } - } - } + if let Some(ref err) = error { + div { class: "alert alert-error mb-8", + span { "{err}" } + button { + class: "btn btn-sm btn-secondary ml-8", + onclick: move |_| on_refresh.call(()), + "Retry" + } + } + } - if tasks.is_empty() { - div { class: "empty-state", - div { class: "empty-icon", - Icon { icon: FaCalendar, width: 48, height: 48 } - } - p { "No scheduled tasks configured." } - p { class: "text-muted", - "Tasks will appear here once configured on the server." - } - } - } else { - div { class: "tasks-grid", - for task in tasks.iter() { - { - let task_id_toggle = task.id.clone(); - let task_id_run = task.id.clone(); - let last_run = task.last_run.clone().unwrap_or_else(|| "Never".to_string()); - let next_run = task + if tasks.is_empty() { + div { class: "empty-state", + div { class: "empty-icon", + Icon { icon: FaCalendar, width: 48, height: 48 } + } + p { "No scheduled tasks configured." } + p { class: "text-muted", + "Tasks will appear here once configured on the server." + } + } + } else { + div { class: "tasks-grid", + for task in tasks.iter() { + { + let task_id_toggle = task.id.clone(); + let task_id_run = task.id.clone(); + let last_run = task.last_run.clone().unwrap_or_else(|| "Never".to_string()); + let next_run = task - // Header with status and actions + // Header with status and actions - // Task info grid + // Task info grid - // Actions - .next_run - .clone() - .unwrap_or_else(|| "Not scheduled".to_string()); - let last_status = task - .last_status - .clone() - .unwrap_or_else(|| "No runs yet".to_string()); - let is_enabled = task.enabled; - let task_name = task.name.clone(); - let schedule = task.schedule.clone(); - rsx! { - div { class: if is_enabled { "task-card task-card-enabled" } else { "task-card task-card-disabled" }, + // Actions + .next_run + .clone() + .unwrap_or_else(|| "Not scheduled".to_string()); + let last_status = task + .last_status + .clone() + .unwrap_or_else(|| "No runs yet".to_string()); + let is_enabled = task.enabled; + let task_name = task.name.clone(); + let schedule = task.schedule.clone(); + rsx! { + div { class: if is_enabled { "task-card task-card-enabled" } else { "task-card task-card-disabled" }, - div { class: "task-card-header", - div { class: "task-header-left", - div { class: "task-name", "{task_name}" } - div { class: "task-schedule", - span { class: "schedule-icon", - Icon { icon: FaClock, width: 14, height: 14 } - } - "{schedule}" - } - } - div { class: "task-status-badge", - if is_enabled { - span { class: "status-badge status-enabled", - span { class: "status-dot" } - "Active" - } - } else { - span { class: "status-badge status-disabled", - span { class: "status-dot" } - "Disabled" - } - } - } - } - div { class: "task-info-grid", - div { class: "task-info-item", - div { class: "task-info-icon", - Icon { icon: FaClock, width: 16, height: 16 } - } - div { class: "task-info-content", - div { class: "task-info-label", "Last Run" } - div { class: "task-info-value", "{last_run}" } - } - } - div { class: "task-info-item", - div { class: "task-info-icon", - Icon { icon: FaClock, width: 16, height: 16 } - } - div { class: "task-info-content", - div { class: "task-info-label", "Next Run" } - div { class: "task-info-value", "{next_run}" } - } - } - div { class: "task-info-item", - div { class: "task-info-icon", - Icon { icon: FaCircleCheck, width: 16, height: 16 } - } - div { class: "task-info-content", - div { class: "task-info-label", "Last Status" } - div { class: "task-info-value", "{last_status}" } - } - } - } - div { class: "task-card-actions", - button { - class: if is_enabled { "btn btn-sm btn-secondary" } else { "btn btn-sm btn-primary" }, - onclick: move |_| on_toggle.call(task_id_toggle.clone()), - if is_enabled { - span { - Icon { icon: FaPause, width: 14, height: 14 } - " Disable" - } - } else { - span { - Icon { icon: FaPlay, width: 14, height: 14 } - " Enable" - } - } - } - button { - class: "btn btn-sm btn-primary", - onclick: move |_| on_run_now.call(task_id_run.clone()), - disabled: !is_enabled, - Icon { icon: FaPlay, width: 14, height: 14 } - " Run Now" - } - } - } - } - } - } - } - } - } - } - } + div { class: "task-card-header", + div { class: "task-header-left", + div { class: "task-name", "{task_name}" } + div { class: "task-schedule", + span { class: "schedule-icon", + Icon { icon: FaClock, width: 14, height: 14 } + } + "{schedule}" + } + } + div { class: "task-status-badge", + if is_enabled { + span { class: "status-badge status-enabled", + span { class: "status-dot" } + "Active" + } + } else { + span { class: "status-badge status-disabled", + span { class: "status-dot" } + "Disabled" + } + } + } + } + + div { class: "task-info-grid", + div { class: "task-info-item", + div { class: "task-info-icon", + Icon { icon: FaClock, width: 16, height: 16 } + } + div { class: "task-info-content", + div { class: "task-info-label", "Last Run" } + div { class: "task-info-value", "{last_run}" } + } + } + div { class: "task-info-item", + div { class: "task-info-icon", + Icon { icon: FaClock, width: 16, height: 16 } + } + div { class: "task-info-content", + div { class: "task-info-label", "Next Run" } + div { class: "task-info-value", "{next_run}" } + } + } + div { class: "task-info-item", + div { class: "task-info-icon", + Icon { icon: FaCircleCheck, width: 16, height: 16 } + } + div { class: "task-info-content", + div { class: "task-info-label", "Last Status" } + div { class: "task-info-value", "{last_status}" } + } + } + } + + div { class: "task-card-actions", + button { + class: if is_enabled { "btn btn-sm btn-secondary" } else { "btn btn-sm btn-primary" }, + onclick: move |_| on_toggle.call(task_id_toggle.clone()), + if is_enabled { + span { + Icon { icon: FaPause, width: 14, height: 14 } + " Disable" + } + } else { + span { + Icon { icon: FaPlay, width: 14, height: 14 } + " Enable" + } + } + } + button { + class: "btn btn-sm btn-primary", + onclick: move |_| on_run_now.call(task_id_run.clone()), + disabled: !is_enabled, + Icon { icon: FaPlay, width: 14, height: 14 } + " Run Now" + } + } + } + } + } + } + } + } + } + } + } } diff --git a/crates/pinakes-ui/src/components/utils.rs b/crates/pinakes-ui/src/components/utils.rs index a08e1fd..81f9228 100644 --- a/crates/pinakes-ui/src/components/utils.rs +++ b/crates/pinakes-ui/src/components/utils.rs @@ -1,69 +1,69 @@ pub fn format_size(bytes: u64) -> String { - if bytes < 1024 { - format!("{bytes} B") - } else if bytes < 1024 * 1024 { - format!("{:.1} KB", bytes as f64 / 1024.0) - } else if bytes < 1024 * 1024 * 1024 { - format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) - } else { - format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) - } + if bytes < 1024 { + format!("{bytes} B") + } else if bytes < 1024 * 1024 { + format!("{:.1} KB", bytes as f64 / 1024.0) + } else if bytes < 1024 * 1024 * 1024 { + format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) + } else { + format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) + } } pub fn type_badge_class(media_type: &str) -> &'static str { - match media_type { - "mp3" | "flac" | "ogg" | "wav" => "type-audio", - "mp4" | "mkv" | "avi" | "webm" => "type-video", - "jpeg" | "jpg" | "png" | "gif" | "webp" | "avif" => "type-image", - "pdf" | "epub" | "djvu" => "type-document", - "md" | "markdown" => "type-text", - _ => "type-other", - } + match media_type { + "mp3" | "flac" | "ogg" | "wav" => "type-audio", + "mp4" | "mkv" | "avi" | "webm" => "type-video", + "jpeg" | "jpg" | "png" | "gif" | "webp" | "avif" => "type-image", + "pdf" | "epub" | "djvu" => "type-document", + "md" | "markdown" => "type-text", + _ => "type-other", + } } pub fn type_icon(media_type: &str) -> &'static str { - match media_type { - "mp3" | "flac" | "ogg" | "wav" => "\u{266b}", - "mp4" | "mkv" | "avi" | "webm" => "\u{25b6}", - "jpeg" | "jpg" | "png" | "gif" | "webp" | "avif" => "\u{1f5bc}", - "pdf" | "epub" | "djvu" => "\u{1f4c4}", - "md" | "markdown" => "\u{270e}", - _ => "\u{1f4c1}", - } + match media_type { + "mp3" | "flac" | "ogg" | "wav" => "\u{266b}", + "mp4" | "mkv" | "avi" | "webm" => "\u{25b6}", + "jpeg" | "jpg" | "png" | "gif" | "webp" | "avif" => "\u{1f5bc}", + "pdf" | "epub" | "djvu" => "\u{1f4c4}", + "md" | "markdown" => "\u{270e}", + _ => "\u{1f4c1}", + } } pub fn format_timestamp(ts: &str) -> String { - let trimmed = ts.replace('T', " "); - if let Some(dot_pos) = trimmed.find('.') { - trimmed[..dot_pos].to_string() - } else if let Some(z_pos) = trimmed.find('Z') { - trimmed[..z_pos].to_string() - } else if trimmed.len() > 19 { - trimmed[..19].to_string() - } else { - trimmed - } + let trimmed = ts.replace('T', " "); + if let Some(dot_pos) = trimmed.find('.') { + trimmed[..dot_pos].to_string() + } else if let Some(z_pos) = trimmed.find('Z') { + trimmed[..z_pos].to_string() + } else if trimmed.len() > 19 { + trimmed[..19].to_string() + } else { + trimmed + } } pub fn media_category(media_type: &str) -> &'static str { - match media_type { - "mp3" | "flac" | "ogg" | "wav" => "audio", - "mp4" | "mkv" | "avi" | "webm" => "video", - "jpeg" | "jpg" | "png" | "gif" | "webp" | "avif" => "image", - "pdf" | "epub" | "djvu" => "document", - "md" | "markdown" => "text", - _ => "other", - } + match media_type { + "mp3" | "flac" | "ogg" | "wav" => "audio", + "mp4" | "mkv" | "avi" | "webm" => "video", + "jpeg" | "jpg" | "png" | "gif" | "webp" | "avif" => "image", + "pdf" | "epub" | "djvu" => "document", + "md" | "markdown" => "text", + _ => "other", + } } pub fn format_duration(secs: f64) -> String { - let total = secs as u64; - let hours = total / 3600; - let mins = (total % 3600) / 60; - let s = total % 60; - if hours > 0 { - format!("{hours}:{mins:02}:{s:02}") - } else { - format!("{mins:02}:{s:02}") - } + let total = secs as u64; + let hours = total / 3600; + let mins = (total % 3600) / 60; + let s = total % 60; + if hours > 0 { + format!("{hours}:{mins:02}:{s:02}") + } else { + format!("{mins:02}:{s:02}") + } } diff --git a/crates/pinakes-ui/src/main.rs b/crates/pinakes-ui/src/main.rs index 1556a0d..024584d 100644 --- a/crates/pinakes-ui/src/main.rs +++ b/crates/pinakes-ui/src/main.rs @@ -13,34 +13,36 @@ use dioxus::prelude::*; #[derive(Parser)] #[command(name = "pinakes-ui", version, about)] struct Cli { - /// Server URL to connect to - #[arg( - short, - long, - env = "PINAKES_SERVER_URL", - default_value = "http://localhost:3000" - )] - server: String, + /// Server URL to connect to + #[arg( + short, + long, + env = "PINAKES_SERVER_URL", + default_value = "http://localhost:3000" + )] + server: String, - /// Set log level (trace, debug, info, warn, error) - #[arg(long, default_value = "warn")] - log_level: String, + /// Set log level (trace, debug, info, warn, error) + #[arg(long, default_value = "warn")] + log_level: String, } fn main() { - let cli = Cli::parse(); + let cli = Cli::parse(); - let env_filter = EnvFilter::try_new(&cli.log_level).unwrap_or_else(|_| EnvFilter::new("warn")); + let env_filter = EnvFilter::try_new(&cli.log_level) + .unwrap_or_else(|_| EnvFilter::new("warn")); - tracing_subscriber::fmt() - .with_env_filter(env_filter) - .compact() - .init(); + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .compact() + .init(); - // SAFETY: Called before any threads are spawned (single-threaded at this point). - unsafe { std::env::set_var("PINAKES_SERVER_URL", &cli.server) }; + // SAFETY: Called before any threads are spawned (single-threaded at this + // point). + unsafe { std::env::set_var("PINAKES_SERVER_URL", &cli.server) }; - tracing::info!(server = %cli.server, "starting pinakes desktop UI"); + tracing::info!(server = %cli.server, "starting pinakes desktop UI"); - launch(app::App); + launch(app::App); } diff --git a/crates/pinakes-ui/src/styles.rs b/crates/pinakes-ui/src/styles.rs index 462a92c..197ae0b 100644 --- a/crates/pinakes-ui/src/styles.rs +++ b/crates/pinakes-ui/src/styles.rs @@ -1,4171 +1,8 @@ -pub const CSS: &str = r#" -@media (prefers-reduced-motion: reduce) { - *, *::before, *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - } -} +//! Styles module for Pinakes UI +//! +//! Exports the CSS asset for use with Dioxus. +//! SCSS files are compiled to CSS via build.rs. -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} +use dioxus::prelude::*; -:root { - --bg-0: #111118; - --bg-1: #18181f; - --bg-2: #1f1f28; - --bg-3: #26263a; - --border-subtle: rgba(255, 255, 255, 0.06); - --border: rgba(255, 255, 255, 0.09); - --border-strong: rgba(255, 255, 255, 0.14); - --text-0: #dcdce4; - --text-1: #a0a0b8; - --text-2: #6c6c84; - --accent: #7c7ef5; - --accent-dim: rgba(124, 126, 245, 0.15); - --accent-text: #9698f7; - --success: #3ec97a; - --error: #e45858; - --warning: #d4a037; - --radius-sm: 3px; - --radius: 5px; - --radius-md: 7px; - --shadow-sm: 0 1px 3px rgba(0,0,0,0.3); - --shadow: 0 2px 8px rgba(0,0,0,0.35); - --shadow-lg: 0 4px 20px rgba(0,0,0,0.45); -} - -body { - font-family: 'Inter', -apple-system, 'Segoe UI', system-ui, sans-serif; - background: var(--bg-0); - color: var(--text-0); - font-size: 13px; - line-height: 1.5; - -webkit-font-smoothing: antialiased; - overflow: hidden; -} - -:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - -/* Layout */ -.app { - display: flex; - height: 100vh; - overflow: hidden; -} - -.sidebar { - width: 220px; - min-width: 220px; - max-width: 220px; - background: var(--bg-1); - border-right: 1px solid var(--border); - display: flex; - flex-direction: column; - flex-shrink: 0; - user-select: none; - overflow-y: auto; - overflow-x: hidden; - z-index: 10; - transition: width 0.15s, min-width 0.15s, max-width 0.15s; -} - -.sidebar.collapsed { width: 48px; min-width: 48px; max-width: 48px; } -.sidebar.collapsed .nav-label, -.sidebar.collapsed .sidebar-header .logo, -.sidebar.collapsed .sidebar-header .version, -.sidebar.collapsed .nav-badge { display: none; } -.sidebar.collapsed .nav-item { justify-content: center; padding: 8px; border-left: none; border-radius: var(--radius-sm); } -.sidebar.collapsed .nav-item.active { border-left: none; } -.sidebar.collapsed .nav-icon { width: auto; margin: 0; } -.sidebar.collapsed .sidebar-header { padding: 12px 8px; justify-content: center; } -.sidebar.collapsed .nav-section { padding: 0 4px; } -.sidebar.collapsed .sidebar-footer { padding: 8px 4px; } - -/* Nav item text - hide when collapsed, properly handle overflow when expanded */ -.nav-item-text { - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - min-width: 0; -} - -/* When sidebar is expanded, allow text to show fully */ -.sidebar:not(.collapsed) .nav-item-text { - overflow: visible; -} - -.sidebar.collapsed .nav-item-text { display: none; } - -.sidebar-toggle { - background: none; - border: none; - color: var(--text-2); - cursor: pointer; - padding: 8px; - font-size: 16px; - width: 100%; - text-align: center; -} -.sidebar-toggle:hover { color: var(--text-0); } - -.sidebar-header { - padding: 16px 16px 20px; - display: flex; - align-items: baseline; - gap: 8px; -} - -.sidebar-header .logo { - font-size: 15px; - font-weight: 700; - letter-spacing: -0.4px; - color: var(--text-0); -} - -.sidebar-header .version { - font-size: 10px; - color: var(--text-2); -} - -.nav-section { - padding: 0 8px; - margin-bottom: 2px; -} - -.nav-label { - padding: 8px 8px 4px; - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--text-2); -} - -.nav-item { - display: flex; - align-items: center; - gap: 8px; - padding: 6px 8px; - border-radius: var(--radius-sm); - cursor: pointer; - color: var(--text-1); - font-size: 13px; - font-weight: 450; - transition: color 0.1s, background 0.1s; - border: none; - background: none; - width: 100%; - text-align: left; - border-left: 2px solid transparent; - margin-left: 0; -} - -.nav-item:hover { - color: var(--text-0); - background: rgba(255,255,255,0.03); -} - -.nav-item.active { - color: var(--accent-text); - border-left-color: var(--accent); - background: var(--accent-dim); -} - -.nav-icon { - width: 18px; - text-align: center; - font-size: 14px; - opacity: 0.7; -} - -.sidebar-spacer { flex: 1; } - -.sidebar-footer { - padding: 12px; - border-top: 1px solid var(--border-subtle); - overflow: visible; - min-width: 0; -} - -/* Hide footer content in collapsed sidebar */ -.sidebar.collapsed .sidebar-footer .status-text { display: none; } -.sidebar.collapsed .sidebar-footer .user-info { justify-content: center; } - -/* Main */ -.main { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; - min-width: 0; -} - -.header { - height: 48px; - min-height: 48px; - border-bottom: 1px solid var(--border-subtle); - display: flex; - align-items: center; - padding: 0 20px; - gap: 12px; - background: var(--bg-1); -} - -.page-title { - font-size: 14px; - font-weight: 600; - color: var(--text-0); -} - -.header-spacer { flex: 1; } - -.content { - flex: 1; - overflow-y: auto; - padding: 20px; -} - -/* Table */ -.data-table { - width: 100%; - border-collapse: collapse; - background: var(--bg-2); - border: 1px solid var(--border); - border-radius: var(--radius); - overflow: hidden; -} - -.data-table thead th { - padding: 8px 14px; - text-align: left; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--text-2); - border-bottom: 1px solid var(--border); - background: var(--bg-3); -} - -.data-table tbody td { - padding: 8px 14px; - font-size: 13px; - border-bottom: 1px solid var(--border-subtle); - max-width: 300px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.data-table tbody tr { - cursor: pointer; - transition: background 0.08s; -} - -.data-table tbody tr:hover { - background: rgba(255,255,255,0.02); -} - -.data-table tbody tr.row-selected { - background: rgba(99, 102, 241, 0.12); -} - -.data-table tbody tr:last-child td { - border-bottom: none; -} - -/* Buttons */ -.btn { - padding: 5px 12px; - border-radius: var(--radius-sm); - border: none; - cursor: pointer; - font-size: 12px; - font-weight: 500; - transition: all 0.1s; - display: inline-flex; - align-items: center; - gap: 5px; - white-space: nowrap; - line-height: 1.5; -} - -.btn-primary { - background: var(--accent); - color: #fff; -} -.btn-primary:hover { background: #8b8df7; } - -.btn-secondary { - background: var(--bg-3); - color: var(--text-0); - border: 1px solid var(--border); -} -.btn-secondary:hover { - border-color: var(--border-strong); - background: rgba(255,255,255,0.06); -} - -.btn-danger { - background: transparent; - color: var(--error); - border: 1px solid rgba(228, 88, 88, 0.25); -} -.btn-danger:hover { background: rgba(228, 88, 88, 0.08); } - -.btn-ghost { - background: transparent; - color: var(--text-1); - border: none; - padding: 5px 8px; -} -.btn-ghost:hover { - color: var(--text-0); - background: rgba(255,255,255,0.04); -} - -.btn-sm { - padding: 3px 8px; - font-size: 11px; -} - -.btn-icon { - padding: 4px; - border-radius: var(--radius-sm); - background: transparent; - border: none; - color: var(--text-2); - cursor: pointer; - transition: color 0.1s; - font-size: 13px; -} -.btn-icon:hover { color: var(--text-0); } - -/* Cards */ -.card { - background: var(--bg-2); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 16px; -} - -.card-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 12px; -} - -.card-title { - font-size: 14px; - font-weight: 600; -} - -/* Forms */ -input[type="text"], textarea, select { - padding: 6px 10px; - border-radius: var(--radius-sm); - border: 1px solid var(--border); - background: var(--bg-0); - color: var(--text-0); - font-size: 13px; - outline: none; - transition: border-color 0.12s; - font-family: inherit; -} - -input[type="text"]::placeholder, textarea::placeholder { - color: var(--text-2); -} - -input[type="text"]:focus, textarea:focus, select:focus { - border-color: var(--accent); -} - -.form-group { margin-bottom: 12px; } - -.form-label { - display: block; - font-size: 11px; - font-weight: 600; - color: var(--text-1); - margin-bottom: 4px; - text-transform: uppercase; - letter-spacing: 0.03em; -} - -.form-row { - display: flex; - gap: 8px; - align-items: flex-end; -} - -.form-row input[type="text"] { - flex: 1; -} - -/* Toast */ -.toast { - position: fixed; - bottom: 16px; - right: 16px; - padding: 8px 16px; - border-radius: var(--radius); - background: var(--bg-3); - border: 1px solid var(--border); - color: var(--text-0); - font-size: 12px; - box-shadow: var(--shadow); - z-index: 300; - animation: toast-in 0.15s ease-out; - max-width: 420px; -} - -.toast.success { border-left: 3px solid var(--success); } -.toast.error { border-left: 3px solid var(--error); } - -@keyframes toast-in { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: translateY(0); } -} - -/* Detail */ -.detail-actions { - display: flex; - gap: 6px; - margin-bottom: 16px; -} - -.detail-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; -} - -.detail-field { - padding: 10px 12px; - background: var(--bg-0); - border-radius: var(--radius-sm); - border: 1px solid var(--border-subtle); -} - -.detail-field.full-width { - grid-column: 1 / -1; -} - -.detail-label { - font-size: 10px; - font-weight: 600; - color: var(--text-2); - text-transform: uppercase; - letter-spacing: 0.04em; - margin-bottom: 2px; -} - -.detail-value { - font-size: 13px; - color: var(--text-0); - word-break: break-all; -} - -.detail-value.mono { - font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; - font-size: 11px; - color: var(--text-1); -} - -/* Stats */ -.statistics-page { - padding: 20px; -} - -.stats-overview { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 16px; - margin-bottom: 32px; -} - -.stat-card { - background: var(--bg-2); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 20px; - display: flex; - align-items: center; - gap: 16px; -} - -.stat-primary { border-left: 3px solid var(--accent); } -.stat-success { border-left: 3px solid var(--success); } -.stat-info { border-left: 3px solid #6ca0d4; } -.stat-warning { border-left: 3px solid var(--warning); } -.stat-purple { border-left: 3px solid #9d8be0; } -.stat-danger { border-left: 3px solid var(--error); } - -.stat-icon { - flex-shrink: 0; - color: var(--text-2); -} - -.stat-content { - flex: 1; -} - -.stat-value { - font-size: 28px; - font-weight: 700; - color: var(--text-0); - line-height: 1.2; - font-variant-numeric: tabular-nums; -} - -.stat-label { - font-size: 12px; - color: var(--text-2); - margin-top: 4px; - font-weight: 500; -} - -.stats-section { - background: var(--bg-2); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 24px; - margin-bottom: 20px; -} - -.section-title { - font-size: 16px; - font-weight: 600; - color: var(--text-0); - margin-bottom: 20px; -} - -.chart-bars { - display: flex; - flex-direction: column; - gap: 16px; -} - -.bar-item { - display: grid; - grid-template-columns: 120px 1fr 80px; - align-items: center; - gap: 16px; -} - -.bar-label { - font-size: 13px; - font-weight: 500; - color: var(--text-1); - text-align: right; -} - -.bar-track { - height: 28px; - background: var(--bg-3); - border-radius: var(--radius-sm); - overflow: hidden; - position: relative; -} - -.bar-fill { - height: 100%; - transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1); - border-radius: var(--radius-sm); -} - -.bar-primary { - background: linear-gradient(90deg, var(--accent) 0%, #7c7ef3 100%); -} - -.bar-success { - background: linear-gradient(90deg, var(--success) 0%, #66bb6a 100%); -} - -.bar-value { - font-size: 13px; - font-weight: 600; - color: var(--text-1); - text-align: right; - font-variant-numeric: tabular-nums; -} - -.tag-list { - display: flex; - flex-wrap: wrap; - gap: 12px; -} - -.tag-item { - display: flex; - align-items: center; - gap: 8px; - background: var(--bg-3); - padding: 8px 12px; - border-radius: var(--radius-sm); -} - -.tag-badge { - font-size: 13px; - font-weight: 500; - color: var(--text-0); -} - -.tag-count { - font-size: 12px; - color: var(--text-2); - font-weight: 600; -} - -.collection-list { - display: flex; - flex-direction: column; - gap: 8px; -} - -.collection-item { - display: flex; - align-items: center; - gap: 12px; - padding: 12px; - background: var(--bg-3); - border-radius: var(--radius-sm); -} - -.collection-icon { - color: var(--text-2); -} - -.collection-name { - flex: 1; - font-size: 13px; - font-weight: 500; - color: var(--text-0); -} - -.collection-count { - font-size: 12px; - color: var(--text-2); - font-weight: 600; -} - -.date-range { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - gap: 16px; -} - -.date-item { - display: flex; - align-items: center; - gap: 12px; - padding: 16px; - background: var(--bg-3); - border-radius: var(--radius-sm); -} - -.date-content { - flex: 1; -} - -.date-label { - font-size: 11px; - color: var(--text-2); - font-weight: 600; - text-transform: uppercase; - margin-bottom: 4px; -} - -.date-value { - font-size: 13px; - color: var(--text-0); - font-family: 'Menlo', 'Monaco', 'Courier New', monospace; -} - -/* Tasks */ -.tasks-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); - gap: 16px; - padding: 16px; -} - -.task-card { - background: var(--bg-2); - border: 1px solid var(--border); - border-radius: var(--radius); - overflow: hidden; - transition: all 0.2s; -} - -.task-card:hover { - border-color: var(--border-strong); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); - transform: translateY(-2px); -} - -.task-card-enabled { - border-left: 3px solid var(--success); -} - -.task-card-disabled { - border-left: 3px solid var(--text-3); - opacity: 0.7; -} - -.task-card-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - padding: 16px; - border-bottom: 1px solid var(--border-subtle); -} - -.task-header-left { - flex: 1; - min-width: 0; -} - -.task-name { - font-size: 16px; - font-weight: 600; - color: var(--text-0); - margin-bottom: 4px; -} - -.task-schedule { - display: flex; - align-items: center; - gap: 6px; - font-size: 12px; - color: var(--text-2); - font-family: 'Menlo', 'Monaco', 'Courier New', monospace; -} - -.schedule-icon { - font-size: 14px; -} - -.task-status-badge { - flex-shrink: 0; -} - -.status-badge { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 10px; - border-radius: var(--radius-sm); - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.03em; -} - -.status-enabled { - background: rgba(76, 175, 80, 0.12); - color: var(--success); -} - -.status-disabled { - background: var(--bg-3); - color: var(--text-2); -} - -.status-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: currentColor; -} - -.status-enabled .status-dot { - animation: pulse 1.5s infinite; -} - -.task-info-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); - gap: 12px; - padding: 16px; -} - -.task-info-item { - display: flex; - align-items: flex-start; - gap: 10px; -} - -.task-info-icon { - font-size: 18px; - color: var(--text-2); - flex-shrink: 0; -} - -.task-info-content { - flex: 1; - min-width: 0; -} - -.task-info-label { - font-size: 10px; - color: var(--text-2); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.03em; - margin-bottom: 4px; -} - -.task-info-value { - font-size: 12px; - color: var(--text-1); - font-weight: 500; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.task-card-actions { - display: flex; - gap: 8px; - padding: 12px 16px; - background: var(--bg-1); - border-top: 1px solid var(--border-subtle); -} - -.task-card-actions button { - flex: 1; -} - -.empty-icon { - font-size: 48px; - margin-bottom: 12px; -} - -.text-muted { - color: var(--text-2); - font-size: 13px; -} - -/* ── Type badges ── */ -.type-badge { - display: inline-block; - padding: 1px 6px; - border-radius: var(--radius-sm); - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.03em; -} - -.type-audio { background: rgba(139, 92, 246, 0.1); color: #9d8be0; } -.type-video { background: rgba(200, 72, 130, 0.1); color: #d07eaa; } -.type-image { background: rgba(34, 160, 80, 0.1); color: #5cb97a; } -.type-document { background: rgba(59, 120, 200, 0.1); color: #6ca0d4; } -.type-text { background: rgba(200, 160, 36, 0.1); color: #c4a840; } -.type-other { background: rgba(128, 128, 160, 0.08); color: var(--text-2); } - -/* Tags */ -.tag-list { display: flex; flex-wrap: wrap; gap: 4px; } - -.tag-badge { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 2px 10px; - background: var(--accent-dim); - color: var(--accent-text); - border-radius: 12px; - font-size: 11px; - font-weight: 500; -} - -.tag-badge .tag-remove { - cursor: pointer; - opacity: 0.4; - font-size: 13px; - line-height: 1; - transition: opacity 0.1s; -} -.tag-badge .tag-remove:hover { opacity: 1; } - -.tag-badge.selected { - background: var(--accent); - color: #fff; - cursor: pointer; -} - -.tag-badge:not(.selected) { - cursor: pointer; -} - -/* Empty state */ -.empty-state { - text-align: center; - padding: 48px 16px; - color: var(--text-2); -} - -.empty-icon { - font-size: 32px; - margin-bottom: 12px; - opacity: 0.3; -} - -.empty-title { - font-size: 15px; - font-weight: 600; - color: var(--text-1); - margin-bottom: 4px; -} - -.empty-subtitle { - font-size: 12px; - max-width: 320px; - margin: 0 auto; - line-height: 1.5; -} - -/* Settings */ -.settings-section { margin-bottom: 24px; } - -.section-title { - font-size: 14px; - font-weight: 600; - margin-bottom: 12px; - padding-bottom: 6px; - border-bottom: 1px solid var(--border-subtle); -} - -.root-list { list-style: none; } - -.root-item { - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px 12px; - background: var(--bg-0); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-sm); - margin-bottom: 4px; - font-family: 'JetBrains Mono', ui-monospace, monospace; - font-size: 12px; - color: var(--text-1); -} - -.toggle { - display: flex; - align-items: center; - gap: 8px; - cursor: pointer; - font-size: 13px; - color: var(--text-0); -} - -.toggle-track { - width: 32px; - height: 18px; - border-radius: 9px; - background: var(--bg-3); - border: 1px solid var(--border); - position: relative; - transition: background 0.15s; - flex-shrink: 0; -} - -.toggle-track.active { background: var(--accent); border-color: var(--accent); } - -.toggle-thumb { - width: 14px; - height: 14px; - border-radius: 50%; - background: var(--text-0); - position: absolute; - top: 1px; - left: 1px; - transition: transform 0.15s; -} - -.toggle-track.active .toggle-thumb { - transform: translateX(14px); -} - -.toggle.disabled { opacity: 0.4; cursor: not-allowed; } - -.info-row { - display: flex; - justify-content: space-between; - align-items: center; - padding: 6px 0; - border-bottom: 1px solid var(--border-subtle); - font-size: 13px; -} - -.info-row:last-child { border-bottom: none; } - -.info-label { color: var(--text-1); font-weight: 500; } -.info-value { color: var(--text-0); } - -/* Scrollbar */ -::-webkit-scrollbar { width: 5px; height: 5px; } -::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 3px; } -::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.14); } - -* { - scrollbar-width: thin; - scrollbar-color: rgba(255,255,255,0.08) transparent; -} - -.content { - scrollbar-width: thin; - scrollbar-color: rgba(255,255,255,0.08) transparent; -} - -/* Import Tabs */ -.import-tabs { - display: flex; - gap: 0; - margin-bottom: 16px; - border-bottom: 1px solid var(--border); -} - -.import-tab { - padding: 8px 16px; - background: none; - border: none; - border-bottom: 2px solid transparent; - color: var(--text-2); - font-size: 12px; - font-weight: 500; - cursor: pointer; - transition: color 0.1s, border-color 0.1s; -} - -.import-tab:hover { - color: var(--text-0); -} - -.import-tab.active { - color: var(--accent-text); - border-bottom-color: var(--accent); -} - -/* Batch Actions */ -.batch-actions { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - background: var(--accent-dim); - border: 1px solid rgba(124, 126, 245, 0.2); - border-radius: var(--radius-sm); - margin-bottom: 12px; - font-size: 12px; - font-weight: 500; - color: var(--accent-text); -} - -/* Action badges (audit) */ -.action-danger { - background: rgba(228, 88, 88, 0.1); - color: #d47070; -} - -/* Tag hierarchy */ -.tag-group { - margin-bottom: 6px; -} - -.tag-children { - margin-left: 16px; - margin-top: 4px; - display: flex; - flex-wrap: wrap; - gap: 4px; -} - -/* Detail field inputs */ -.detail-field input[type="text"], -.detail-field textarea, -.detail-field select { - width: 100%; - margin-top: 4px; -} - -.detail-field textarea { - min-height: 64px; - resize: vertical; -} - -/* Checkbox */ -input[type="checkbox"] { - appearance: none; - -webkit-appearance: none; - width: 16px; - height: 16px; - border: 1px solid var(--border-strong); - border-radius: 3px; - background: var(--bg-2); - cursor: pointer; - position: relative; - flex-shrink: 0; - transition: all 0.15s ease; -} - -input[type="checkbox"]:hover { - border-color: var(--accent); - background: var(--bg-3); -} - -input[type="checkbox"]:checked { - background: var(--accent); - border-color: var(--accent); -} - -input[type="checkbox"]:checked::after { - content: ""; - position: absolute; - left: 5px; - top: 2px; - width: 4px; - height: 8px; - border: solid var(--bg-0); - border-width: 0 2px 2px 0; - transform: rotate(45deg); -} - -input[type="checkbox"]:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - -/* Checkbox with label */ -.checkbox-label { - display: inline-flex; - align-items: center; - gap: 8px; - cursor: pointer; - font-size: 13px; - color: var(--text-1); - user-select: none; -} - -.checkbox-label:hover { - color: var(--text-0); -} - -.checkbox-label input[type="checkbox"] { - margin: 0; -} - -/* Number input */ -input[type="number"] { - width: 80px; - padding: 6px 8px; - background: var(--bg-2); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - color: var(--text-0); - font-size: 12px; - -moz-appearance: textfield; -} - -input[type="number"]::-webkit-outer-spin-button, -input[type="number"]::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; -} - -input[type="number"]:focus { - outline: none; - border-color: var(--accent); -} - -/* Select */ -select { - appearance: none; - -webkit-appearance: none; - background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%236c6c84' d='M5 7L1 3h8z'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 8px center; - padding-right: 26px; - min-width: 100px; -} - -/* Code */ -code { - padding: 1px 5px; - border-radius: var(--radius-sm); - background: var(--bg-0); - color: var(--accent-text); - font-family: 'JetBrains Mono', ui-monospace, monospace; - font-size: 11px; -} - -ul { list-style: none; padding: 0; } -ul li { padding: 3px 0; font-size: 12px; color: var(--text-1); } - -/* Status indicator */ -.status-indicator { - display: flex; - align-items: center; - justify-content: center; - gap: 6px; - font-size: 11px; - font-weight: 500; - min-width: 0; - overflow: visible; -} - -/* In expanded sidebar, align left with gap */ -.sidebar:not(.collapsed) .status-indicator { - justify-content: flex-start; -} - -.status-dot { - width: 6px; - height: 6px; - border-radius: 50%; - flex-shrink: 0; -} - -.status-dot.connected { background: var(--success); } -.status-dot.disconnected { background: var(--error); } -.status-dot.checking { background: var(--warning); animation: pulse 1.5s infinite; } - -@keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.3; } -} - -.status-text { - color: var(--text-2); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - min-width: 0; -} - -/* Ensure status text is visible in expanded sidebar */ -.sidebar:not(.collapsed) .status-text { - overflow: visible; -} - -/* Modal */ -.modal-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.5); - display: flex; - align-items: center; - justify-content: center; - z-index: 100; - animation: fade-in 0.1s ease-out; -} - -@keyframes fade-in { - from { opacity: 0; } - to { opacity: 1; } -} - -.modal { - background: var(--bg-2); - border: 1px solid var(--border); - border-radius: var(--radius-md); - padding: 20px; - min-width: 360px; - max-width: 480px; - box-shadow: var(--shadow-lg); -} - -.modal-title { - font-size: 15px; - font-weight: 600; - margin-bottom: 6px; -} - -.modal-body { - font-size: 12px; - color: var(--text-1); - margin-bottom: 16px; - line-height: 1.5; -} - -.modal-actions { - display: flex; - justify-content: flex-end; - gap: 6px; -} - -/* Saved Searches */ -.saved-searches-list { - display: flex; - flex-direction: column; - gap: 4px; - max-height: 300px; - overflow-y: auto; -} - -.saved-search-item { - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px 12px; - background: var(--bg-1); - border-radius: var(--radius-sm); - cursor: pointer; - transition: background 0.15s ease; -} - -.saved-search-item:hover { - background: var(--bg-2); -} - -.saved-search-info { - display: flex; - flex-direction: column; - gap: 2px; - flex: 1; - min-width: 0; -} - -.saved-search-name { - font-weight: 500; - color: var(--text-0); -} - -.saved-search-query { - font-size: 11px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.card-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 12px; -} - -.card-header h4 { - margin: 0; -} - -/* Offline banner */ -.offline-banner { - background: rgba(228, 88, 88, 0.06); - border: 1px solid rgba(228, 88, 88, 0.2); - border-radius: var(--radius-sm); - padding: 8px 12px; - margin-bottom: 12px; - display: flex; - align-items: center; - gap: 8px; - font-size: 12px; - color: #d47070; -} - -.offline-banner .offline-icon { - font-size: 14px; - flex-shrink: 0; -} - -/* Utility */ -.flex-row { display: flex; align-items: center; gap: 8px; } -.flex-between { display: flex; justify-content: space-between; align-items: center; } -.mb-16 { margin-bottom: 16px; } -.mb-8 { margin-bottom: 8px; } -.text-muted { color: var(--text-1); } -.text-sm { font-size: 11px; } -.mono { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 12px; } - -/* Filter bar */ -.filter-bar { - display: flex; - flex-direction: column; - gap: 12px; - padding: 12px; - background: var(--bg-0); - border: 1px solid var(--border-subtle); - border-radius: var(--radius); - margin-bottom: 12px; -} - -.filter-bar .filter-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; -} - -.filter-bar .filter-label { - font-size: 11px; - font-weight: 500; - color: var(--text-2); - text-transform: uppercase; - letter-spacing: 0.5px; - margin-right: 4px; -} - -/* Filter chip/toggle style */ -.filter-chip { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 5px 10px; - background: var(--bg-2); - border: 1px solid var(--border); - border-radius: 14px; - cursor: pointer; - font-size: 11px; - color: var(--text-1); - transition: all 0.15s ease; - user-select: none; -} - -.filter-chip:hover { - background: var(--bg-3); - border-color: var(--border-strong); - color: var(--text-0); -} - -.filter-chip.active { - background: var(--accent-dim); - border-color: var(--accent); - color: var(--accent-text); -} - -.filter-chip input[type="checkbox"] { - width: 12px; - height: 12px; - margin: 0; -} - -.filter-chip input[type="checkbox"]:checked::after { - left: 3px; - top: 1px; - width: 3px; - height: 6px; -} - -/* Size filter inputs */ -.filter-bar .size-filters { - display: flex; - align-items: center; - gap: 8px; - padding-top: 8px; - border-top: 1px solid var(--border-subtle); -} - -.filter-bar .size-filter-group { - display: flex; - align-items: center; - gap: 6px; -} - -.filter-bar .size-filter-group label { - font-size: 11px; - color: var(--text-2); -} - -.filter-bar input[type="number"] { - width: 70px; -} - -.filter-group { - display: flex; - align-items: center; - gap: 6px; -} - -.filter-group label { - display: flex; - align-items: center; - gap: 3px; - cursor: pointer; - color: var(--text-1); - font-size: 11px; - white-space: nowrap; -} - -.filter-group label:hover { - color: var(--text-0); -} - -.filter-separator { - width: 1px; - height: 20px; - background: var(--border); - flex-shrink: 0; -} - -.filter-size { - display: flex; - align-items: center; - gap: 4px; - font-size: 11px; - color: var(--text-1); -} - -.filter-size input[type="text"] { - width: 60px; - padding: 3px 6px; - font-size: 11px; -} - -/* ── Tooltips ── */ -.tooltip-trigger { - display: inline-flex; - align-items: center; - justify-content: center; - width: 14px; - height: 14px; - border-radius: 50%; - background: var(--bg-3); - color: var(--text-2); - font-size: 9px; - font-weight: 700; - cursor: help; - position: relative; - flex-shrink: 0; - margin-left: 4px; -} - -.tooltip-trigger:hover { - background: var(--accent-dim); - color: var(--accent-text); -} - -.tooltip-text { - display: none; - position: absolute; - bottom: calc(100% + 6px); - left: 50%; - transform: translateX(-50%); - padding: 6px 10px; - background: var(--bg-3); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - color: var(--text-0); - font-size: 11px; - font-weight: 400; - line-height: 1.4; - white-space: normal; - width: 220px; - text-transform: none; - letter-spacing: normal; - box-shadow: var(--shadow); - z-index: 100; - pointer-events: none; -} - -.tooltip-trigger:hover .tooltip-text { - display: block; -} - -/* Form label row */ -.form-label-row { - display: flex; - align-items: center; - gap: 2px; - margin-bottom: 4px; -} - -.form-label-row .form-label { - margin-bottom: 0; -} - -/* Read-only banner */ -.readonly-banner { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - background: rgba(212, 160, 55, 0.06); - border: 1px solid rgba(212, 160, 55, 0.2); - border-radius: var(--radius-sm); - margin-bottom: 16px; - font-size: 12px; - color: var(--warning); -} - -/* Config path */ -.config-path { - font-size: 11px; - color: var(--text-2); - margin-bottom: 12px; - font-family: 'JetBrains Mono', ui-monospace, monospace; - padding: 6px 10px; - background: var(--bg-0); - border-radius: var(--radius-sm); - border: 1px solid var(--border-subtle); -} - -/* Settings cards */ -.settings-card { - background: var(--bg-2); - border: 1px solid var(--border); - border-radius: var(--radius-md); - padding: 20px; - margin-bottom: 16px; -} - -.settings-card-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 16px; - padding-bottom: 12px; - border-bottom: 1px solid var(--border-subtle); -} - -.settings-card-title { - font-size: 14px; - font-weight: 600; -} - -.config-status { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 3px 10px; - border-radius: 12px; - font-size: 11px; - font-weight: 600; -} - -.config-status.writable { - background: rgba(62, 201, 122, 0.1); - color: var(--success); -} - -.config-status.readonly { - background: rgba(228, 88, 88, 0.1); - color: var(--error); -} - -/* Disabled button */ -.btn:disabled, .btn[disabled] { - opacity: 0.4; - cursor: not-allowed; - pointer-events: none; -} - -/* Disabled with hint - shows what action is needed */ -.btn.btn-disabled-hint:disabled { - opacity: 0.6; - border-style: dashed; - pointer-events: auto; - cursor: help; -} - -/* Library Toolbar */ -.library-toolbar { - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px 0; - margin-bottom: 12px; - gap: 12px; - flex-wrap: wrap; -} - -.toolbar-left { - display: flex; - align-items: center; - gap: 10px; -} - -.toolbar-right { - display: flex; - align-items: center; - gap: 10px; -} - -/* View Toggle */ -.view-toggle { - display: flex; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - overflow: hidden; -} - -.view-btn { - padding: 4px 10px; - background: var(--bg-2); - border: none; - color: var(--text-2); - cursor: pointer; - font-size: 14px; - line-height: 1; - transition: background 0.1s, color 0.1s; -} - -.view-btn:first-child { - border-right: 1px solid var(--border); -} - -.view-btn:hover { - color: var(--text-0); - background: var(--bg-3); -} - -.view-btn.active { - background: var(--accent-dim); - color: var(--accent-text); -} - -/* Sort & Page Size Controls */ -.sort-control select, -.page-size-control select { - padding: 4px 24px 4px 8px; - font-size: 11px; - background: var(--bg-2); -} - -.page-size-control { - display: flex; - align-items: center; - gap: 4px; -} - -/* Media Grid */ -.media-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); - gap: 12px; -} - -.media-card { - background: var(--bg-2); - border: 1px solid var(--border); - border-radius: var(--radius); - overflow: hidden; - cursor: pointer; - transition: border-color 0.12s, box-shadow 0.12s; - position: relative; -} - -.media-card:hover { - border-color: var(--border-strong); - box-shadow: var(--shadow-sm); -} - -.media-card.selected { - border-color: var(--accent); - box-shadow: 0 0 0 1px var(--accent); -} - -/* Card Checkbox */ -.card-checkbox { - position: absolute; - top: 6px; - left: 6px; - z-index: 2; - opacity: 0; - transition: opacity 0.1s; -} - -.media-card:hover .card-checkbox, -.media-card.selected .card-checkbox { - opacity: 1; -} - -.card-checkbox input[type="checkbox"] { - width: 16px; - height: 16px; - cursor: pointer; - filter: drop-shadow(0 1px 2px rgba(0,0,0,0.5)); -} - -/* Card Thumbnail */ -.card-thumbnail { - width: 100%; - aspect-ratio: 1; - background: var(--bg-0); - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; - position: relative; -} - -.card-thumbnail img, -.card-thumb-img { - width: 100%; - height: 100%; - object-fit: cover; - position: absolute; - top: 0; - left: 0; - z-index: 1; -} - -.card-type-icon { - font-size: 32px; - opacity: 0.4; - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - position: absolute; - top: 0; - left: 0; - z-index: 0; -} - -/* Card Info */ -.card-info { - padding: 8px 10px; -} - -.card-name { - font-size: 12px; - font-weight: 500; - color: var(--text-0); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - margin-bottom: 4px; -} - -.card-meta { - display: flex; - align-items: center; - gap: 6px; - font-size: 10px; -} - -.card-size { - color: var(--text-2); - font-size: 10px; -} - -/* Table Thumbnail */ -.table-thumb-cell { - width: 36px; - padding: 4px 6px !important; - position: relative; -} - -.table-thumb { - width: 28px; - height: 28px; - object-fit: cover; - border-radius: 3px; - display: block; -} - -.table-thumb-overlay { - position: absolute; - top: 4px; - left: 6px; - z-index: 1; -} - -.table-type-icon { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - font-size: 14px; - opacity: 0.5; - border-radius: 3px; - background: var(--bg-0); - z-index: 0; -} - -/* Type Filter Row */ -.type-filter-row { - display: flex; - align-items: center; - gap: 6px; - padding: 4px 0; - margin-bottom: 6px; - flex-wrap: wrap; -} - -.filter-chip { - padding: 3px 12px; - border-radius: 12px; - border: 1px solid var(--border); - background: var(--bg-2); - color: var(--text-2); - font-size: 11px; - cursor: pointer; - transition: background 0.1s, color 0.1s, border-color 0.1s; -} - -.filter-chip:hover { - color: var(--text-0); - background: var(--bg-3); - border-color: var(--border-strong); -} - -.filter-chip.active { - background: var(--accent-dim); - color: var(--accent-text); - border-color: var(--accent); -} - -/* Library Stats Row */ -.library-stats { - display: flex; - justify-content: space-between; - align-items: center; - padding: 2px 0 6px 0; - font-size: 11px; -} - -/* Sortable Table Headers */ -.sortable-header { - cursor: pointer; - user-select: none; - transition: color 0.1s; -} - -.sortable-header:hover { - color: var(--accent-text); -} - -/* Card extra info */ -.card-title, -.card-artist { - font-size: 10px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - line-height: 1.3; -} - -/* Pagination */ -.pagination { - display: flex; - align-items: center; - justify-content: center; - gap: 4px; - margin-top: 16px; - padding: 8px 0; -} - -.page-btn { - min-width: 28px; - text-align: center; - font-variant-numeric: tabular-nums; -} - -.page-ellipsis { - color: var(--text-2); - padding: 0 4px; - font-size: 12px; - user-select: none; -} - -/* Loading indicator */ -.loading-overlay { - display: flex; - align-items: center; - justify-content: center; - padding: 48px 16px; - color: var(--text-2); - font-size: 13px; - gap: 10px; -} - -.spinner { - width: 18px; - height: 18px; - border: 2px solid var(--border); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.7s linear infinite; -} - -@keyframes spin { - to { transform: rotate(360deg); } -} - -.error-banner { - background: rgba(228, 88, 88, 0.06); - border: 1px solid rgba(228, 88, 88, 0.2); - border-radius: var(--radius-sm); - padding: 10px 14px; - margin-bottom: 12px; - font-size: 12px; - color: #d47070; - display: flex; - align-items: center; - gap: 8px; -} - -.error-banner .error-icon { - font-size: 14px; - flex-shrink: 0; -} - -/* Toast container (stacked) */ -.toast-container { - position: fixed; - bottom: 16px; - right: 16px; - z-index: 300; - display: flex; - flex-direction: column-reverse; - gap: 6px; - align-items: flex-end; -} - -.toast-container .toast { - position: static; - transform: none; -} - -/* Nav badge */ -.nav-badge { - margin-left: auto; - font-size: 10px; - font-weight: 600; - color: var(--text-2); - background: var(--bg-3); - padding: 1px 6px; - border-radius: 8px; - min-width: 20px; - text-align: center; - font-variant-numeric: tabular-nums; -} - -/* Detail preview */ -.detail-preview { - margin-bottom: 16px; - background: var(--bg-0); - border: 1px solid var(--border-subtle); - border-radius: var(--radius); - overflow: hidden; - text-align: center; -} -.detail-preview:has(.markdown-viewer) { - max-height: none; - overflow-y: auto; - text-align: left; -} -.detail-preview:not(:has(.markdown-viewer)) { - max-height: 450px; -} - -.detail-preview img { - max-width: 100%; - max-height: 400px; - object-fit: contain; - display: block; - margin: 0 auto; -} - -.detail-preview audio { - width: 100%; - padding: 16px; -} - -.detail-preview video { - max-width: 100%; - max-height: 400px; - display: block; - margin: 0 auto; -} - -/* Action badge styles (audit) */ -.action-updated { - background: rgba(59, 120, 200, 0.1); - color: #6ca0d4; -} - -.action-collection { - background: rgba(34, 160, 80, 0.1); - color: #5cb97a; -} - -.action-collection-remove { - background: rgba(212, 160, 55, 0.1); - color: #c4a840; -} - -.action-opened { - background: rgba(139, 92, 246, 0.1); - color: #9d8be0; -} - -.action-scanned { - background: rgba(128, 128, 160, 0.08); - color: var(--text-2); -} - -/* Audit controls */ -.audit-controls { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 12px; -} - -.filter-select { - padding: 4px 24px 4px 8px; - font-size: 11px; - background: var(--bg-2); -} - -/* Clickable elements */ -.clickable { - cursor: pointer; - color: var(--accent-text); -} - -.clickable:hover { - text-decoration: underline; -} - -.clickable-row { - cursor: pointer; -} - -.clickable-row:hover { - background: rgba(255,255,255,0.03); -} - -/* Progress bar */ -.progress-bar { - width: 100%; - height: 8px; - background: var(--bg-3); - border-radius: 4px; - overflow: hidden; - margin-bottom: 6px; -} - -.progress-fill { - height: 100%; - background: var(--accent); - border-radius: 4px; - transition: width 0.3s ease; -} - -.progress-fill.indeterminate { - width: 30%; - animation: indeterminate 1.5s ease-in-out infinite; -} - -@keyframes indeterminate { - 0% { transform: translateX(-100%); } - 100% { transform: translateX(400%); } -} - -/* Import status panel */ -.import-status-panel { - background: var(--bg-2); - border: 1px solid var(--accent); - border-radius: var(--radius); - padding: 12px 16px; - margin-bottom: 16px; -} - -.import-status-header { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 8px; - font-size: 13px; - color: var(--text-0); -} - -.import-current-file { - display: flex; - align-items: center; - gap: 4px; - margin-bottom: 6px; - font-size: 12px; - overflow: hidden; -} - -.import-file-label { - color: var(--text-2); - flex-shrink: 0; -} - -.import-file-name { - color: var(--text-0); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-family: monospace; - font-size: 11px; -} - -.import-queue-indicator { - display: flex; - align-items: center; - gap: 4px; - margin-bottom: 8px; - font-size: 11px; -} - -.import-queue-badge { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 18px; - height: 18px; - padding: 0 6px; - background: var(--accent-dim); - color: var(--accent-text); - border-radius: 9px; - font-weight: 600; - font-size: 10px; -} - -.import-queue-text { - color: var(--text-2); -} - -/* Sidebar import progress */ -.sidebar-import-progress { - padding: 8px 12px; - background: var(--bg-2); - border-top: 1px solid var(--border-subtle); - font-size: 11px; -} - -.sidebar-import-header { - display: flex; - align-items: center; - gap: 6px; - margin-bottom: 4px; - color: var(--text-1); -} - -.sidebar-import-file { - color: var(--text-2); - font-size: 10px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - margin-bottom: 4px; -} - -.sidebar-import-progress .progress-bar { - height: 3px; -} - -.sidebar.collapsed .sidebar-import-progress { - padding: 6px; -} - -.sidebar.collapsed .sidebar-import-header span, -.sidebar.collapsed .sidebar-import-file { - display: none; -} - -/* Tag confirmation */ -.tag-confirm-delete { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 10px; - color: var(--text-1); -} - -.tag-confirm-yes { - cursor: pointer; - color: var(--error); - font-weight: 600; -} - -.tag-confirm-yes:hover { - text-decoration: underline; -} - -.tag-confirm-no { - cursor: pointer; - color: var(--text-2); - font-weight: 500; -} - -.tag-confirm-no:hover { - text-decoration: underline; -} - -/* Help overlay */ -.help-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.5); - display: flex; - align-items: center; - justify-content: center; - z-index: 200; - animation: fade-in 0.1s ease-out; -} - -.help-dialog { - background: var(--bg-2); - border: 1px solid var(--border); - border-radius: var(--radius-md); - padding: 24px; - min-width: 300px; - max-width: 400px; - box-shadow: var(--shadow-lg); -} - -.help-dialog h3 { - font-size: 16px; - font-weight: 600; - margin-bottom: 16px; -} - -.help-shortcuts { - display: flex; - flex-direction: column; - gap: 8px; - margin-bottom: 16px; -} - -.shortcut-row { - display: flex; - align-items: center; - gap: 12px; -} - -.shortcut-row kbd { - display: inline-block; - padding: 2px 8px; - background: var(--bg-0); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - font-family: 'JetBrains Mono', ui-monospace, monospace; - font-size: 11px; - color: var(--text-0); - min-width: 32px; - text-align: center; -} - -.shortcut-row span { - font-size: 13px; - color: var(--text-1); -} - -.help-close { - display: block; - width: 100%; - padding: 6px 12px; - background: var(--bg-3); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - color: var(--text-0); - font-size: 12px; - cursor: pointer; - text-align: center; -} - -.help-close:hover { - background: rgba(255,255,255,0.06); -} - -/* Add media modal (collections) */ -.modal.wide { - max-width: 600px; - max-height: 70vh; - overflow-y: auto; -} - -/* Database management */ -.db-actions { - display: flex; - flex-direction: column; - gap: 16px; - padding: 12px; -} - -.db-action-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 12px; - border-radius: 6px; - background: rgba(255,255,255,0.015); -} - -.db-action-info { - flex: 1; -} - -.db-action-info h4 { - font-size: 0.95rem; - font-weight: 600; - color: var(--text-0); - margin-bottom: 4px; -} - -.db-action-confirm { - display: flex; - align-items: center; - gap: 8px; - flex-shrink: 0; -} - -.danger-card { - border: 1px solid rgba(228, 88, 88, 0.25); -} - -/* Library select-all banner */ -.select-all-banner { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - padding: 8px 16px; - background: rgba(99, 102, 241, 0.08); - border-radius: 6px; - margin-bottom: 8px; - font-size: 0.85rem; - color: var(--text-1); -} - -.select-all-banner button { - background: none; - border: none; - color: var(--accent); - cursor: pointer; - font-weight: 600; - text-decoration: underline; - font-size: 0.85rem; - padding: 0; -} - -.select-all-banner button:hover { - color: var(--text-0); -} - -/* Media Player */ -.media-player { - position: relative; - background: var(--bg-0); - border-radius: var(--radius); - overflow: hidden; -} - -.media-player:focus { outline: none; } - -.media-player-audio .player-artwork { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 24px 16px 8px; - gap: 8px; -} - -.player-artwork img { - max-width: 200px; - max-height: 200px; - border-radius: var(--radius); - object-fit: cover; -} - -.player-artwork-placeholder { - width: 120px; - height: 120px; - display: flex; - align-items: center; - justify-content: center; - background: var(--bg-2); - border-radius: var(--radius); - font-size: 48px; - opacity: 0.3; -} - -.player-title { - font-size: 13px; - font-weight: 500; - color: var(--text-0); - text-align: center; -} - -.player-controls { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 14px; - background: var(--bg-2); -} - -.media-player-video .player-controls { - position: absolute; - bottom: 0; - left: 0; - right: 0; - background: rgba(0, 0, 0, 0.7); - opacity: 0; - transition: opacity 0.2s; -} - -.media-player-video:hover .player-controls { - opacity: 1; -} - -.play-btn, .mute-btn, .fullscreen-btn { - background: none; - border: none; - color: var(--text-0); - cursor: pointer; - font-size: 16px; - padding: 4px; - line-height: 1; - transition: color 0.1s; -} - -.play-btn:hover, .mute-btn:hover, .fullscreen-btn:hover { - color: var(--accent-text); -} - -.player-time { - font-size: 11px; - color: var(--text-2); - font-family: 'JetBrains Mono', ui-monospace, monospace; - min-width: 36px; - text-align: center; - user-select: none; -} - -.seek-bar { - flex: 1; - -webkit-appearance: none; - appearance: none; - height: 4px; - border-radius: 2px; - background: var(--bg-3); - outline: none; - cursor: pointer; -} - -.seek-bar::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 12px; - height: 12px; - border-radius: 50%; - background: var(--accent); - cursor: pointer; - border: none; -} - -.seek-bar::-moz-range-thumb { - width: 12px; - height: 12px; - border-radius: 50%; - background: var(--accent); - cursor: pointer; - border: none; -} - -.volume-slider { - width: 70px; - -webkit-appearance: none; - appearance: none; - height: 4px; - border-radius: 2px; - background: var(--bg-3); - outline: none; - cursor: pointer; -} - -.volume-slider::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 10px; - height: 10px; - border-radius: 50%; - background: var(--text-1); - cursor: pointer; - border: none; -} - -.volume-slider::-moz-range-thumb { - width: 10px; - height: 10px; - border-radius: 50%; - background: var(--text-1); - cursor: pointer; - border: none; -} - -/* Image Viewer */ -.image-viewer-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.92); - z-index: 150; - display: flex; - flex-direction: column; - animation: fade-in 0.15s ease-out; -} - -.image-viewer-overlay:focus { outline: none; } - -.image-viewer-toolbar { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 16px; - background: rgba(0, 0, 0, 0.5); - border-bottom: 1px solid rgba(255, 255, 255, 0.08); - z-index: 2; - user-select: none; -} - -.image-viewer-toolbar-left, -.image-viewer-toolbar-center, -.image-viewer-toolbar-right { - display: flex; - align-items: center; - gap: 6px; -} - -.iv-btn { - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.1); - color: var(--text-0); - border-radius: var(--radius-sm); - padding: 4px 10px; - font-size: 12px; - cursor: pointer; - transition: background 0.1s; -} - -.iv-btn:hover { - background: rgba(255, 255, 255, 0.12); -} - -.iv-close { - color: var(--error); - font-weight: 600; -} - -.iv-zoom-label { - font-size: 11px; - color: var(--text-1); - min-width: 40px; - text-align: center; - font-family: 'JetBrains Mono', ui-monospace, monospace; -} - -.image-viewer-canvas { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; - position: relative; -} - -.image-viewer-canvas img { - max-width: 100%; - max-height: 100%; - object-fit: contain; - user-select: none; - -webkit-user-drag: none; -} - -/* Markdown Viewer */ -.markdown-viewer { - padding: 16px; - text-align: left; - display: flex; - flex-direction: column; - gap: 12px; -} - -.markdown-toolbar { - display: flex; - gap: 8px; - padding: 8px; - background: var(--bg-2); - border-radius: var(--radius); - border: 1px solid var(--border); -} - -.toolbar-btn { - padding: 6px 12px; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--bg-1); - color: var(--text-1); - font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: all 0.15s; -} - -.toolbar-btn:hover { - background: var(--bg-3); - border-color: var(--border-strong); -} - -.toolbar-btn.active { - background: var(--accent); - color: white; - border-color: var(--accent); -} - -.markdown-source { - max-width: 100%; - background: var(--bg-3); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 16px; - overflow-x: auto; - font-family: 'Menlo', 'Monaco', 'Courier New', monospace; - font-size: 13px; - line-height: 1.6; - color: var(--text-0); - white-space: pre-wrap; - word-wrap: break-word; -} - -.markdown-source code { - font-family: inherit; - background: none; - padding: 0; - border: none; -} - -.markdown-content { - max-width: 800px; - color: var(--text-0); - line-height: 1.7; - font-size: 14px; - text-align: left; -} - -.markdown-content h1 { font-size: 1.8em; font-weight: 700; margin: 1em 0 0.5em; border-bottom: 1px solid var(--border-subtle); padding-bottom: 0.3em; } -.markdown-content h2 { font-size: 1.5em; font-weight: 600; margin: 0.8em 0 0.4em; border-bottom: 1px solid var(--border-subtle); padding-bottom: 0.2em; } -.markdown-content h3 { font-size: 1.25em; font-weight: 600; margin: 0.6em 0 0.3em; } -.markdown-content h4 { font-size: 1.1em; font-weight: 600; margin: 0.5em 0 0.25em; } -.markdown-content h5, .markdown-content h6 { font-size: 1em; font-weight: 600; margin: 0.4em 0 0.2em; color: var(--text-1); } - -.markdown-content p { margin: 0 0 1em; } -.markdown-content a { color: var(--accent); text-decoration: none; } -.markdown-content a:hover { text-decoration: underline; } - -.markdown-content pre { - background: var(--bg-3); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - padding: 12px 16px; - overflow-x: auto; - margin: 0 0 1em; - font-family: 'JetBrains Mono', ui-monospace, monospace; - font-size: 12px; - line-height: 1.5; -} - -.markdown-content code { - background: var(--bg-3); - padding: 1px 5px; - border-radius: var(--radius-sm); - font-family: 'JetBrains Mono', ui-monospace, monospace; - font-size: 0.9em; -} - -.markdown-content pre code { - background: none; - padding: 0; -} - -.markdown-content blockquote { - border-left: 3px solid var(--accent); - padding: 4px 16px; - margin: 0 0 1em; - color: var(--text-1); - background: rgba(124, 126, 245, 0.04); -} - -.markdown-content table { - width: 100%; - border-collapse: collapse; - margin: 0 0 1em; -} - -.markdown-content th, .markdown-content td { - padding: 6px 12px; - border: 1px solid var(--border); - font-size: 13px; -} - -.markdown-content th { - background: var(--bg-3); - font-weight: 600; - text-align: left; -} - -.markdown-content ul, .markdown-content ol { - margin: 0 0 1em; - padding-left: 24px; -} - -.markdown-content ul { list-style: disc; } -.markdown-content ol { list-style: decimal; } -.markdown-content li { padding: 2px 0; font-size: 14px; color: var(--text-0); } - -.markdown-content hr { - border: none; - border-top: 1px solid var(--border); - margin: 1.5em 0; -} - -.markdown-content img { - max-width: 100%; - border-radius: var(--radius); -} - -.markdown-content tr:nth-child(even) { - background: var(--bg-2); -} - -.markdown-content .footnote-definition { - font-size: 0.85em; - color: var(--text-1); - margin-top: 0.5em; - padding-left: 1.5em; -} - -.markdown-content .footnote-definition sup { - color: var(--accent); - margin-right: 4px; -} - -.markdown-content sup a { - color: var(--accent); - text-decoration: none; - font-size: 0.8em; -} - -/* Frontmatter Card */ -.frontmatter-card { - max-width: 800px; - background: var(--bg-2); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 12px 16px; - margin-bottom: 16px; -} - -.frontmatter-fields { - display: grid; - grid-template-columns: auto 1fr; - gap: 4px 12px; - margin: 0; -} - -.frontmatter-fields dt { - font-weight: 600; - font-size: 12px; - color: var(--text-1); - text-transform: capitalize; -} - -.frontmatter-fields dd { - font-size: 13px; - color: var(--text-0); - margin: 0; -} - -/* Duplicates */ -.duplicates-view { - padding: 0; -} - -.duplicates-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 16px; -} - -.duplicates-header h3 { - margin: 0; -} - -.duplicates-summary { - display: flex; - align-items: center; - gap: 12px; -} - -.duplicate-group { - border: 1px solid var(--border); - border-radius: var(--radius); - margin-bottom: 8px; - overflow: hidden; -} - -.duplicate-group-header { - display: flex; - align-items: center; - gap: 12px; - width: 100%; - padding: 10px 14px; - background: var(--bg-2); - border: none; - cursor: pointer; - text-align: left; - color: var(--text-0); - font-size: 13px; -} - -.duplicate-group-header:hover { - background: var(--bg-3); -} - -.expand-icon { - font-size: 10px; - width: 14px; - flex-shrink: 0; -} - -.group-name { - font-weight: 600; - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.group-badge { - background: var(--accent); - color: white; - padding: 2px 8px; - border-radius: 10px; - font-size: 11px; - font-weight: 600; - flex-shrink: 0; -} - -.group-size { - flex-shrink: 0; - font-size: 12px; -} - -.group-hash { - font-size: 11px; - flex-shrink: 0; -} - -.duplicate-items { - border-top: 1px solid var(--border); -} - -.duplicate-item { - display: flex; - align-items: center; - gap: 12px; - padding: 10px 14px; - border-bottom: 1px solid var(--border-subtle); -} - -.duplicate-item:last-child { - border-bottom: none; -} - -.duplicate-item-keep { - background: rgba(76, 175, 80, 0.06); -} - -.dup-thumb { - width: 48px; - height: 48px; - flex-shrink: 0; - border-radius: var(--radius-sm); - overflow: hidden; -} - -.dup-thumb-img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.dup-thumb-placeholder { - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - background: var(--bg-3); - font-size: 20px; - color: var(--text-2); -} - -.dup-info { - flex: 1; - min-width: 0; -} - -.dup-filename { - font-weight: 600; - font-size: 13px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.dup-path { - font-size: 11px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.dup-meta { - font-size: 12px; - margin-top: 2px; -} - -.dup-actions { - display: flex; - align-items: center; - gap: 6px; - flex-shrink: 0; -} - -.keep-badge { - background: rgba(76, 175, 80, 0.15); - color: #4caf50; - padding: 2px 10px; - border-radius: 10px; - font-size: 11px; - font-weight: 600; -} - -/* Login */ -.login-container { - display: flex; - align-items: center; - justify-content: center; - height: 100vh; - background: var(--bg-0); -} - -.login-card { - background: var(--bg-2); - border: 1px solid var(--border); - border-radius: var(--radius-md); - padding: 32px; - width: 360px; - box-shadow: var(--shadow-lg); -} - -.login-title { - font-size: 20px; - font-weight: 700; - color: var(--text-0); - text-align: center; - margin-bottom: 4px; -} - -.login-subtitle { - font-size: 13px; - color: var(--text-2); - text-align: center; - margin-bottom: 20px; -} - -.login-error { - background: rgba(228, 88, 88, 0.08); - border: 1px solid rgba(228, 88, 88, 0.2); - border-radius: var(--radius-sm); - padding: 8px 12px; - margin-bottom: 12px; - font-size: 12px; - color: var(--error); -} - -.login-form input[type="text"], -.login-form input[type="password"] { - width: 100%; -} - -.login-btn { - width: 100%; - padding: 8px 16px; - font-size: 13px; - margin-top: 4px; -} - -/* User Info (sidebar) */ -.user-info { - display: flex; - align-items: center; - gap: 6px; - font-size: 12px; - overflow: hidden; - min-width: 0; -} - -.user-name { - font-weight: 500; - color: var(--text-0); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: 90px; - flex-shrink: 1; -} - -/* Hide user details in collapsed sidebar, show only logout icon */ -.sidebar.collapsed .user-info .user-name, -.sidebar.collapsed .user-info .role-badge, -.sidebar.collapsed .user-info .btn { display: none; } - -.sidebar.collapsed .user-info { - justify-content: center; - padding: 4px; -} - -.role-badge { - display: inline-block; - padding: 1px 6px; - border-radius: var(--radius-sm); - font-size: 10px; - font-weight: 600; - text-transform: uppercase; -} - -.role-admin { background: rgba(139, 92, 246, 0.1); color: #9d8be0; } -.role-editor { background: rgba(34, 160, 80, 0.1); color: #5cb97a; } -.role-viewer { background: rgba(59, 120, 200, 0.1); color: #6ca0d4; } - -/* Settings fields */ -.settings-field { - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px 0; - border-bottom: 1px solid var(--border-subtle); -} - -.settings-field:last-child { border-bottom: none; } - -.settings-field select { - min-width: 120px; -} - -.settings-card-body { - padding-top: 4px; -} - -/* Detail no preview */ -.detail-no-preview { - padding: 32px 16px; - text-align: center; - display: flex; - flex-direction: column; - align-items: center; - gap: 12px; -} - -/* Light Theme */ -.theme-light { - --bg-0: #f5f5f7; - --bg-1: #eeeef0; - --bg-2: #ffffff; - --bg-3: #e8e8ec; - --border-subtle: rgba(0, 0, 0, 0.06); - --border: rgba(0, 0, 0, 0.1); - --border-strong: rgba(0, 0, 0, 0.16); - --text-0: #1a1a2e; - --text-1: #555570; - --text-2: #8888a0; - --accent: #6366f1; - --accent-dim: rgba(99, 102, 241, 0.1); - --accent-text: #4f52e8; - --shadow-sm: 0 1px 3px rgba(0,0,0,0.08); - --shadow: 0 2px 8px rgba(0,0,0,0.1); - --shadow-lg: 0 4px 20px rgba(0,0,0,0.12); -} - -.theme-light ::-webkit-scrollbar-thumb { - background: rgba(0, 0, 0, 0.2); -} - -.theme-light ::-webkit-scrollbar-thumb:hover { - background: rgba(0, 0, 0, 0.35); -} - -.theme-light ::-webkit-scrollbar-track { - background: rgba(0, 0, 0, 0.05); -} - -/* Skeleton Loading States */ -@keyframes skeleton-pulse { - 0% { opacity: 0.6; } - 50% { opacity: 0.3; } - 100% { opacity: 0.6; } -} - -.skeleton-pulse { - animation: skeleton-pulse 1.5s ease-in-out infinite; - background: var(--bg-3); - border-radius: 4px; -} - -.skeleton-card { - display: flex; - flex-direction: column; - gap: 8px; - padding: 8px; -} - -.skeleton-thumb { - width: 100%; - aspect-ratio: 1; - border-radius: 6px; -} - -.skeleton-text { - height: 14px; - width: 80%; -} - -.skeleton-text-short { - width: 50%; -} - -.skeleton-row { - display: flex; - gap: 12px; - padding: 10px 16px; - align-items: center; -} - -.skeleton-cell { - height: 14px; - flex: 1; - border-radius: 4px; -} - -.skeleton-cell-icon { - width: 32px; - height: 32px; - flex: none; - border-radius: 4px; -} - -.skeleton-cell-wide { - flex: 3; -} - -.loading-overlay { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 12px; - background: rgba(0, 0, 0, 0.3); - z-index: 100; - border-radius: 8px; -} - -.loading-spinner { - width: 32px; - height: 32px; - border: 3px solid var(--border); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; -} - -@keyframes spin { - to { transform: rotate(360deg); } -} - -.loading-message { - color: var(--text-1); - font-size: 0.9rem; -} - -/* Breadcrumb */ -.breadcrumb { - display: flex; - align-items: center; - gap: 4px; - padding: 8px 16px; - font-size: 0.85rem; - color: var(--text-2); -} - -.breadcrumb-sep { - color: var(--text-2); - opacity: 0.5; -} - -.breadcrumb-link { - color: var(--accent-text); - text-decoration: none; - cursor: pointer; -} - -.breadcrumb-link:hover { - text-decoration: underline; -} - -.breadcrumb-current { - color: var(--text-0); - font-weight: 500; -} - -/* Queue Panel */ -.queue-panel { - display: flex; - flex-direction: column; - border-left: 1px solid var(--border); - background: var(--bg-1); - min-width: 280px; - max-width: 320px; -} - -.queue-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 16px; - border-bottom: 1px solid var(--border-subtle); -} - -.queue-header h3 { - margin: 0; - font-size: 0.9rem; - color: var(--text-0); -} - -.queue-controls { - display: flex; - gap: 2px; -} - -.queue-list { - overflow-y: auto; - flex: 1; -} - -.queue-item { - display: flex; - align-items: center; - padding: 8px 16px; - cursor: pointer; - border-bottom: 1px solid var(--border-subtle); - transition: background 0.15s; -} - -.queue-item:hover { - background: var(--bg-2); -} - -.queue-item-active { - background: var(--accent-dim); - border-left: 3px solid var(--accent); -} - -.queue-item-info { - flex: 1; - min-width: 0; -} - -.queue-item-title { - display: block; - font-size: 0.85rem; - color: var(--text-0); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.queue-item-artist { - display: block; - font-size: 0.75rem; - color: var(--text-2); -} - -.queue-item-remove { - opacity: 0; - transition: opacity 0.15s; -} - -.queue-item:hover .queue-item-remove { - opacity: 1; -} - -.queue-empty { - padding: 24px 16px; - text-align: center; - color: var(--text-2); - font-size: 0.85rem; -} - -/* ── PDF Viewer ── */ -.pdf-viewer { - display: flex; - flex-direction: column; - height: 100%; - min-height: 500px; - background: var(--bg-0); - border-radius: var(--radius); - overflow: hidden; -} - -.pdf-toolbar { - display: flex; - align-items: center; - gap: 12px; - padding: 8px 12px; - background: var(--bg-1); - border-bottom: 1px solid var(--border); -} - -.pdf-toolbar-group { - display: flex; - align-items: center; - gap: 4px; -} - -.pdf-toolbar-btn { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - background: var(--bg-2); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - color: var(--text-1); - font-size: 14px; - cursor: pointer; - transition: all 0.15s; -} - -.pdf-toolbar-btn:hover:not(:disabled) { - background: var(--bg-3); - color: var(--text-0); -} - -.pdf-toolbar-btn:disabled { - opacity: 0.4; - cursor: not-allowed; -} - -.pdf-zoom-label { - min-width: 45px; - text-align: center; - font-size: 12px; - color: var(--text-1); -} - -.pdf-container { - flex: 1; - position: relative; - overflow: hidden; - background: var(--bg-2); -} - -.pdf-object { - width: 100%; - height: 100%; - border: none; -} - -.pdf-loading { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 12px; - background: var(--bg-1); - color: var(--text-1); -} - -.pdf-error { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 16px; - background: var(--bg-1); - color: var(--text-1); - padding: 24px; - text-align: center; -} - -.pdf-fallback { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 16px; - padding: 48px 24px; - text-align: center; - color: var(--text-2); -} - -/* Light theme adjustments */ -.theme-light .pdf-container { - background: #e8e8e8; -} - -/* ── Backlinks Panel ── */ -.backlinks-panel, -.outgoing-links-panel { - background: var(--bg-2); - border: 1px solid var(--border); - border-radius: var(--radius); - margin-top: 16px; - overflow: hidden; -} - -.backlinks-header, -.outgoing-links-header { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 14px; - background: var(--bg-3); - cursor: pointer; - user-select: none; - transition: background 0.1s; -} - -.backlinks-header:hover, -.outgoing-links-header:hover { - background: rgba(255, 255, 255, 0.04); -} - -.backlinks-toggle, -.outgoing-links-toggle { - font-size: 10px; - color: var(--text-2); - width: 12px; - text-align: center; -} - -.backlinks-title, -.outgoing-links-title { - font-size: 12px; - font-weight: 600; - color: var(--text-0); - flex: 1; -} - -.backlinks-count, -.outgoing-links-count { - font-size: 11px; - color: var(--text-2); -} - -.backlinks-reindex-btn { - display: flex; - align-items: center; - justify-content: center; - width: 22px; - height: 22px; - padding: 0; - margin-left: auto; - background: transparent; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - color: var(--text-2); - font-size: 12px; - cursor: pointer; - transition: background 0.1s, color 0.1s, border-color 0.1s; -} - -.backlinks-reindex-btn:hover:not(:disabled) { - background: var(--bg-2); - color: var(--text-0); - border-color: var(--border-strong); -} - -.backlinks-reindex-btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.spinner-tiny { - width: 10px; - height: 10px; - border: 1.5px solid var(--border); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.7s linear infinite; -} - -.backlinks-message { - padding: 8px 12px; - margin-bottom: 10px; - border-radius: var(--radius-sm); - font-size: 11px; -} - -.backlinks-message.success { - background: rgba(62, 201, 122, 0.08); - border: 1px solid rgba(62, 201, 122, 0.2); - color: var(--success); -} - -.backlinks-message.error { - background: rgba(228, 88, 88, 0.06); - border: 1px solid rgba(228, 88, 88, 0.2); - color: var(--error); -} - -.outgoing-links-unresolved-badge { - margin-left: 8px; - padding: 2px 8px; - border-radius: 10px; - font-size: 10px; - font-weight: 500; - background: rgba(212, 160, 55, 0.12); - color: var(--warning); -} - -.outgoing-links-global-unresolved { - display: flex; - align-items: center; - gap: 6px; - margin-top: 12px; - padding: 10px 12px; - background: rgba(212, 160, 55, 0.06); - border: 1px solid rgba(212, 160, 55, 0.15); - border-radius: var(--radius-sm); - font-size: 11px; - color: var(--text-2); -} - -.outgoing-links-global-unresolved .unresolved-icon { - color: var(--warning); -} - -.backlinks-content, -.outgoing-links-content { - padding: 12px; - border-top: 1px solid var(--border-subtle); -} - -.backlinks-loading, -.outgoing-links-loading { - display: flex; - align-items: center; - gap: 8px; - padding: 12px; - color: var(--text-2); - font-size: 12px; -} - -.backlinks-error, -.outgoing-links-error { - padding: 8px 12px; - background: rgba(228, 88, 88, 0.06); - border: 1px solid rgba(228, 88, 88, 0.2); - border-radius: var(--radius-sm); - font-size: 12px; - color: var(--error); -} - -.backlinks-empty, -.outgoing-links-empty { - padding: 16px; - text-align: center; - color: var(--text-2); - font-size: 12px; - font-style: italic; -} - -.backlinks-list, -.outgoing-links-list { - list-style: none; - padding: 0; - margin: 0; - display: flex; - flex-direction: column; - gap: 6px; -} - -.backlink-item, -.outgoing-link-item { - padding: 10px 12px; - background: var(--bg-0); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-sm); - cursor: pointer; - transition: background 0.1s, border-color 0.1s; -} - -.backlink-item:hover, -.outgoing-link-item:hover { - background: var(--bg-1); - border-color: var(--border); -} - -.backlink-source, -.outgoing-link-target { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 4px; -} - -.backlink-title, -.outgoing-link-text { - font-size: 13px; - font-weight: 500; - color: var(--text-0); - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.backlink-type-badge, -.outgoing-link-type-badge { - display: inline-block; - padding: 1px 6px; - border-radius: 8px; - font-size: 9px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.03em; -} - -.backlink-type-wikilink, -.link-type-wikilink { - background: rgba(124, 126, 245, 0.1); - color: var(--accent-text); -} - -.backlink-type-embed, -.link-type-embed { - background: rgba(139, 92, 246, 0.1); - color: #9d8be0; -} - -.backlink-type-markdown_link, -.link-type-markdown_link { - background: rgba(59, 120, 200, 0.1); - color: #6ca0d4; -} - -.backlink-context { - font-size: 11px; - color: var(--text-2); - line-height: 1.4; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; -} - -.backlink-line { - color: var(--text-1); - font-weight: 500; -} - -.unresolved-badge { - padding: 1px 6px; - border-radius: 8px; - font-size: 9px; - font-weight: 600; - background: rgba(212, 160, 55, 0.1); - color: var(--warning); -} - -.outgoing-link-item.unresolved { - opacity: 0.7; - border-style: dashed; -} - -.spinner-small { - width: 14px; - height: 14px; - border: 2px solid var(--border); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 0.7s linear infinite; -} - -/* ── Graph View ── */ -.graph-view { - display: flex; - flex-direction: column; - height: 100%; - background: var(--bg-1); - border-radius: var(--radius); - overflow: hidden; -} - -.graph-toolbar { - display: flex; - align-items: center; - gap: 16px; - padding: 12px 16px; - background: var(--bg-2); - border-bottom: 1px solid var(--border); -} - -.graph-title { - font-size: 14px; - font-weight: 600; - color: var(--text-0); -} - -.graph-controls { - display: flex; - align-items: center; - gap: 8px; - font-size: 12px; - color: var(--text-1); -} - -.graph-controls select { - padding: 4px 20px 4px 8px; - font-size: 11px; - background: var(--bg-3); -} - -.graph-stats { - margin-left: auto; - font-size: 11px; - color: var(--text-2); -} - -.graph-container { - flex: 1; - position: relative; - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; - background: var(--bg-0); -} - -.graph-loading, -.graph-error, -.graph-empty { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 12px; - padding: 48px; - color: var(--text-2); - font-size: 13px; - text-align: center; -} - -.graph-svg { - max-width: 100%; - max-height: 100%; - cursor: grab; -} - -.graph-svg-container { - position: relative; - width: 100%; - height: 100%; -} - -.graph-zoom-controls { - position: absolute; - top: 16px; - left: 16px; - display: flex; - flex-direction: column; - gap: 8px; - z-index: 5; -} - -.zoom-btn { - width: 36px; - height: 36px; - border-radius: 6px; - background: var(--bg-2); - border: 1px solid var(--border); - color: var(--text-0); - font-size: 18px; - font-weight: bold; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all 0.15s; - box-shadow: var(--shadow-sm); -} - -.zoom-btn:hover { - background: var(--bg-3); - border-color: var(--border-strong); - transform: scale(1.05); -} - -.zoom-btn:active { - transform: scale(0.95); -} - -.graph-edges line { - stroke: var(--border-strong); - stroke-width: 1; - opacity: 0.6; -} - -.graph-edges line.edge-type-wikilink { - stroke: var(--accent); -} - -.graph-edges line.edge-type-embed { - stroke: #9d8be0; - stroke-dasharray: 4 2; -} - -.graph-nodes .graph-node { - cursor: pointer; -} - -.graph-nodes .graph-node circle { - fill: #4caf50; - stroke: #388e3c; - stroke-width: 2; - transition: fill 0.15s, stroke 0.15s; -} - -.graph-nodes .graph-node:hover circle { - fill: #66bb6a; -} - -.graph-nodes .graph-node.selected circle { - fill: var(--accent); - stroke: #5456d6; -} - -.graph-nodes .graph-node text { - fill: var(--text-1); - font-size: 11px; - pointer-events: none; -} - -/* ── Node Details Panel ── */ -.node-details-panel { - position: absolute; - top: 16px; - right: 16px; - width: 280px; - background: var(--bg-2); - border: 1px solid var(--border); - border-radius: var(--radius); - box-shadow: var(--shadow); - z-index: 10; -} - -.node-details-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 14px; - border-bottom: 1px solid var(--border-subtle); -} - -.node-details-header h3 { - font-size: 13px; - font-weight: 600; - color: var(--text-0); - margin: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.node-details-panel .close-btn { - background: none; - border: none; - color: var(--text-2); - cursor: pointer; - font-size: 14px; - padding: 2px 6px; - line-height: 1; -} - -.node-details-panel .close-btn:hover { - color: var(--text-0); -} - -.node-details-content { - padding: 14px; -} - -.node-details-content .node-title { - font-size: 12px; - color: var(--text-1); - margin-bottom: 12px; -} - -.node-stats { - display: flex; - gap: 16px; - margin-bottom: 12px; -} - -.node-stats .stat { - font-size: 12px; - color: var(--text-2); -} - -.node-stats .stat strong { - color: var(--text-0); -} - -/* ── Physics Controls Panel ── */ -.physics-controls-panel { - position: absolute; - top: 16px; - right: 16px; - width: 300px; - background: var(--bg-2); - border: 1px solid var(--border); - border-radius: var(--radius); - box-shadow: var(--shadow); - padding: 16px; - z-index: 10; -} - -.physics-controls-panel h4 { - font-size: 13px; - font-weight: 600; - color: var(--text-0); - margin: 0 0 16px 0; - padding-bottom: 8px; - border-bottom: 1px solid var(--border-subtle); -} - -.control-group { - margin-bottom: 14px; -} - -.control-group label { - display: block; - font-size: 11px; - font-weight: 500; - color: var(--text-1); - margin-bottom: 6px; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.control-group input[type="range"] { - width: 100%; - height: 4px; - border-radius: 2px; - background: var(--bg-3); - outline: none; - -webkit-appearance: none; -} - -.control-group input[type="range"]::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 14px; - height: 14px; - border-radius: 50%; - background: var(--accent); - cursor: pointer; - transition: transform 0.1s; -} - -.control-group input[type="range"]::-webkit-slider-thumb:hover { - transform: scale(1.15); -} - -.control-group input[type="range"]::-moz-range-thumb { - width: 14px; - height: 14px; - border-radius: 50%; - background: var(--accent); - cursor: pointer; - border: none; - transition: transform 0.1s; -} - -.control-group input[type="range"]::-moz-range-thumb:hover { - transform: scale(1.15); -} - -.control-value { - display: inline-block; - margin-top: 4px; - font-size: 11px; - color: var(--text-2); - font-family: 'JetBrains Mono', ui-monospace, monospace; -} - -.physics-controls-panel .btn { - width: 100%; - margin-top: 8px; -} - -/* Wikilink Styles (in markdown) */ -.wikilink { - color: var(--accent-text); - text-decoration: none; - border-bottom: 1px dashed var(--accent); - cursor: pointer; - transition: border-color 0.1s, color 0.1s; -} - -.wikilink:hover { - color: var(--accent); - border-bottom-style: solid; -} - -.wikilink-embed { - display: inline-block; - padding: 2px 8px; - background: rgba(139, 92, 246, 0.08); - border: 1px dashed rgba(139, 92, 246, 0.3); - border-radius: var(--radius-sm); - color: #9d8be0; - font-size: 12px; - cursor: default; -} - -/* Light theme adjustments for links and graph */ -.theme-light .graph-nodes .graph-node text { - fill: var(--text-0); -} - -.theme-light .graph-edges line { - stroke: rgba(0, 0, 0, 0.2); -} -"#; +pub static STYLES: Asset = asset!("/assets/css/main.css");