diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 473fe3a..0000000 --- a/.editorconfig +++ /dev/null @@ -1,32 +0,0 @@ -root = true - -[*] -charset = utf-8 -end_of_line = lf -insert_final_newline = true -trim_trailing_whitespace = true - -[*.rs] -indent_style = space -indent_size = 2 - -[justfile] -indent_style = space -indent_size = 2 - -[*.toml] -indent_style = space -indent_size = 2 - -[*.md] -indent_style = space -indent_size = 2 -trim_trailing_whitespace = false - -[*.nix] -indent_style = space -indent_size = 2 - -[*.scss] -indent_style = space -indent_size = 2 diff --git a/.envrc b/.envrc index 4ba1317..3550a30 100644 --- a/.envrc +++ b/.envrc @@ -1,4 +1 @@ -watch_file nix/shell.nix -watch_file flake.lock - use flake diff --git a/.taplo.toml b/.taplo.toml deleted file mode 100644 index 48e58d7..0000000 --- a/.taplo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[formatting] -align_entries = true -column_width = 110 -compact_arrays = false -reorder_keys = true - -[[rule]] -include = [ "**/Cargo.toml" ] -keys = [ "package", "workspace.package" ] - -[rule.formatting] -reorder_keys = false diff --git a/Cargo.lock b/Cargo.lock index 735750e..622df0c 100644 Binary files a/Cargo.lock and b/Cargo.lock differ diff --git a/Cargo.toml b/Cargo.toml index eef9a81..8d9ea1e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,67 +1,73 @@ [workspace] -exclude = [ - "crates/pinakes-core/tests/fixtures/test-plugin", - "examples/plugins/auto-tagger", - "examples/plugins/text-enrichment", - "examples/plugins/subtitle-detector", - "examples/plugins/cbz-comics", -] -members = [ "crates/*", "packages/*", "xtask" ] +members = ["crates/*", "xtask"] +exclude = ["crates/pinakes-core/tests/fixtures/test-plugin"] resolver = "3" [workspace.package] -edition = "2024" # keep in sync with .rustfmt.toml -license = "EUPL-1.2" -rust-version = "1.95.0" -version = "0.4.0-dev" -readme = true +edition = "2024" # keep in sync with .rustfmt.toml +version = "0.3.0-dev" +license = "EUPL-1.2" +readme = true +rust-version = "1.95.0" # follows nightly Rust [workspace.dependencies] -# Crate components for Pinakes. Those are the internal dependencies that are built -# while building any package. -pinakes-core = { path = "./crates/pinakes-core" } -pinakes-enrichment = { path = "./crates/pinakes-enrichment" } -pinakes-metadata = { path = "./crates/pinakes-metadata" } -pinakes-migrations = { path = "./crates/pinakes-migrations" } -pinakes-plugin = { path = "./crates/pinakes-plugin" } +# Crate components for Pinakes. +pinakes-core = { path = "./crates/pinakes-core" } +pinakes-server = { path = "./crates/pinakes-server" } pinakes-plugin-api = { path = "./crates/pinakes-plugin-api" } -pinakes-sync = { path = "./crates/pinakes-sync" } -pinakes-types = { path = "./crates/pinakes-types" } +pinakes-ui = { path = "./crates/pinakes-ui" } +pinakes-tui = { path = "./crates/pinakes-tui" } -# Pinakes itself is a REST API server. UI and TUI are official visual components -# that connect to the server. Using the API documentation, the user can write -# their own clients, but we separate "crates" and "packages" to establish the -# distinction properly. -pinakes-server = { path = "./packages/pinakes-server" } -pinakes-tui = { path = "./packages/pinakes-tui" } -pinakes-ui = { path = "./packages/pinakes-ui" } - -# Other dependencies. Declaring them in the virtual manifests lets use reuse the crates -# without having to track individual crate version across different types of crates. This -# also includes *dev* dependencies. -ammonia = "4.1.2" +tokio = { version = "1.49.0", features = ["full"] } +tokio-util = { version = "0.7.18", features = ["rt"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" +toml = "1.0.3" +clap = { version = "4.5.60", features = ["derive", "env"] } +chrono = { version = "0.4.44", features = ["serde"] } +uuid = { version = "1.21.0", features = ["v7", "serde"] } +thiserror = "2.0.18" anyhow = "1.0.102" -argon2 = { version = "0.5.3", features = [ "std" ] } -async-trait = "0.1.89" -axum = { version = "0.8.9", features = [ "macros", "multipart" ] } -axum-server = { version = "0.8.0" } -blake3 = "1.8.5" -chrono = { version = "0.4.44", features = [ "serde" ] } -clap = { version = "4.6.1", features = [ "derive", "env" ] } -crossterm = "0.29.0" -deadpool-postgres = "0.14.1" -dioxus = { version = "0.7.9", features = [ "desktop", "router" ] } -dioxus-core = { version = "0.7.9" } -dioxus-free-icons = { version = "0.10.0", features = [ "font-awesome-solid" ] } -ed25519-dalek = { version = "2.2.0", features = [ "std" ] } +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] } +blake3 = "1.8.3" +rustc-hash = "2.1.1" +ed25519-dalek = { version = "2.1.1", features = ["std"] } +lofty = "0.23.2" +lopdf = "0.39.0" epub = "2.1.5" -futures = "0.3.32" -gloo-timers = { version = "0.4.0", features = [ "futures" ] } -governor = "0.10.4" +matroska = "0.30.0" gray_matter = "0.3.2" -http = "1.4.0" -http-body-util = "0.1.3" -image = { version = "0.25.10", default-features = false, features = [ +kamadak-exif = "0.6.1" +rusqlite = { version = "=0.37.0", features = ["bundled", "column_decltype"] } +tokio-postgres = { version = "0.7.16", features = [ + "with-uuid-1", + "with-chrono-0_4", + "with-serde_json-1", +] } +deadpool-postgres = "0.14.1" +postgres-types = { version = "0.2.12", features = ["derive"] } +postgres-native-tls = "0.5.2" +native-tls = "0.2.18" +refinery = { version = "0.9.0", features = ["rusqlite", "tokio-postgres"] } +walkdir = "2.5.0" +notify = { version = "8.2.0", features = ["macos_fsevent"] } +winnow = "0.7.14" +axum = { version = "0.8.8", features = ["macros", "multipart"] } +axum-server = { version = "0.8.0" } +tower = "0.5.3" +tower-http = { version = "0.6.8", features = ["cors", "trace", "set-header"] } +governor = "0.10.4" +tower_governor = "0.8.0" +reqwest = { version = "0.13.2", features = ["json", "query", "blocking"] } +url = "2.5" +ratatui = "0.30.0" +crossterm = "0.29.0" +dioxus = { version = "0.7.3", features = ["desktop", "router"] } +dioxus-core = { version = "0.7.3" } +async-trait = "0.1.89" +futures = "0.3.32" +image = { version = "0.25.9", default-features = false, features = [ "jpeg", "png", "webp", @@ -69,97 +75,71 @@ image = { version = "0.25.10", default-features = false, features = [ "tiff", "bmp", ] } -image_hasher = "3.1.1" -kamadak-exif = "0.6.1" -lofty = "0.24.0" -lopdf = "0.40.0" -matroska = "0.30.1" +pulldown-cmark = "0.13.1" +ammonia = "4.1.2" +argon2 = { version = "0.5.3", features = ["std"] } mime_guess = "2.0.5" -moka = { version = "0.12.15", features = [ "future" ] } -native-tls = "0.2.18" -notify = { version = "8.2.0", features = [ "macos_fsevent" ] } -percent-encoding = "2.3.2" -postgres-native-tls = "0.5.3" -postgres-types = { version = "0.2.13", features = [ "derive" ] } -pulldown-cmark = "0.13.4" -rand = "0.10.1" -ratatui = "0.30.0" -refinery = { version = "0.9.1", features = [ "tokio-postgres" ] } regex = "1.12.3" -reqwest = { version = "0.13.3", features = [ "json", "query", "blocking" ] } +dioxus-free-icons = { version = "0.10.0", features = ["font-awesome-solid"] } rfd = "0.17.2" -rusqlite = { version = "0.39.0", features = [ "bundled", "column_decltype" ] } -rusqlite_migration = "2.5.0" -rustc-hash = "2.1.2" -serde = { version = "1.0.228", features = [ "derive" ] } -serde_json = "1.0.150" -tempfile = "3.27.0" -thiserror = "2.0.18" -tokio = { version = "1.52.3", features = [ "full" ] } -tokio-postgres = { version = "0.7.17", features = [ "with-uuid-1", "with-chrono-0_4", "with-serde_json-1" ] } -tokio-util = { version = "0.7.18", features = [ "rt" ] } -toml = "1.1.2" -tower = "0.5.3" -tower-http = { version = "0.6.11", features = [ "cors", "trace", "set-header" ] } -tower_governor = "0.8.0" -tracing = "0.1.44" -tracing-subscriber = { version = "0.3.23", features = [ "env-filter", "json" ] } -url = "2.5" +gloo-timers = { version = "0.3.0", features = ["futures"] } +rand = "0.10.0" +moka = { version = "0.12.14", features = ["future"] } urlencoding = "2.1.3" -utoipa = { version = "5.5.0", features = [ "axum_extras", "uuid", "chrono" ] } +image_hasher = "3.1.1" +percent-encoding = "2.3.2" +http = "1.4.0" +wasmtime = { version = "42.0.1", features = ["component-model"] } +wit-bindgen = "0.53.1" +tempfile = "3.26.0" +utoipa = { version = "5.4.0", features = ["axum_extras", "uuid", "chrono"] } utoipa-axum = { version = "0.2.0" } -utoipa-swagger-ui = { version = "9.0.2", features = [ "axum" ] } -uuid = { version = "1.23.1", features = [ "v7", "serde" ] } -walkdir = "2.5.0" -wasmtime = { version = "45.0.0", features = [ "component-model" ] } -winnow = "1.0.3" -wit-bindgen = "0.57.1" +utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] } # See: # [workspace.lints.clippy] -cargo = { level = "warn", priority = -1 } +cargo = { level = "warn", priority = -1 } complexity = { level = "warn", priority = -1 } -nursery = { level = "warn", priority = -1 } -pedantic = { level = "warn", priority = -1 } -perf = { level = "warn", priority = -1 } -style = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +perf = { level = "warn", priority = -1 } +style = { level = "warn", priority = -1 } # The lint groups above enable some less-than-desirable rules, we should manually # enable those to keep our sanity. -absolute_paths = "allow" -arbitrary_source_item_ordering = "allow" -clone_on_ref_ptr = "warn" -dbg_macro = "warn" -empty_drop = "warn" -empty_structs_with_brackets = "warn" -exit = "warn" -filetype_is_file = "warn" -get_unwrap = "warn" -implicit_return = "allow" -infinite_loop = "warn" +absolute_paths = "allow" +arbitrary_source_item_ordering = "allow" +clone_on_ref_ptr = "warn" +dbg_macro = "warn" +empty_drop = "warn" +empty_structs_with_brackets = "warn" +exit = "warn" +filetype_is_file = "warn" +get_unwrap = "warn" +implicit_return = "allow" +infinite_loop = "warn" map_with_unused_argument_over_ranges = "warn" -missing_docs_in_private_items = "allow" -multiple_crate_versions = "allow" # :( -non_ascii_literal = "allow" -non_std_lazy_statics = "warn" -pathbuf_init_then_push = "warn" -pattern_type_mismatch = "allow" -question_mark_used = "allow" -rc_buffer = "warn" -rc_mutex = "warn" -rest_pat_in_fully_bound_structs = "warn" -significant_drop_tightening = "allow" # rusqlite Statement<'conn> borrows the guard; cannot drop early -similar_names = "allow" -single_call_fn = "allow" -std_instead_of_core = "allow" -too_long_first_doc_paragraph = "allow" -too_many_arguments = "allow" -too_many_lines = "allow" -undocumented_unsafe_blocks = "warn" -unnecessary_safety_comment = "warn" -unused_result_ok = "warn" -unused_trait_names = "allow" +missing_docs_in_private_items = "allow" +multiple_crate_versions = "allow" # :( +non_ascii_literal = "allow" +non_std_lazy_statics = "warn" +pathbuf_init_then_push = "warn" +pattern_type_mismatch = "allow" +question_mark_used = "allow" +rc_buffer = "warn" +rc_mutex = "warn" +rest_pat_in_fully_bound_structs = "warn" +similar_names = "allow" +single_call_fn = "allow" +std_instead_of_core = "allow" +too_long_first_doc_paragraph = "allow" +too_many_lines = "allow" +undocumented_unsafe_blocks = "warn" +unnecessary_safety_comment = "warn" +unused_result_ok = "warn" +unused_trait_names = "allow" +too_many_arguments = "allow" # False positive: # clippy's build script check doesn't recognize workspace-inherited metadata @@ -167,23 +147,23 @@ unused_trait_names = "allow" cargo_common_metadata = "allow" # In the honor of a recent Cloudflare regression -panic = "deny" +panic = "deny" unwrap_used = "deny" # Less dangerous, but we'd like to know # Those must be opt-in, and are fine ONLY in tests and examples. -expect_used = "warn" -print_stderr = "warn" -print_stdout = "warn" -todo = "warn" +expect_used = "warn" +print_stderr = "warn" +print_stdout = "warn" +todo = "warn" unimplemented = "warn" -unreachable = "warn" +unreachable = "warn" [profile.dev.package] -argon2 = { opt-level = 3 } -blake3 = { opt-level = 3 } -image = { opt-level = 3 } -lofty = { opt-level = 3 } -lopdf = { opt-level = 3 } +blake3 = { opt-level = 3 } +image = { opt-level = 3 } +regex = { opt-level = 3 } +argon2 = { opt-level = 3 } matroska = { opt-level = 3 } -regex = { opt-level = 3 } +lopdf = { opt-level = 3 } +lofty = { opt-level = 3 } diff --git a/HACKING.md b/HACKING.md new file mode 100644 index 0000000..5f5bafb --- /dev/null +++ b/HACKING.md @@ -0,0 +1,32 @@ +# Hacking Pinakes + +Pinakes is a lot of things. One of the things it aims to be is _complete_. To be +complete in features, to be complete in documentation and to be complete in +hackability. This document covers, very comprehensively, how you may: + +- Build Pinakes +- Develop Pinakes +- Contribute to Pinakes + +for developers as well as: + +- Distribute Pinakes + +for Pinakes maintainers and packagers. + +## Building Pinakes + +Pinakes is built with Rust (nightly edition) and various crates. The most +_notable_ crate among those is Dioxus, which provides its own toolkit. The UI +for Pinakes is usually _not_ built with the Dioxus CLI but instead with +`cargo build`. This also applies to distributable build results. + +[Direnv]: https://direnv.net + +To build Pinakes, simply pick the components you want and build them with +`cargo build --release --package `. A Nix shell is provided for +reproducible developer environments and you may obtain all build dependencies by +simply running `nix develop` or `direnv allow` if you use [Direnv]. Nix is a +cross-platform build tool and works on most Linux distributions as well as +Darwin. While distro-specific package managers _might_ work, Nix is the only +supported one. diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 9ba5d42..0000000 Binary files a/LICENSE and /dev/null differ diff --git a/crates/pinakes-core/Cargo.toml b/crates/pinakes-core/Cargo.toml index 08698d4..c4ba043 100644 --- a/crates/pinakes-core/Cargo.toml +++ b/crates/pinakes-core/Cargo.toml @@ -4,18 +4,6 @@ edition.workspace = true version.workspace = true license.workspace = true -[features] -default = ["sqlite", "postgres"] -ffmpeg-tests = [] -sqlite = ["dep:rusqlite"] -postgres = [ - "dep:tokio-postgres", - "dep:deadpool-postgres", - "dep:postgres-types", - "dep:postgres-native-tls", - "dep:native-tls", -] - [dependencies] tokio = { workspace = true } serde = { workspace = true } @@ -32,18 +20,13 @@ lopdf = { workspace = true } epub = { workspace = true } matroska = { workspace = true } gray_matter = { workspace = true } -rusqlite = { workspace = true, optional = true } -tokio-postgres = { workspace = true, optional = true } -deadpool-postgres = { workspace = true, optional = true } -postgres-types = { workspace = true, optional = true } -postgres-native-tls = { workspace = true, optional = true } -native-tls = { workspace = true, optional = true } -pinakes-migrations = { workspace = true } -pinakes-types = { workspace = true } -pinakes-metadata = { workspace = true } -pinakes-plugin = { workspace = true } -pinakes-enrichment = { workspace = true } -pinakes-sync = { workspace = true } +rusqlite = { workspace = true } +tokio-postgres = { workspace = true } +deadpool-postgres = { workspace = true } +postgres-types = { workspace = true } +postgres-native-tls = { workspace = true } +native-tls = { workspace = true } +refinery = { workspace = true } walkdir = { workspace = true } notify = { workspace = true } winnow = { workspace = true } @@ -60,13 +43,18 @@ moka = { workspace = true } urlencoding = { workspace = true } image_hasher = { workspace = true } rustc-hash = { workspace = true } -pinakes-plugin-api = { workspace = true } -wasmtime = { workspace = true } -ed25519-dalek = { workspace = true } + +# Plugin system +pinakes-plugin-api.workspace = true +wasmtime.workspace = true +ed25519-dalek.workspace = true + +[features] +ffmpeg-tests = [] + +[lints] +workspace = true [dev-dependencies] tempfile = { workspace = true } rand = { workspace = true } - -[lints] -workspace = true diff --git a/crates/pinakes-core/src/config.rs b/crates/pinakes-core/src/config.rs index bc5f4ab..325ff30 100644 --- a/crates/pinakes-core/src/config.rs +++ b/crates/pinakes-core/src/config.rs @@ -1 +1,1668 @@ -pub use pinakes_types::config::*; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +/// Expand environment variables in a string using `std::env::var` for lookup. +/// 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 { + expand_env_vars(input, |name| { + std::env::var(name).map_err(|_| { + crate::error::PinakesError::Config(format!( + "environment variable not set: {name}" + )) + }) + }) +} + +/// Expand environment variables in a string using the provided lookup function. +/// Supports both `${VAR_NAME}` and `$VAR_NAME` syntax. +fn expand_env_vars( + input: &str, + lookup: impl Fn(&str) -> crate::error::Result, +) -> crate::error::Result { + 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 '{' + } + + // 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(), + )); + } + + result.push_str(&lookup(&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 rate_limits: RateLimitConfig, + #[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, + #[serde(default)] + pub trash: TrashConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScheduledTaskConfig { + pub id: String, + pub enabled: bool, + pub schedule: crate::scheduler::Schedule, + pub last_run: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RateLimitConfig { + /// Global rate limit: requests per second (token replenish interval). + /// Default: 1 (combined with `burst_size=100` gives ~100 req/sec) + #[serde(default = "default_global_per_second")] + pub global_per_second: u64, + /// Global rate limit: burst size (max concurrent requests per IP) + #[serde(default = "default_global_burst")] + pub global_burst_size: u32, + /// Login rate limit: seconds between token replenishment. + /// Default: 12 (one token every 12s, combined with burst=5 gives ~5 req/min) + #[serde(default = "default_login_per_second")] + pub login_per_second: u64, + /// Login rate limit: burst size + #[serde(default = "default_login_burst")] + pub login_burst_size: u32, + /// Search rate limit: seconds between token replenishment. + /// Default: 6 (one token every 6s, combined with burst=10 gives ~10 req/min) + #[serde(default = "default_search_per_second")] + pub search_per_second: u64, + /// Search rate limit: burst size + #[serde(default = "default_search_burst")] + pub search_burst_size: u32, + /// Streaming rate limit: seconds between token replenishment. + /// Default: 60 (one per minute) + #[serde(default = "default_stream_per_second")] + pub stream_per_second: u64, + /// Streaming rate limit: burst size (max concurrent streams) + #[serde(default = "default_stream_burst")] + pub stream_burst_size: u32, + /// Share token rate limit: seconds between token replenishment. + /// Default: 2 + #[serde(default = "default_share_per_second")] + pub share_per_second: u64, + /// Share token rate limit: burst size + #[serde(default = "default_share_burst")] + pub share_burst_size: u32, +} + +const fn default_global_per_second() -> u64 { + 1 +} +const fn default_global_burst() -> u32 { + 100 +} +const fn default_login_per_second() -> u64 { + 12 +} +const fn default_login_burst() -> u32 { + 5 +} +const fn default_search_per_second() -> u64 { + 6 +} +const fn default_search_burst() -> u32 { + 10 +} +const fn default_stream_per_second() -> u64 { + 60 +} +const fn default_stream_burst() -> u32 { + 5 +} +const fn default_share_per_second() -> u64 { + 2 +} +const fn default_share_burst() -> u32 { + 20 +} + +impl Default for RateLimitConfig { + fn default() -> Self { + Self { + global_per_second: default_global_per_second(), + global_burst_size: default_global_burst(), + login_per_second: default_login_per_second(), + login_burst_size: default_login_burst(), + search_per_second: default_search_per_second(), + search_burst_size: default_search_burst(), + stream_per_second: default_stream_per_second(), + stream_burst_size: default_stream_burst(), + share_per_second: default_share_per_second(), + share_burst_size: default_share_burst(), + } + } +} + +impl RateLimitConfig { + /// Validate that all rate limit values are positive. + /// + /// # Errors + /// + /// Returns an error string if any rate limit value is zero. + pub fn validate(&self) -> Result<(), String> { + for (name, value) in [ + ("global_per_second", self.global_per_second), + ("global_burst_size", u64::from(self.global_burst_size)), + ("login_per_second", self.login_per_second), + ("login_burst_size", u64::from(self.login_burst_size)), + ("search_per_second", self.search_per_second), + ("search_burst_size", u64::from(self.search_burst_size)), + ("stream_per_second", self.stream_per_second), + ("stream_burst_size", u64::from(self.stream_burst_size)), + ("share_per_second", self.share_per_second), + ("share_burst_size", u64::from(self.share_burst_size)), + ] { + if value == 0 { + return Err(format!("{name} must be > 0")); + } + } + Ok(()) + } +} + +#[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, + /// Maximum time a job is allowed to run before being cancelled (in seconds). + /// Set to 0 to disable timeout. Default: 3600 (1 hour). + #[serde(default = "default_job_timeout")] + pub job_timeout_secs: u64, +} + +const fn default_worker_count() -> usize { + 2 +} +const fn default_cache_ttl() -> u64 { + 60 +} +const fn default_job_timeout() -> u64 { + 3600 +} + +impl Default for JobsConfig { + fn default() -> Self { + Self { + worker_count: default_worker_count(), + cache_ttl_secs: default_cache_ttl(), + job_timeout_secs: default_job_timeout(), + } + } +} + +#[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, +} + +const fn default_thumb_size() -> u32 { + 320 +} +const fn default_thumb_quality() -> u8 { + 80 +} +const fn default_video_seek() -> u32 { + 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(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebhookConfig { + 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, +} + +fn default_theme() -> String { + "dark".to_string() +} +fn default_view() -> String { + "library".to_string() +} +const fn default_page_size() -> usize { + 50 +} +fn default_view_mode() -> String { + "grid".to_string() +} +const fn default_true() -> bool { + 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, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountsConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub users: Vec, + /// Session expiry in hours. Defaults to 24. + #[serde(default = "default_session_expiry_hours")] + pub session_expiry_hours: u64, +} + +const fn default_session_expiry_hours() -> u64 { + 24 +} + +impl Default for AccountsConfig { + fn default() -> Self { + Self { + enabled: false, + users: Vec::new(), + session_expiry_hours: default_session_expiry_hours(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserAccount { + pub username: String, + pub password_hash: String, + #[serde(default)] + pub role: UserRole, +} + +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, +)] +#[serde(rename_all = "lowercase")] +pub enum UserRole { + Admin, + Editor, + #[default] + Viewer, +} + +impl UserRole { + #[must_use] + pub const fn can_read(self) -> bool { + true + } + + #[must_use] + pub const fn can_write(self) -> bool { + matches!(self, Self::Admin | Self::Editor) + } + + #[must_use] + pub const 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"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginTimeoutConfig { + /// Timeout for capability discovery queries (`supported_types`, + /// `interested_events`) + #[serde(default = "default_capability_query_timeout")] + pub capability_query_secs: u64, + /// Timeout for processing calls (`extract_metadata`, `generate_thumbnail`) + #[serde(default = "default_processing_timeout")] + pub processing_secs: u64, + /// Timeout for event handler calls + #[serde(default = "default_event_handler_timeout")] + pub event_handler_secs: u64, +} + +const fn default_capability_query_timeout() -> u64 { + 2 +} + +const fn default_processing_timeout() -> u64 { + 30 +} + +const fn default_event_handler_timeout() -> u64 { + 10 +} + +impl Default for PluginTimeoutConfig { + fn default() -> Self { + Self { + capability_query_secs: default_capability_query_timeout(), + processing_secs: default_processing_timeout(), + event_handler_secs: default_event_handler_timeout(), + } + } +} + +#[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 timeouts: PluginTimeoutConfig, + #[serde(default = "default_max_consecutive_failures")] + pub max_consecutive_failures: u32, + + /// Hex-encoded Ed25519 public keys trusted for plugin signature + /// verification. Each entry is 64 hex characters (32 bytes). + #[serde(default)] + pub trusted_keys: Vec, +} + +fn default_plugin_data_dir() -> PathBuf { + Config::default_data_dir().join("plugins").join("data") +} + +fn default_plugin_cache_dir() -> PathBuf { + Config::default_data_dir().join("plugins").join("cache") +} + +const fn default_max_concurrent_ops() -> usize { + 4 +} + +const fn default_plugin_timeout() -> u64 { + 30 +} + +const fn default_max_consecutive_failures() -> u32 { + 5 +} + +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(), + timeouts: PluginTimeoutConfig::default(), + max_consecutive_failures: default_max_consecutive_failures(), + trusted_keys: vec![], + } + } +} + +#[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, +} + +const fn default_cache_ttl_hours() -> u64 { + 48 +} + +const fn default_max_concurrent_transcodes() -> usize { + 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, +} + +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(), + }, + ], + } + } +} + +#[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, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct EnrichmentSources { + #[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, +} + +#[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, +} + +const fn default_auto_sync_interval() -> u64 { + 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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CloudSyncRule { + 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, +} + +impl Default for CloudConfig { + fn default() -> Self { + Self { + enabled: false, + auto_sync_interval_mins: default_auto_sync_interval(), + accounts: vec![], + } + } +} + +#[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, +} + +const fn default_retention_days() -> u64 { + 90 +} + +impl Default for AnalyticsConfig { + fn default() -> Self { + Self { + enabled: false, + track_usage: true, + retention_days: default_retention_days(), + } + } +} + +/// Feature toggles for photo processing (image analysis features). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PhotoFeatures { + /// 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, + + /// Generate multi-resolution thumbnails (tiny, grid, preview) + #[serde(default)] + pub multi_resolution_thumbnails: bool, +} + +impl Default for PhotoFeatures { + fn default() -> Self { + Self { + generate_perceptual_hash: true, + auto_tag_from_exif: false, + multi_resolution_thumbnails: false, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PhotoConfig { + /// Feature toggles for photo processing + #[serde(flatten)] + pub features: PhotoFeatures, + + /// 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, + + /// 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, +} + +impl PhotoConfig { + /// Returns true if perceptual hashing is enabled. + #[must_use] + pub const fn generate_perceptual_hash(&self) -> bool { + self.features.generate_perceptual_hash + } + + /// Returns true if auto-tagging from EXIF is enabled. + #[must_use] + pub const fn auto_tag_from_exif(&self) -> bool { + self.features.auto_tag_from_exif + } + + /// Returns true if multi-resolution thumbnails are enabled. + #[must_use] + pub const fn multi_resolution_thumbnails(&self) -> bool { + self.features.multi_resolution_thumbnails + } +} + +const fn default_min_event_photos() -> usize { + 5 +} + +const fn default_event_time_gap() -> i64 { + 2 * 60 * 60 // 2 hours +} + +const fn default_event_distance() -> f64 { + 1.0 // 1 km +} + +impl Default for PhotoConfig { + fn default() -> Self { + Self { + features: PhotoFeatures::default(), + 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(), + } + } +} + +#[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, +} + +fn default_managed_storage_dir() -> PathBuf { + Config::default_data_dir().join("managed") +} + +const fn default_max_upload_size() -> u64 { + 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, + } + } +} + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, +)] +#[serde(rename_all = "snake_case")] +pub enum ConflictResolution { + ServerWins, + ClientWins, + #[default] + KeepBoth, + Manual, +} + +#[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, +} + +const fn default_max_sync_file_size() -> u64 { + 4096 // 4GB +} + +const fn default_chunk_size() -> u64 { + 4096 // 4MB +} + +const fn default_upload_timeout() -> u64 { + 24 // 24 hours +} + +const fn default_max_concurrent_uploads() -> usize { + 3 +} + +const fn default_sync_log_retention() -> u64 { + 90 // 90 days +} + +fn default_temp_upload_dir() -> PathBuf { + 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(), + } + } +} + +/// Core permission flags for the sharing subsystem. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SharingPermissions { + /// Enable sharing functionality + #[serde(default = "default_true")] + pub enabled: bool, + /// Allow creating public share links + #[serde(default = "default_true")] + pub allow_public_links: bool, + /// Allow users to reshare content shared with them + #[serde(default = "default_true")] + pub allow_reshare: bool, +} + +impl Default for SharingPermissions { + fn default() -> Self { + Self { + enabled: true, + allow_public_links: true, + allow_reshare: true, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SharingConfig { + /// Core permission flags for sharing + #[serde(flatten)] + pub permissions: SharingPermissions, + /// Require password for public share links + #[serde(default)] + pub require_public_link_password: bool, + /// Enable share notifications + #[serde(default = "default_true")] + pub notifications_enabled: bool, + /// Maximum expiry time for public links in hours (0 = unlimited) + #[serde(default)] + pub max_public_link_expiry_hours: u64, + /// 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, +} + +impl SharingConfig { + /// Returns true if sharing is enabled. + #[must_use] + pub const fn enabled(&self) -> bool { + self.permissions.enabled + } + + /// Returns true if public links are allowed. + #[must_use] + pub const fn allow_public_links(&self) -> bool { + self.permissions.allow_public_links + } + + /// Returns true if resharing is allowed. + #[must_use] + pub const fn allow_reshare(&self) -> bool { + self.permissions.allow_reshare + } +} + +const fn default_notification_retention() -> u64 { + 30 +} + +const fn default_activity_retention() -> u64 { + 90 +} + +impl Default for SharingConfig { + fn default() -> Self { + Self { + permissions: SharingPermissions::default(), + require_public_link_password: false, + notifications_enabled: true, + max_public_link_expiry_hours: 0, + notification_retention_days: default_notification_retention(), + activity_retention_days: default_activity_retention(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrashConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_trash_retention_days")] + pub retention_days: u64, + #[serde(default)] + pub auto_empty: bool, +} + +const fn default_trash_retention_days() -> u64 { + 30 +} + +impl Default for TrashConfig { + fn default() -> Self { + Self { + enabled: false, + retention_days: default_trash_retention_days(), + auto_empty: false, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StorageConfig { + 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, +} + +impl StorageBackendType { + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::Sqlite => "sqlite", + Self::Postgres => "postgres", + } + } +} + +impl std::fmt::Display for StorageBackendType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SqliteConfig { + 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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DirectoryConfig { + 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, +} + +const fn default_import_concurrency() -> usize { + 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, + /// Enable CORS (Cross-Origin Resource Sharing). + /// When false, default localhost origins are used. + #[serde(default)] + pub cors_enabled: bool, + /// Allowed CORS origins when `cors_enabled` is true. + /// If empty and `cors_enabled` is true, defaults to localhost origins. + #[serde(default)] + pub cors_origins: Vec, + /// TLS/HTTPS configuration + #[serde(default)] + pub tls: TlsConfig, + /// Enable the Swagger UI at /api/docs. + /// Defaults to true. Set to false to disable in production if desired. + #[serde(default = "default_true")] + pub swagger_ui: bool, +} + +/// 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, +} + +const fn default_http_port() -> u16 { + 80 +} + +const fn default_hsts_max_age() -> u64 { + 31_536_000 // 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(), + } + } +} + +impl TlsConfig { + /// Validate TLS configuration + /// + /// # Errors + /// + /// Returns an error string if TLS is enabled but required paths are missing + /// or invalid. + 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 { + /// Load configuration from a TOML file, expanding environment variables in + /// secret fields. + /// + /// # Errors + /// + /// Returns [`crate::error::PinakesError`] if the file cannot be read, parsed, + /// or contains invalid environment variable references. + 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)?; + } + + // 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(()) + } + + /// Try loading from file, falling back to defaults if the file doesn't exist. + /// + /// # Errors + /// + /// Returns [`crate::error::PinakesError`] if the file exists but cannot be + /// read or parsed. + 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. + /// + /// # Errors + /// + /// Returns [`crate::error::PinakesError`] if the file cannot be written or + /// the config cannot be serialized. + 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. + /// + /// # Errors + /// + /// Returns [`crate::error::PinakesError`] if a required directory cannot be + /// created or is read-only. + pub fn ensure_dirs(&self) -> crate::error::Result<()> { + if let Some(ref sqlite) = self.storage.sqlite + && 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(()) + } + + /// Returns the default config file path following XDG conventions. + #[must_use] + pub fn default_config_path() -> PathBuf { + std::env::var("XDG_CONFIG_HOME").map_or_else( + |_| { + std::env::var("HOME").map_or_else( + |_| PathBuf::from("pinakes.toml"), + |home| { + PathBuf::from(home) + .join(".config") + .join("pinakes") + .join("pinakes.toml") + }, + ) + }, + |xdg| PathBuf::from(xdg).join("pinakes").join("pinakes.toml"), + ) + } + + /// Validate configuration values for correctness. + /// + /// # Errors + /// + /// Returns an error string if any configuration value is invalid. + 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 rate limits + self.rate_limits.validate()?; + + // Validate TLS configuration + self.server.tls.validate()?; + Ok(()) + } + + /// Returns the default data directory following XDG conventions. + #[must_use] + pub fn default_data_dir() -> PathBuf { + std::env::var("XDG_DATA_HOME").map_or_else( + |_| { + std::env::var("HOME").map_or_else( + |_| PathBuf::from("pinakes-data"), + |home| { + PathBuf::from(home) + .join(".local") + .join("share") + .join("pinakes") + }, + ) + }, + |xdg| PathBuf::from(xdg).join("pinakes"), + ) + } +} + +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, + cors_enabled: false, + cors_origins: vec![], + tls: TlsConfig::default(), + swagger_ui: true, + }, + ui: UiConfig::default(), + accounts: AccountsConfig::default(), + rate_limits: RateLimitConfig::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(), + trash: TrashConfig::default(), + } + } +} + +#[cfg(test)] +mod tests { + use rustc_hash::FxHashMap; + + 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 + } + + #[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_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_high() { + let config = test_config_with_concurrency(256); + assert!(config.validate().is_ok()); + } + + // Environment variable expansion tests using expand_env_vars with a + // HashMap lookup. This avoids unsafe std::env::set_var and is + // thread-safe for parallel test execution. + fn test_lookup<'a>( + vars: &'a FxHashMap<&str, &str>, + ) -> impl Fn(&str) -> crate::error::Result + 'a { + move |name| { + vars + .get(name) + .map(std::string::ToString::to_string) + .ok_or_else(|| { + crate::error::PinakesError::Config(format!( + "environment variable not set: {name}" + )) + }) + } + } + + #[test] + fn test_expand_env_var_simple() { + let vars = [("TEST_VAR_SIMPLE", "test_value")] + .into_iter() + .collect::>(); + let result = expand_env_vars("$TEST_VAR_SIMPLE", test_lookup(&vars)); + assert_eq!(result.unwrap(), "test_value"); + } + + #[test] + fn test_expand_env_var_braces() { + let vars = [("TEST_VAR_BRACES", "test_value")] + .into_iter() + .collect::>(); + let result = expand_env_vars("${TEST_VAR_BRACES}", test_lookup(&vars)); + assert_eq!(result.unwrap(), "test_value"); + } + + #[test] + fn test_expand_env_var_embedded() { + let vars = [("TEST_VAR_EMBEDDED", "value")] + .into_iter() + .collect::>(); + let result = + expand_env_vars("prefix_${TEST_VAR_EMBEDDED}_suffix", test_lookup(&vars)); + assert_eq!(result.unwrap(), "prefix_value_suffix"); + } + + #[test] + fn test_expand_env_var_multiple() { + let vars = [("VAR1", "value1"), ("VAR2", "value2")] + .into_iter() + .collect::>(); + let result = expand_env_vars("${VAR1}_${VAR2}", test_lookup(&vars)); + assert_eq!(result.unwrap(), "value1_value2"); + } + + #[test] + fn test_expand_env_var_missing() { + let vars = FxHashMap::default(); + let result = expand_env_vars("${NONEXISTENT_VAR}", test_lookup(&vars)); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("environment variable not set") + ); + } + + #[test] + fn test_expand_env_var_empty_name() { + let vars = FxHashMap::default(); + let result = expand_env_vars("${}", test_lookup(&vars)); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("empty environment variable name") + ); + } + + #[test] + fn test_expand_env_var_escaped() { + let vars = FxHashMap::default(); + let result = expand_env_vars("\\$NOT_A_VAR", test_lookup(&vars)); + assert_eq!(result.unwrap(), "$NOT_A_VAR"); + } + + #[test] + fn test_expand_env_var_no_vars() { + let vars = FxHashMap::default(); + let result = expand_env_vars("plain_text", test_lookup(&vars)); + assert_eq!(result.unwrap(), "plain_text"); + } + + #[test] + fn test_expand_env_var_underscore() { + let vars = [("TEST_VAR_NAME", "value")] + .into_iter() + .collect::>(); + let result = expand_env_vars("$TEST_VAR_NAME", test_lookup(&vars)); + assert_eq!(result.unwrap(), "value"); + } + + #[test] + fn test_expand_env_var_mixed_syntax() { + let vars = [("VAR1_MIXED", "v1"), ("VAR2_MIXED", "v2")] + .into_iter() + .collect::>(); + let result = + expand_env_vars("$VAR1_MIXED and ${VAR2_MIXED}", test_lookup(&vars)); + assert_eq!(result.unwrap(), "v1 and v2"); + } +} diff --git a/crates/pinakes-enrichment/src/books.rs b/crates/pinakes-core/src/enrichment/books.rs similarity index 83% rename from crates/pinakes-enrichment/src/books.rs rename to crates/pinakes-core/src/enrichment/books.rs index 6786ca1..02226f5 100644 --- a/crates/pinakes-enrichment/src/books.rs +++ b/crates/pinakes-core/src/enrichment/books.rs @@ -1,10 +1,4 @@ -use std::sync::LazyLock; - use chrono::Utc; -use pinakes_types::{ - error::{PinakesError, Result}, - model::MediaItem, -}; use uuid::Uuid; use super::{ @@ -14,33 +8,10 @@ use super::{ googlebooks::GoogleBooksClient, openlibrary::OpenLibraryClient, }; - -// --- ISBN helper (duplicated from pinakes-core::books to avoid circular dep) -// --- -static ISBN_PATTERNS: LazyLock> = LazyLock::new(|| { - [ - 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])", - r"ISBN(?:-13)?\s+(\d{13})", - r"ISBN(?:-10)?\s+(\d{9}[\dXx])", - r"\b(\d{3}-\d{1,5}-\d{1,7}-\d{1,7}-\d)\b", - r"\b(\d{1,5}-\d{1,7}-\d{1,7}-[\dXx])\b", - ] - .iter() - .filter_map(|p| regex::Regex::new(p).ok()) - .collect() -}); - -fn extract_isbn_from_text(text: &str) -> Option { - for pattern in ISBN_PATTERNS.iter() { - if let Some(captures) = pattern.captures(text) - && let Some(isbn) = captures.get(1) - { - return Some(isbn.as_str().to_string()); - } - } - None -} +use crate::{ + error::{PinakesError, Result}, + model::MediaItem, +}; /// Book enricher that tries `OpenLibrary` first, then falls back to Google /// Books @@ -74,8 +45,8 @@ impl BookEnricher { })?; Ok(Some(ExternalMetadata { - id: Uuid::now_v7(), - media_id: pinakes_types::model::MediaId(Uuid::nil()), /* Will be set by caller */ + id: Uuid::new_v4(), + media_id: crate::model::MediaId(Uuid::nil()), // Will be set by caller source: EnrichmentSourceType::OpenLibrary, external_id: None, metadata_json, @@ -104,8 +75,8 @@ impl BookEnricher { })?; Ok(Some(ExternalMetadata { - id: Uuid::now_v7(), - media_id: pinakes_types::model::MediaId(Uuid::nil()), /* Will be set by caller */ + 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, @@ -136,8 +107,8 @@ impl BookEnricher { })?; return Ok(Some(ExternalMetadata { - id: Uuid::now_v7(), - media_id: pinakes_types::model::MediaId(Uuid::nil()), + id: Uuid::new_v4(), + media_id: crate::model::MediaId(Uuid::nil()), source: EnrichmentSourceType::OpenLibrary, external_id: result.key.clone(), metadata_json, @@ -155,8 +126,8 @@ impl BookEnricher { })?; return Ok(Some(ExternalMetadata { - id: Uuid::now_v7(), - media_id: pinakes_types::model::MediaId(Uuid::nil()), + id: Uuid::new_v4(), + media_id: crate::model::MediaId(Uuid::nil()), source: EnrichmentSourceType::GoogleBooks, external_id: Some(book.id.clone()), metadata_json, @@ -180,7 +151,7 @@ impl MetadataEnricher for BookEnricher { // Try ISBN-based enrichment first by checking title/description for ISBN // patterns if let Some(ref title) = item.title { - if let Some(isbn) = extract_isbn_from_text(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)); diff --git a/crates/pinakes-enrichment/src/googlebooks.rs b/crates/pinakes-core/src/enrichment/googlebooks.rs similarity index 99% rename from crates/pinakes-enrichment/src/googlebooks.rs rename to crates/pinakes-core/src/enrichment/googlebooks.rs index 1b4abe2..abfb118 100644 --- a/crates/pinakes-enrichment/src/googlebooks.rs +++ b/crates/pinakes-core/src/enrichment/googlebooks.rs @@ -1,8 +1,9 @@ use std::fmt::Write as _; -use pinakes_types::error::{PinakesError, Result}; use serde::{Deserialize, Serialize}; +use crate::error::{PinakesError, Result}; + /// Google Books API client for book metadata enrichment pub struct GoogleBooksClient { client: reqwest::Client, diff --git a/crates/pinakes-enrichment/src/lastfm.rs b/crates/pinakes-core/src/enrichment/lastfm.rs similarity index 99% rename from crates/pinakes-enrichment/src/lastfm.rs rename to crates/pinakes-core/src/enrichment/lastfm.rs index cdcb4bd..9bde8c5 100644 --- a/crates/pinakes-enrichment/src/lastfm.rs +++ b/crates/pinakes-core/src/enrichment/lastfm.rs @@ -3,13 +3,13 @@ use std::time::Duration; use chrono::Utc; -use pinakes_types::{ - error::{PinakesError, Result}, - model::MediaItem, -}; use uuid::Uuid; use super::{EnrichmentSourceType, ExternalMetadata, MetadataEnricher}; +use crate::{ + error::{PinakesError, Result}, + model::MediaItem, +}; pub struct LastFmEnricher { client: reqwest::Client, diff --git a/crates/pinakes-enrichment/src/mod.rs b/crates/pinakes-core/src/enrichment/mod.rs similarity index 98% rename from crates/pinakes-enrichment/src/mod.rs rename to crates/pinakes-core/src/enrichment/mod.rs index 527601d..16de3cb 100644 --- a/crates/pinakes-enrichment/src/mod.rs +++ b/crates/pinakes-core/src/enrichment/mod.rs @@ -11,7 +11,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use pinakes_types::{ +use crate::{ error::Result, model::{MediaId, MediaItem}, }; diff --git a/crates/pinakes-enrichment/src/musicbrainz.rs b/crates/pinakes-core/src/enrichment/musicbrainz.rs similarity index 99% rename from crates/pinakes-enrichment/src/musicbrainz.rs rename to crates/pinakes-core/src/enrichment/musicbrainz.rs index 344e6f0..9115f38 100644 --- a/crates/pinakes-enrichment/src/musicbrainz.rs +++ b/crates/pinakes-core/src/enrichment/musicbrainz.rs @@ -3,13 +3,13 @@ use std::{fmt::Write as _, time::Duration}; use chrono::Utc; -use pinakes_types::{ - error::{PinakesError, Result}, - model::MediaItem, -}; use uuid::Uuid; use super::{EnrichmentSourceType, ExternalMetadata, MetadataEnricher}; +use crate::{ + error::{PinakesError, Result}, + model::MediaItem, +}; pub struct MusicBrainzEnricher { client: reqwest::Client, diff --git a/crates/pinakes-enrichment/src/openlibrary.rs b/crates/pinakes-core/src/enrichment/openlibrary.rs similarity index 99% rename from crates/pinakes-enrichment/src/openlibrary.rs rename to crates/pinakes-core/src/enrichment/openlibrary.rs index 0dd4db7..02ca965 100644 --- a/crates/pinakes-enrichment/src/openlibrary.rs +++ b/crates/pinakes-core/src/enrichment/openlibrary.rs @@ -1,8 +1,9 @@ use std::fmt::Write as _; -use pinakes_types::error::{PinakesError, Result}; use serde::{Deserialize, Serialize}; +use crate::error::{PinakesError, Result}; + /// `OpenLibrary` API client for book metadata enrichment pub struct OpenLibraryClient { client: reqwest::Client, diff --git a/crates/pinakes-enrichment/src/tmdb.rs b/crates/pinakes-core/src/enrichment/tmdb.rs similarity index 87% rename from crates/pinakes-enrichment/src/tmdb.rs rename to crates/pinakes-core/src/enrichment/tmdb.rs index 027402e..146deb8 100644 --- a/crates/pinakes-enrichment/src/tmdb.rs +++ b/crates/pinakes-core/src/enrichment/tmdb.rs @@ -3,13 +3,13 @@ use std::time::Duration; use chrono::Utc; -use pinakes_types::{ - error::{PinakesError, Result}, - model::MediaItem, -}; use uuid::Uuid; use super::{EnrichmentSourceType, ExternalMetadata, MetadataEnricher}; +use crate::{ + error::{PinakesError, Result}, + model::MediaItem, +}; pub struct TmdbEnricher { client: reqwest::Client, @@ -20,21 +20,21 @@ pub struct TmdbEnricher { impl TmdbEnricher { /// Create a new `TMDb` enricher. /// - /// # Errors + /// # Panics /// - /// Returns an error if the HTTP client cannot be built (e.g. TLS - /// initialisation failure). - pub fn new(api_key: String) -> Result { - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(10)) - .connect_timeout(Duration::from_secs(5)) - .build() - .map_err(|e| PinakesError::External(e.to_string()))?; - Ok(Self { - client, + /// Panics if the HTTP client cannot be built (programming error in client + /// configuration). + #[must_use] + pub fn new(api_key: String) -> Self { + Self { + client: reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .connect_timeout(Duration::from_secs(5)) + .build() + .expect("failed to build HTTP client with configured timeouts"), api_key, base_url: "https://api.themoviedb.org/3".to_string(), - }) + } } } diff --git a/crates/pinakes-core/src/error.rs b/crates/pinakes-core/src/error.rs index f96beac..7e43726 100644 --- a/crates/pinakes-core/src/error.rs +++ b/crates/pinakes-core/src/error.rs @@ -1,9 +1,146 @@ -//! Error types for pinakes-core. -//! -//! Re-exports from [`pinakes_types::error`] for use within this crate. -pub use pinakes_types::error::{PinakesError, Result}; +use std::path::PathBuf; -/// Create a curried error mapper with operation context. +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum PinakesError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("database error: {0}")] + Database(String), + + #[error("migration error: {0}")] + Migration(String), + + #[error("configuration error: {0}")] + Config(String), + + #[error("media item not found: {0}")] + NotFound(String), + + #[error("duplicate content hash: {0}")] + DuplicateHash(String), + + #[error("unsupported media type for path: {0}")] + UnsupportedMediaType(PathBuf), + + #[error("metadata extraction failed: {0}")] + MetadataExtraction(String), + + #[error("thumbnail generation failed: {0}")] + ThumbnailGeneration(String), + + #[error("search query parse error: {0}")] + SearchParse(String), + + #[error("file not found at path: {0}")] + FileNotFound(PathBuf), + + #[error("tag not found: {0}")] + TagNotFound(String), + + #[error("collection not found: {0}")] + CollectionNotFound(String), + + #[error("invalid operation: {0}")] + InvalidOperation(String), + + #[error("invalid data: {0}")] + InvalidData(String), + + #[error("authentication error: {0}")] + Authentication(String), + + #[error("authorization error: {0}")] + Authorization(String), + + #[error("path not allowed: {0}")] + PathNotAllowed(String), + + #[error("external API error: {0}")] + External(String), + + // Managed Storage errors + #[error("managed storage not enabled")] + ManagedStorageDisabled, + + #[error("upload too large: {0} bytes exceeds limit")] + UploadTooLarge(u64), + + #[error("blob not found: {0}")] + BlobNotFound(String), + + #[error("storage integrity error: {0}")] + StorageIntegrity(String), + + // Sync errors + #[error("sync not enabled")] + SyncDisabled, + + #[error("device not found: {0}")] + DeviceNotFound(String), + + #[error("sync conflict: {0}")] + SyncConflict(String), + + #[error("upload session expired: {0}")] + UploadSessionExpired(String), + + #[error("upload session not found: {0}")] + UploadSessionNotFound(String), + + #[error("chunk out of order: expected {expected}, got {actual}")] + ChunkOutOfOrder { expected: u64, actual: u64 }, + + // Sharing errors + #[error("share not found: {0}")] + ShareNotFound(String), + + #[error("share expired: {0}")] + ShareExpired(String), + + #[error("share password required")] + SharePasswordRequired, + + #[error("share password invalid")] + SharePasswordInvalid, + + #[error("insufficient share permissions")] + InsufficientSharePermissions, + + #[error("serialization error: {0}")] + Serialization(String), + + #[error("external tool `{tool}` failed: {stderr}")] + ExternalTool { tool: String, stderr: String }, + + #[error("subtitle track {index} not found in media")] + SubtitleTrackNotFound { index: u32 }, + + #[error("invalid language code: {0}")] + InvalidLanguageCode(String), +} + +impl From for PinakesError { + fn from(e: rusqlite::Error) -> Self { + Self::Database(e.to_string()) + } +} + +impl From for PinakesError { + fn from(e: tokio_postgres::Error) -> Self { + Self::Database(e.to_string()) + } +} + +impl From for PinakesError { + fn from(e: serde_json::Error) -> Self { + Self::Serialization(e.to_string()) + } +} + +/// Build a closure that wraps a database error with operation context. /// /// Usage: `stmt.execute(params).map_err(db_ctx("insert_media", media_id))?;` pub fn db_ctx( @@ -13,3 +150,5 @@ pub fn db_ctx( let context = format!("{operation} [{entity}]"); move |e| PinakesError::Database(format!("{context}: {e}")) } + +pub type Result = std::result::Result; diff --git a/crates/pinakes-core/src/events.rs b/crates/pinakes-core/src/events.rs index e2222e9..cbe90ae 100644 --- a/crates/pinakes-core/src/events.rs +++ b/crates/pinakes-core/src/events.rs @@ -3,7 +3,10 @@ use chrono::{DateTime, Utc}; -use crate::model::{MediaId, MediaItem}; +use crate::{ + error::Result, + model::{MediaId, MediaItem}, +}; /// Configuration for event detection #[derive(Debug, Clone)] @@ -65,16 +68,15 @@ fn haversine_distance(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 { } /// Detect photo events from a list of media items -#[must_use] pub fn detect_events( mut items: Vec, config: &EventDetectionConfig, -) -> Vec { +) -> Result> { // Filter to only photos with date_taken items.retain(|item| item.date_taken.is_some()); if items.is_empty() { - return Vec::new(); + return Ok(Vec::new()); } // Sort by date_taken (None < Some, but all are Some after retain) @@ -82,7 +84,7 @@ pub fn detect_events( let mut events: Vec = Vec::new(); let Some(first_date) = items[0].date_taken else { - return Vec::new(); + return Ok(Vec::new()); }; let mut current_event_items: Vec = vec![items[0].id]; let mut current_start_time = first_date; @@ -169,22 +171,21 @@ pub fn detect_events( }); } - events + Ok(events) } /// Detect photo bursts (rapid sequences of photos) /// Returns groups of media IDs that are likely burst sequences -#[must_use] pub fn detect_bursts( mut items: Vec, max_gap_secs: i64, min_burst_size: usize, -) -> Vec> { +) -> Result>> { // Filter to only photos with date_taken items.retain(|item| item.date_taken.is_some()); if items.is_empty() { - return Vec::new(); + return Ok(Vec::new()); } // Sort by date_taken (None < Some, but all are Some after retain) @@ -192,7 +193,7 @@ pub fn detect_bursts( let mut bursts: Vec> = Vec::new(); let Some(first_date) = items[0].date_taken else { - return Vec::new(); + return Ok(Vec::new()); }; let mut current_burst: Vec = vec![items[0].id]; let mut last_time = first_date; @@ -220,5 +221,5 @@ pub fn detect_bursts( bursts.push(current_burst); } - bursts + Ok(bursts) } diff --git a/crates/pinakes-core/src/import.rs b/crates/pinakes-core/src/import.rs index 0981a96..7bae8a3 100644 --- a/crates/pinakes-core/src/import.rs +++ b/crates/pinakes-core/src/import.rs @@ -12,6 +12,7 @@ use crate::{ hash::compute_file_hash, links, media_type::{BuiltinMediaType, MediaType}, + metadata, model::{ AuditAction, CustomField, @@ -182,7 +183,7 @@ pub async fn import_file_with_options( let path_clone = path.clone(); let media_type_clone = media_type.clone(); tokio::task::spawn_blocking(move || { - pinakes_metadata::extract_metadata(&path_clone, &media_type_clone) + metadata::extract_metadata(&path_clone, &media_type_clone) }) .await .map_err(|e| PinakesError::MetadataExtraction(e.to_string()))?? @@ -226,7 +227,7 @@ pub async fn import_file_with_options( let perceptual_hash = if options.photo_config.generate_perceptual_hash() && media_type.category() == crate::media_type::MediaCategory::Image { - pinakes_metadata::image::generate_perceptual_hash(&path) + crate::metadata::image::generate_perceptual_hash(&path) } else { None }; diff --git a/crates/pinakes-core/src/jobs.rs b/crates/pinakes-core/src/jobs.rs index ba68220..d4bc106 100644 --- a/crates/pinakes-core/src/jobs.rs +++ b/crates/pinakes-core/src/jobs.rs @@ -185,12 +185,12 @@ impl JobQueue { }; { + let mut map = self.jobs.write().await; + map.insert(id, job); // Prune old terminal jobs to prevent unbounded memory growth. // Keep at most 500 completed/failed/cancelled entries, removing the // oldest. const MAX_TERMINAL_JOBS: usize = 500; - let mut map = self.jobs.write().await; - map.insert(id, job); let mut terminal: Vec<(Uuid, chrono::DateTime)> = map .iter() .filter(|(_, j)| { diff --git a/crates/pinakes-core/src/lib.rs b/crates/pinakes-core/src/lib.rs index 3920f0f..17fdb19 100644 --- a/crates/pinakes-core/src/lib.rs +++ b/crates/pinakes-core/src/lib.rs @@ -4,6 +4,7 @@ pub mod books; pub mod cache; pub mod collections; pub mod config; +pub mod enrichment; pub mod error; pub mod events; pub mod export; @@ -13,7 +14,9 @@ pub mod integrity; pub mod jobs; pub mod links; pub mod managed_storage; -pub use pinakes_types::{media_type, model}; +pub mod media_type; +pub mod metadata; +pub mod model; pub mod opener; pub mod path_validation; pub mod playlists; diff --git a/crates/pinakes-metadata/src/audio.rs b/crates/pinakes-core/src/metadata/audio.rs similarity index 99% rename from crates/pinakes-metadata/src/audio.rs rename to crates/pinakes-core/src/metadata/audio.rs index b33ff3f..576f511 100644 --- a/crates/pinakes-metadata/src/audio.rs +++ b/crates/pinakes-core/src/metadata/audio.rs @@ -4,13 +4,13 @@ use lofty::{ file::{AudioFile, TaggedFileExt}, tag::Accessor, }; -use pinakes_types::{ + +use super::{ExtractedMetadata, MetadataExtractor}; +use crate::{ error::{PinakesError, Result}, media_type::{BuiltinMediaType, MediaType}, }; -use super::{ExtractedMetadata, MetadataExtractor}; - pub struct AudioExtractor; impl MetadataExtractor for AudioExtractor { diff --git a/crates/pinakes-metadata/src/document.rs b/crates/pinakes-core/src/metadata/document.rs similarity index 81% rename from crates/pinakes-metadata/src/document.rs rename to crates/pinakes-core/src/metadata/document.rs index 0752f2f..395e18b 100644 --- a/crates/pinakes-metadata/src/document.rs +++ b/crates/pinakes-core/src/metadata/document.rs @@ -1,98 +1,11 @@ -use std::{path::Path, sync::LazyLock}; +use std::path::Path; -use pinakes_types::{ +use super::{ExtractedMetadata, MetadataExtractor}; +use crate::{ error::{PinakesError, Result}, media_type::{BuiltinMediaType, MediaType}, }; -use super::{ExtractedMetadata, MetadataExtractor}; - -// --- ISBN helpers (duplicated from pinakes-core::books to avoid circular dep) -// --- - -static ISBN_PATTERNS: LazyLock> = LazyLock::new(|| { - [ - 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])", - r"ISBN(?:-13)?\s+(\d{13})", - r"ISBN(?:-10)?\s+(\d{9}[\dXx])", - r"\b(\d{3}-\d{1,5}-\d{1,7}-\d{1,7}-\d)\b", - r"\b(\d{1,5}-\d{1,7}-\d{1,7}-[\dXx])\b", - ] - .iter() - .filter_map(|p| regex::Regex::new(p).ok()) - .collect() -}); - -fn extract_isbn_from_text(text: &str) -> Option { - for pattern in ISBN_PATTERNS.iter() { - if let Some(captures) = pattern.captures(text) - && let Some(isbn) = captures.get(1) - && let Ok(normalized) = normalize_isbn(isbn.as_str()) - { - return Some(normalized); - } - } - None -} - -fn normalize_isbn(isbn: &str) -> std::result::Result { - 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(()) - } - }, - _ => Err(()), - } -} - -fn isbn10_to_isbn13(isbn10: &str) -> std::result::Result { - if isbn10.len() != 10 { - return Err(()); - } - let mut isbn13 = format!("978{}", &isbn10[..9]); - let check_digit = calculate_isbn13_check_digit(&isbn13).ok_or(())?; - isbn13.push_str(&check_digit.to_string()); - Ok(isbn13) -} - -fn calculate_isbn13_check_digit(isbn_without_check: &str) -> Option { - if isbn_without_check.len() != 12 { - return None; - } - 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(); - Some((10 - (sum % 10)) % 10) -} - -fn is_valid_isbn13(isbn13: &str) -> bool { - 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(); - sum.is_multiple_of(10) -} - pub struct DocumentExtractor; impl MetadataExtractor for DocumentExtractor { @@ -119,7 +32,7 @@ fn extract_pdf(path: &Path) -> Result { .map_err(|e| PinakesError::MetadataExtraction(format!("PDF load: {e}")))?; let mut meta = ExtractedMetadata::default(); - let mut book_meta = pinakes_types::model::BookMetadata::default(); + let mut book_meta = crate::model::BookMetadata::default(); // Find the Info dictionary via the trailer if let Ok(info_ref) = doc.trailer.get(b"Info") { @@ -146,7 +59,7 @@ fn extract_pdf(path: &Path) -> Result { .filter(|name| !name.is_empty()) .enumerate() .map(|(pos, name)| { - let mut author = pinakes_types::model::AuthorInfo::new(name); + let mut author = crate::model::AuthorInfo::new(name); author.position = i32::try_from(pos).unwrap_or(i32::MAX); author }) @@ -194,8 +107,8 @@ fn extract_pdf(path: &Path) -> Result { } // Extract ISBN from the text - if let Some(isbn) = extract_isbn_from_text(&extracted_text) - && let Ok(normalized) = normalize_isbn(&isbn) + 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); @@ -232,7 +145,7 @@ fn extract_epub(path: &Path) -> Result { ..Default::default() }; - let mut book_meta = pinakes_types::model::BookMetadata::default(); + let mut book_meta = crate::model::BookMetadata::default(); // Extract basic metadata if let Some(lang) = doc.mdata("language") { @@ -257,8 +170,7 @@ fn extract_epub(path: &Path) -> Result { let mut position = 0; for item in &doc.metadata { if item.property == "creator" || item.property == "dc:creator" { - let mut author = - pinakes_types::model::AuthorInfo::new(item.value.clone()); + let mut author = crate::model::AuthorInfo::new(item.value.clone()); author.position = position; position += 1; @@ -309,7 +221,7 @@ fn extract_epub(path: &Path) -> Result { // Try to normalize ISBN if (id_type == "isbn" || id_type == "isbn13") - && let Ok(normalized) = normalize_isbn(&item.value) + && let Ok(normalized) = crate::books::normalize_isbn(&item.value) { book_meta.isbn13 = Some(normalized.clone()); book_meta.isbn = Some(item.value.clone()); diff --git a/crates/pinakes-metadata/src/image.rs b/crates/pinakes-core/src/metadata/image.rs similarity index 99% rename from crates/pinakes-metadata/src/image.rs rename to crates/pinakes-core/src/metadata/image.rs index 196ad09..6652a82 100644 --- a/crates/pinakes-metadata/src/image.rs +++ b/crates/pinakes-core/src/metadata/image.rs @@ -1,12 +1,11 @@ use std::path::Path; -use pinakes_types::{ +use super::{ExtractedMetadata, MetadataExtractor}; +use crate::{ error::Result, media_type::{BuiltinMediaType, MediaType}, }; -use super::{ExtractedMetadata, MetadataExtractor}; - pub struct ImageExtractor; impl MetadataExtractor for ImageExtractor { diff --git a/crates/pinakes-metadata/src/markdown.rs b/crates/pinakes-core/src/metadata/markdown.rs similarity index 98% rename from crates/pinakes-metadata/src/markdown.rs rename to crates/pinakes-core/src/metadata/markdown.rs index e9b4b1a..155a7e6 100644 --- a/crates/pinakes-metadata/src/markdown.rs +++ b/crates/pinakes-core/src/metadata/markdown.rs @@ -1,12 +1,11 @@ use std::path::Path; -use pinakes_types::{ +use super::{ExtractedMetadata, MetadataExtractor}; +use crate::{ error::Result, media_type::{BuiltinMediaType, MediaType}, }; -use super::{ExtractedMetadata, MetadataExtractor}; - pub struct MarkdownExtractor; impl MetadataExtractor for MarkdownExtractor { diff --git a/crates/pinakes-metadata/src/mod.rs b/crates/pinakes-core/src/metadata/mod.rs similarity index 95% rename from crates/pinakes-metadata/src/mod.rs rename to crates/pinakes-core/src/metadata/mod.rs index 403a06b..b4e91e5 100644 --- a/crates/pinakes-metadata/src/mod.rs +++ b/crates/pinakes-core/src/metadata/mod.rs @@ -8,7 +8,7 @@ use std::path::Path; use rustc_hash::FxHashMap; -use pinakes_types::{error::Result, media_type::MediaType, model::BookMetadata}; +use crate::{error::Result, media_type::MediaType, model::BookMetadata}; #[derive(Debug, Clone, Default)] pub struct ExtractedMetadata { diff --git a/crates/pinakes-metadata/src/video.rs b/crates/pinakes-core/src/metadata/video.rs similarity index 99% rename from crates/pinakes-metadata/src/video.rs rename to crates/pinakes-core/src/metadata/video.rs index 5720d42..a0c26f5 100644 --- a/crates/pinakes-metadata/src/video.rs +++ b/crates/pinakes-core/src/metadata/video.rs @@ -1,12 +1,11 @@ use std::path::Path; -use pinakes_types::{ +use super::{ExtractedMetadata, MetadataExtractor}; +use crate::{ error::{PinakesError, Result}, media_type::{BuiltinMediaType, MediaType}, }; -use super::{ExtractedMetadata, MetadataExtractor}; - pub struct VideoExtractor; impl MetadataExtractor for VideoExtractor { diff --git a/crates/pinakes-types/src/model.rs b/crates/pinakes-core/src/model.rs similarity index 96% rename from crates/pinakes-types/src/model.rs rename to crates/pinakes-core/src/model.rs index c978f51..f37e7a1 100644 --- a/crates/pinakes-types/src/model.rs +++ b/crates/pinakes-core/src/model.rs @@ -7,35 +7,6 @@ use uuid::Uuid; use crate::media_type::MediaType; -/// Unique identifier for a user account. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct UserId(pub Uuid); - -impl UserId { - #[must_use] - pub fn new() -> Self { - Self(Uuid::now_v7()) - } -} - -impl Default for UserId { - fn default() -> Self { - Self::new() - } -} - -impl fmt::Display for UserId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -impl From for UserId { - fn from(id: Uuid) -> Self { - Self(id) - } -} - /// Unique identifier for a media item. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct MediaId(pub Uuid); @@ -215,7 +186,7 @@ pub enum CustomFieldType { impl CustomFieldType { #[must_use] - pub const fn as_str(self) -> &'static str { + pub const fn as_str(&self) -> &'static str { match self { Self::Text => "text", Self::Number => "number", @@ -262,7 +233,7 @@ pub enum CollectionKind { impl CollectionKind { #[must_use] - pub const fn as_str(self) -> &'static str { + pub const fn as_str(&self) -> &'static str { match self { Self::Manual => "manual", Self::Virtual => "virtual", diff --git a/crates/pinakes-core/src/opener.rs b/crates/pinakes-core/src/opener.rs index db3b90e..5326154 100644 --- a/crates/pinakes-core/src/opener.rs +++ b/crates/pinakes-core/src/opener.rs @@ -3,11 +3,6 @@ use std::{path::Path, process::Command}; use crate::error::{PinakesError, Result}; pub trait Opener: Send + Sync { - /// Open the file at `path` with the system default application. - /// - /// # Errors - /// - /// Returns an error if the opener command fails to launch or exits non-zero. fn open(&self, path: &Path) -> Result<()>; } diff --git a/crates/pinakes-plugin/src/loader.rs b/crates/pinakes-core/src/plugin/loader.rs similarity index 94% rename from crates/pinakes-plugin/src/loader.rs rename to crates/pinakes-core/src/plugin/loader.rs index 5aeb484..f8242e8 100644 --- a/crates/pinakes-plugin/src/loader.rs +++ b/crates/pinakes-core/src/plugin/loader.rs @@ -21,7 +21,11 @@ impl PluginLoader { } /// Discover all plugins in configured directories - pub fn discover_plugins(&self) -> Vec { + /// + /// # Errors + /// + /// Returns an error if a plugin directory cannot be searched. + pub fn discover_plugins(&self) -> Result> { let mut manifests = Vec::new(); for dir in &self.plugin_dirs { @@ -37,7 +41,7 @@ impl PluginLoader { manifests.extend(found); } - manifests + Ok(manifests) } /// Discover plugins in a specific directory @@ -267,7 +271,7 @@ impl PluginLoader { /// /// Returns an error if the path does not exist, is missing `plugin.toml`, /// the WASM binary is not found, or the WASM file is invalid. - pub fn validate_plugin_package(path: &Path) -> Result<()> { + 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.display())); @@ -335,7 +339,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]); - let manifests = loader.discover_plugins(); + let manifests = loader.discover_plugins().unwrap(); assert_eq!(manifests.len(), 0); } @@ -363,7 +367,7 @@ wasm = "plugin.wasm" .unwrap(); let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]); - let manifests = loader.discover_plugins(); + let manifests = loader.discover_plugins().unwrap(); assert_eq!(manifests.len(), 1); assert_eq!(manifests[0].plugin.name, "test-plugin"); @@ -388,15 +392,17 @@ wasm = "plugin.wasm" "#; std::fs::write(plugin_dir.join("plugin.toml"), manifest_content).unwrap(); + let loader = PluginLoader::new(vec![]); + // Should fail without WASM file - assert!(PluginLoader::validate_plugin_package(&plugin_dir).is_err()); + 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(); // Should succeed now - assert!(PluginLoader::validate_plugin_package(&plugin_dir).is_ok()); + assert!(loader.validate_plugin_package(&plugin_dir).is_ok()); } #[test] @@ -420,6 +426,7 @@ wasm = "plugin.wasm" // Create invalid WASM file std::fs::write(plugin_dir.join("plugin.wasm"), b"not wasm").unwrap(); - assert!(PluginLoader::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 0a8286f..f8ae6cc 100644 --- a/crates/pinakes-core/src/plugin/mod.rs +++ b/crates/pinakes-core/src/plugin/mod.rs @@ -1,3 +1,932 @@ -//! Plugin pipeline for Pinakes. +//! 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. +//! +//! # Architecture +//! +//! - Plugins are compiled to WASM and run in a sandboxed environment +//! - Capability-based security controls what plugins can access +//! - 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 tokio::sync::RwLock; +use tracing::{debug, error, info, warn}; + +pub mod loader; pub mod pipeline; +pub mod registry; +pub mod rpc; +pub mod runtime; +pub mod security; +pub mod signature; + +pub use loader::PluginLoader; pub use pipeline::PluginPipeline; +pub use registry::{PluginRegistry, RegisteredPlugin}; +pub use runtime::{WasmPlugin, WasmRuntime}; +pub use security::CapabilityEnforcer; +pub use signature::{SignatureStatus, verify_plugin_signature}; + +/// Plugin manager coordinates plugin lifecycle and operations +pub struct PluginManager { + /// Plugin registry + registry: Arc>, + + /// WASM runtime for executing plugins + runtime: Arc, + + /// Plugin loader for discovery and loading + loader: PluginLoader, + + /// Capability enforcer for security + enforcer: CapabilityEnforcer, + + /// Plugin data directory + data_dir: PathBuf, + + /// Plugin cache directory + cache_dir: PathBuf, + + /// Configuration + config: PluginManagerConfig, +} + +/// Configuration for the plugin manager +#[derive(Debug, Clone)] +pub struct PluginManagerConfig { + /// Directories to search for plugins + pub plugin_dirs: Vec, + + /// Whether to enable hot-reload (for development) + pub enable_hot_reload: bool, + + /// Whether to allow unsigned plugins + pub allow_unsigned: bool, + + /// Maximum number of concurrent plugin operations + pub max_concurrent_ops: usize, + + /// Plugin timeout in seconds + pub plugin_timeout_secs: u64, + + /// Timeout configuration for different call types + pub timeouts: crate::config::PluginTimeoutConfig, + + /// Max consecutive failures before circuit breaker disables plugin + pub max_consecutive_failures: u32, + + /// Trusted Ed25519 public keys for signature verification (hex-encoded) + pub trusted_keys: Vec, +} + +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, + timeouts: crate::config::PluginTimeoutConfig::default(), + max_consecutive_failures: 5, + trusted_keys: vec![], + } + } +} + +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, + timeouts: cfg.timeouts, + max_consecutive_failures: cfg.max_consecutive_failures, + trusted_keys: cfg.trusted_keys, + } + } +} + +impl PluginManager { + /// Create a new plugin manager + /// + /// # Errors + /// + /// Returns an error if the data or cache directories cannot be created, or + /// if the WASM runtime cannot be initialized. + 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(); + + Ok(Self { + registry, + runtime, + loader, + enforcer, + data_dir, + cache_dir, + config, + }) + } + + /// Discover and load all plugins from configured directories. + /// + /// Plugins are loaded in dependency order: if plugin A declares a + /// dependency on plugin B, B is loaded first. Cycles and missing + /// dependencies are detected and reported as warnings; affected plugins + /// are skipped rather than causing a hard failure. + /// + /// # Errors + /// + /// Returns an error if plugin discovery fails. + pub async fn discover_and_load_all(&self) -> Result> { + info!("Discovering plugins from {:?}", self.config.plugin_dirs); + + let manifests = self.loader.discover_plugins()?; + let ordered = Self::resolve_load_order(&manifests); + let mut loaded_plugins = Vec::new(); + + for manifest in ordered { + 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) + } + + /// Topological sort of manifests by their declared `dependencies`. + /// + /// Uses Kahn's algorithm. Plugins whose dependencies are missing or form + /// a cycle are logged as warnings and excluded from the result. + fn resolve_load_order( + manifests: &[pinakes_plugin_api::PluginManifest], + ) -> Vec { + use std::collections::VecDeque; + + use rustc_hash::{FxHashMap, FxHashSet}; + + // Index manifests by name for O(1) lookup + let by_name: FxHashMap<&str, usize> = manifests + .iter() + .enumerate() + .map(|(i, m)| (m.plugin.name.as_str(), i)) + .collect(); + + // Check for missing dependencies and warn early + let known: FxHashSet<&str> = by_name.keys().copied().collect(); + for manifest in manifests { + for dep in &manifest.plugin.dependencies { + if !known.contains(dep.as_str()) { + warn!( + "Plugin '{}' depends on '{}' which was not discovered; it will be \ + skipped", + manifest.plugin.name, dep + ); + } + } + } + + // Build adjacency: in_degree[i] = number of deps that must load before i + let mut in_degree = vec![0usize; manifests.len()]; + // dependents[i] = indices that depend on i (i must load before them) + let mut dependents: Vec> = vec![vec![]; manifests.len()]; + + for (i, manifest) in manifests.iter().enumerate() { + for dep in &manifest.plugin.dependencies { + if let Some(&dep_idx) = by_name.get(dep.as_str()) { + in_degree[i] += 1; + dependents[dep_idx].push(i); + } else { + // Missing dep: set in_degree impossibly high so it never resolves + in_degree[i] = usize::MAX; + } + } + } + + // Kahn's algorithm + let mut queue: VecDeque = VecDeque::new(); + for (i, °) in in_degree.iter().enumerate() { + if deg == 0 { + queue.push_back(i); + } + } + + let mut result = Vec::with_capacity(manifests.len()); + while let Some(idx) = queue.pop_front() { + result.push(manifests[idx].clone()); + for &dependent in &dependents[idx] { + if in_degree[dependent] == usize::MAX { + continue; // already poisoned by missing dep + } + in_degree[dependent] -= 1; + if in_degree[dependent] == 0 { + queue.push_back(dependent); + } + } + } + + // Anything not in `result` is part of a cycle or has a missing dep + if result.len() < manifests.len() { + let loaded: FxHashSet<&str> = + result.iter().map(|m| m.plugin.name.as_str()).collect(); + for manifest in manifests { + if !loaded.contains(manifest.plugin.name.as_str()) { + warn!( + "Plugin '{}' was skipped due to unresolved dependencies or a \ + dependency cycle", + manifest.plugin.name + ); + } + } + } + + result + } + + /// Load a plugin from a manifest file + /// + /// # Errors + /// + /// Returns an error if the plugin ID is invalid, capability validation + /// fails, the WASM binary cannot be loaded, or the plugin cannot be + /// registered. + 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)?; + + // Verify plugin signature unless unsigned plugins are allowed + if !self.config.allow_unsigned { + let plugin_dir = wasm_path + .parent() + .ok_or_else(|| anyhow::anyhow!("WASM path has no parent directory"))?; + + let trusted_keys: Vec = self + .config + .trusted_keys + .iter() + .filter_map(|hex| { + signature::parse_public_key(hex) + .map_err(|e| warn!("Ignoring malformed trusted key: {e}")) + .ok() + }) + .collect(); + + match signature::verify_plugin_signature( + plugin_dir, + &wasm_path, + &trusted_keys, + )? { + SignatureStatus::Valid => { + debug!("Plugin '{plugin_id}' signature verified"); + }, + SignatureStatus::Unsigned => { + return Err(anyhow::anyhow!( + "Plugin '{plugin_id}' is unsigned and allow_unsigned is false" + )); + }, + SignatureStatus::Invalid(reason) => { + return Err(anyhow::anyhow!( + "Plugin '{plugin_id}' has an invalid signature: {reason}" + )); + }, + } + } + + let wasm_plugin = self.runtime.load_plugin(&wasm_path, context)?; + + // 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 + /// + /// # Errors + /// + /// Returns an error if the plugin cannot be downloaded, the manifest cannot + /// be read, or the plugin cannot be loaded. + 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 + /// + /// # Errors + /// + /// Returns an error if the plugin ID is invalid, the plugin cannot be shut + /// down, cannot be unregistered, or its data directories cannot be removed. + 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!("Uninstalling plugin: {}", plugin_id); + + // 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(()) + } + + /// Enable a plugin + /// + /// # Errors + /// + /// Returns an error if the plugin ID is not found in the registry. + pub async fn enable_plugin(&self, plugin_id: &str) -> Result<()> { + let mut registry = self.registry.write().await; + registry.enable(plugin_id) + } + + /// Disable a plugin + /// + /// # Errors + /// + /// Returns an error if the plugin ID is not found in the registry. + 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 + /// + /// # Errors + /// + /// Returns an error if the plugin ID is not found in the registry. + 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) { + let _ = plugin.wasm_plugin.call_function("shutdown", &[]).await; + Ok(()) + } else { + Err(anyhow::anyhow!("Plugin not found: {plugin_id}")) + } + } + + /// Shutdown all plugins + /// + /// # Errors + /// + /// This function always returns `Ok(())`. Individual plugin shutdown errors + /// are logged but do not cause the overall operation to fail. + pub async fn shutdown_all(&self) -> Result<()> { + info!("Shutting down all plugins"); + + let plugin_ids: Vec = { + let registry = self.registry.read().await; + 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()) + } + + /// Get enabled plugins of a specific kind, sorted by priority (ascending). + /// + /// # Returns + /// + /// `(plugin_id, priority, kinds, wasm_plugin)` tuples. + pub async fn get_enabled_by_kind_sorted( + &self, + kind: &str, + ) -> Vec<(String, u16, Vec, WasmPlugin)> { + let registry = self.registry.read().await; + let mut plugins: Vec<_> = registry + .get_by_kind(kind) + .into_iter() + .filter(|p| p.enabled) + .map(|p| { + ( + p.id.clone(), + p.manifest.plugin.priority, + p.manifest.plugin.kind.clone(), + p.wasm_plugin.clone(), + ) + }) + .collect(); + drop(registry); + plugins.sort_by_key(|(_, priority, ..)| *priority); + plugins + } + + /// Get a reference to the capability enforcer. + #[must_use] + pub const fn enforcer(&self) -> &CapabilityEnforcer { + &self.enforcer + } + + /// List all UI pages provided by loaded plugins. + /// + /// Returns a vector of `(plugin_id, page)` tuples for all enabled plugins + /// that provide pages in their manifests. Both inline and file-referenced + /// page entries are resolved. + pub async fn list_ui_pages( + &self, + ) -> Vec<(String, pinakes_plugin_api::UiPage)> { + self + .list_ui_pages_with_endpoints() + .await + .into_iter() + .map(|(id, page, _)| (id, page)) + .collect() + } + + /// List all UI pages provided by loaded plugins, including each plugin's + /// declared endpoint allowlist. + /// + /// Returns a vector of `(plugin_id, page, allowed_endpoints)` tuples. The + /// `allowed_endpoints` list mirrors the `required_endpoints` field from the + /// plugin manifest's `[ui]` section. + pub async fn list_ui_pages_with_endpoints( + &self, + ) -> Vec<(String, pinakes_plugin_api::UiPage, Vec)> { + let registry = self.registry.read().await; + let mut pages = Vec::new(); + for plugin in registry.list_all() { + if !plugin.enabled { + continue; + } + let allowed = plugin.manifest.ui.required_endpoints.clone(); + let plugin_dir = plugin + .manifest_path + .as_ref() + .and_then(|p| p.parent()) + .map(std::path::Path::to_path_buf); + let Some(plugin_dir) = plugin_dir else { + for entry in &plugin.manifest.ui.pages { + if let pinakes_plugin_api::manifest::UiPageEntry::Inline(page) = entry + { + pages.push((plugin.id.clone(), (**page).clone(), allowed.clone())); + } + } + continue; + }; + match plugin.manifest.load_ui_pages(&plugin_dir) { + Ok(loaded) => { + for page in loaded { + pages.push((plugin.id.clone(), page, allowed.clone())); + } + }, + Err(e) => { + tracing::warn!( + "Failed to load UI pages for plugin '{}': {e}", + plugin.id + ); + }, + } + } + pages + } + + /// Collect CSS custom property overrides declared by all enabled plugins. + /// + /// When multiple plugins declare the same property name, later-loaded plugins + /// overwrite earlier ones. Returns an empty map if no plugins are loaded or + /// none declare theme extensions. + pub async fn list_ui_theme_extensions( + &self, + ) -> rustc_hash::FxHashMap { + let registry = self.registry.read().await; + let mut merged = rustc_hash::FxHashMap::default(); + for plugin in registry.list_all() { + if !plugin.enabled { + continue; + } + for (k, v) in &plugin.manifest.ui.theme_extensions { + merged.insert(k.clone(), v.clone()); + } + } + merged + } + + /// List all UI widgets provided by loaded plugins. + /// + /// Returns a vector of `(plugin_id, widget)` tuples for all enabled plugins + /// that provide widgets in their manifests. + pub async fn list_ui_widgets( + &self, + ) -> Vec<(String, pinakes_plugin_api::UiWidget)> { + let registry = self.registry.read().await; + let mut widgets = Vec::new(); + for plugin in registry.list_all() { + if !plugin.enabled { + continue; + } + for widget in &plugin.manifest.ui.widgets { + widgets.push((plugin.id.clone(), widget.clone())); + } + } + widgets + } + + /// 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) + /// + /// # Errors + /// + /// Returns an error if hot-reload is disabled, the plugin is not found, it + /// cannot be shut down, or the reloaded plugin cannot be registered. + 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"))?; + let manifest = plugin.manifest_path.as_ref().map_or_else( + || plugin.manifest.clone(), + |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() + }) + }, + ); + drop(registry); + manifest + }; + + // 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(()) + } +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + + #[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"); + + let config = PluginManagerConfig::default(); + let manager = + PluginManager::new(data_dir.clone(), cache_dir.clone(), config); + + assert!(manager.is_ok()); + assert!(data_dir.exists()); + assert!(cache_dir.exists()); + } + + #[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 config = PluginManagerConfig::default(); + let manager = PluginManager::new(data_dir, cache_dir, config).unwrap(); + + let plugins = manager.list_plugins().await; + assert_eq!(plugins.len(), 0); + } + + /// Build a minimal manifest for dependency resolution tests + fn test_manifest( + name: &str, + deps: Vec, + ) -> pinakes_plugin_api::PluginManifest { + use pinakes_plugin_api::manifest::{PluginBinary, PluginInfo}; + + pinakes_plugin_api::PluginManifest { + plugin: PluginInfo { + name: name.to_string(), + version: "1.0.0".to_string(), + api_version: "1.0".to_string(), + author: None, + description: None, + homepage: None, + license: None, + priority: 500, + kind: vec!["media_type".to_string()], + binary: PluginBinary { + wasm: "plugin.wasm".to_string(), + entrypoint: None, + }, + dependencies: deps, + }, + capabilities: Default::default(), + config: Default::default(), + ui: Default::default(), + } + } + + #[test] + fn test_resolve_load_order_no_deps() { + let manifests = vec![ + test_manifest("alpha", vec![]), + test_manifest("beta", vec![]), + test_manifest("gamma", vec![]), + ]; + + let ordered = PluginManager::resolve_load_order(&manifests); + assert_eq!(ordered.len(), 3); + } + + #[test] + fn test_resolve_load_order_linear_chain() { + // gamma depends on beta, beta depends on alpha + let manifests = vec![ + test_manifest("gamma", vec!["beta".to_string()]), + test_manifest("alpha", vec![]), + test_manifest("beta", vec!["alpha".to_string()]), + ]; + + let ordered = PluginManager::resolve_load_order(&manifests); + assert_eq!(ordered.len(), 3); + + let names: Vec<&str> = + ordered.iter().map(|m| m.plugin.name.as_str()).collect(); + let alpha_pos = names.iter().position(|&n| n == "alpha").unwrap(); + let beta_pos = names.iter().position(|&n| n == "beta").unwrap(); + let gamma_pos = names.iter().position(|&n| n == "gamma").unwrap(); + assert!(alpha_pos < beta_pos, "alpha must load before beta"); + assert!(beta_pos < gamma_pos, "beta must load before gamma"); + } + + #[test] + fn test_resolve_load_order_cycle_detected() { + // A -> B -> C -> A (cycle) + let manifests = vec![ + test_manifest("a", vec!["c".to_string()]), + test_manifest("b", vec!["a".to_string()]), + test_manifest("c", vec!["b".to_string()]), + ]; + + let ordered = PluginManager::resolve_load_order(&manifests); + // All three should be excluded due to cycle + assert_eq!(ordered.len(), 0); + } + + #[test] + fn test_resolve_load_order_missing_dependency() { + let manifests = vec![ + test_manifest("good", vec![]), + test_manifest("bad", vec!["nonexistent".to_string()]), + ]; + + let ordered = PluginManager::resolve_load_order(&manifests); + // Only "good" should be loaded; "bad" depends on something missing + assert_eq!(ordered.len(), 1); + assert_eq!(ordered[0].plugin.name, "good"); + } + + #[test] + fn test_resolve_load_order_partial_cycle() { + // "ok" has no deps, "cycle_a" and "cycle_b" form a cycle + let manifests = vec![ + test_manifest("ok", vec![]), + test_manifest("cycle_a", vec!["cycle_b".to_string()]), + test_manifest("cycle_b", vec!["cycle_a".to_string()]), + ]; + + let ordered = PluginManager::resolve_load_order(&manifests); + assert_eq!(ordered.len(), 1); + assert_eq!(ordered[0].plugin.name, "ok"); + } + + #[test] + fn test_resolve_load_order_diamond() { + // Man look at how beautiful my diamond is... + // A + // / \ + // B C + // \ / + // D + let manifests = vec![ + test_manifest("d", vec!["b".to_string(), "c".to_string()]), + test_manifest("b", vec!["a".to_string()]), + test_manifest("c", vec!["a".to_string()]), + test_manifest("a", vec![]), + ]; + + let ordered = PluginManager::resolve_load_order(&manifests); + assert_eq!(ordered.len(), 4); + + let names: Vec<&str> = + ordered.iter().map(|m| m.plugin.name.as_str()).collect(); + let a_pos = names.iter().position(|&n| n == "a").unwrap(); + let b_pos = names.iter().position(|&n| n == "b").unwrap(); + let c_pos = names.iter().position(|&n| n == "c").unwrap(); + let d_pos = names.iter().position(|&n| n == "d").unwrap(); + assert!(a_pos < b_pos); + assert!(a_pos < c_pos); + assert!(b_pos < d_pos); + assert!(c_pos < d_pos); + } +} diff --git a/crates/pinakes-core/src/plugin/pipeline.rs b/crates/pinakes-core/src/plugin/pipeline.rs index fcd7a3d..f4301a5 100644 --- a/crates/pinakes-core/src/plugin/pipeline.rs +++ b/crates/pinakes-core/src/plugin/pipeline.rs @@ -18,11 +18,17 @@ use std::{ time::{Duration, Instant}, }; -use pinakes_metadata::ExtractedMetadata; -use pinakes_plugin::{ - CapabilityEnforcer, - PluginManager, - rpc::{ +use rustc_hash::FxHashMap; +use tokio::sync::RwLock; +use tracing::{debug, info, warn}; + +use super::PluginManager; +use crate::{ + config::PluginTimeoutConfig, + media_type::MediaType, + metadata::ExtractedMetadata, + model::MediaId, + plugin::rpc::{ CanHandleRequest, CanHandleResponse, ExtractMetadataRequest, @@ -40,12 +46,6 @@ use pinakes_plugin::{ SearchResultItem, }, }; -use pinakes_types::config::PluginTimeoutConfig; -use rustc_hash::FxHashMap; -use tokio::sync::RwLock; -use tracing::{debug, info, warn}; - -use crate::{media_type::MediaType, model::MediaId}; /// Built-in handlers run at this implicit priority. const BUILTIN_PRIORITY: u16 = 100; @@ -132,7 +132,7 @@ impl PluginPipeline { pub async fn discover_capabilities(&self) -> crate::error::Result<()> { info!("discovering plugin capabilities"); - let timeout = Duration::from_secs(self.timeouts.capability_query); + let timeout = Duration::from_secs(self.timeouts.capability_query_secs); let mut caps = CachedCapabilities::new(); // Discover metadata extractors @@ -323,7 +323,7 @@ impl PluginPipeline { /// Iterates `MediaTypeProvider` plugins in priority order, falling back to /// the built-in resolver at implicit priority 100. pub async fn resolve_media_type(&self, path: &Path) -> Option { - let timeout = Duration::from_secs(self.timeouts.processing); + let timeout = Duration::from_secs(self.timeouts.processing_secs); let plugins = self.manager.get_enabled_by_kind_sorted("media_type").await; let mut builtin_ran = false; @@ -342,7 +342,11 @@ impl PluginPipeline { } // Validate the call is allowed for this plugin kind - if !CapabilityEnforcer::validate_function_call(kinds, "can_handle") { + if !self + .manager + .enforcer() + .validate_function_call(kinds, "can_handle") + { continue; } @@ -438,7 +442,7 @@ impl PluginPipeline { path: &Path, media_type: &MediaType, ) -> crate::error::Result { - let timeout = Duration::from_secs(self.timeouts.processing); + let timeout = Duration::from_secs(self.timeouts.processing_secs); let plugins = self .manager .get_enabled_by_kind_sorted("metadata_extractor") @@ -472,7 +476,10 @@ impl PluginPipeline { continue; } - if !CapabilityEnforcer::validate_function_call(kinds, "extract_metadata") + if !self + .manager + .enforcer() + .validate_function_call(kinds, "extract_metadata") { continue; } @@ -522,7 +529,7 @@ impl PluginPipeline { let path = path.to_path_buf(); let mt = media_type.clone(); let builtin = tokio::task::spawn_blocking(move || { - pinakes_metadata::extract_metadata(&path, &mt) + crate::metadata::extract_metadata(&path, &mt) }) .await .map_err(|e| { @@ -552,7 +559,7 @@ impl PluginPipeline { media_type: &MediaType, thumb_dir: &Path, ) -> crate::error::Result> { - let timeout = Duration::from_secs(self.timeouts.processing); + let timeout = Duration::from_secs(self.timeouts.processing_secs); let plugins = self .manager .get_enabled_by_kind_sorted("thumbnail_generator") @@ -590,10 +597,11 @@ impl PluginPipeline { continue; } - if !CapabilityEnforcer::validate_function_call( - kinds, - "generate_thumbnail", - ) { + if !self + .manager + .enforcer() + .validate_function_call(kinds, "generate_thumbnail") + { continue; } @@ -682,11 +690,11 @@ impl PluginPipeline { /// Internal dispatcher for events. async fn dispatch_event( - self: &Arc, + &self, event_type: &str, payload: &serde_json::Value, ) { - let timeout = Duration::from_secs(self.timeouts.event_handler); + let timeout = Duration::from_secs(self.timeouts.event_handler_secs); // Collect plugin IDs interested in this event let interested_ids: Vec = { @@ -718,7 +726,11 @@ impl PluginPipeline { continue; } - if !CapabilityEnforcer::validate_function_call(kinds, "handle_event") { + if !self + .manager + .enforcer() + .validate_function_call(kinds, "handle_event") + { continue; } @@ -727,35 +739,23 @@ impl PluginPipeline { payload: payload.clone(), }; + // Event handlers return nothing meaningful; we just care about + // success/failure. match wasm - .call_function_json_with_events::( + .call_function_json::( "handle_event", &req, timeout, ) .await { - Ok((_resp, emitted_events)) => { + Ok(_) => { self.record_success(id).await; debug!( plugin_id = %id, event_type = event_type, "event handled" ); - // Re-dispatch any events the handler itself emitted. - for (emitted_type, payload_str) in emitted_events { - if let Ok(emitted_payload) = - serde_json::from_str::(&payload_str) - { - self.emit_event(&emitted_type, &emitted_payload); - } else { - warn!( - plugin_id = %id, - event_type = %emitted_type, - "plugin emitted event with unparseable JSON payload; skipping" - ); - } - } }, Err(e) => { warn!( @@ -778,7 +778,7 @@ impl PluginPipeline { limit: usize, offset: usize, ) -> Vec { - let timeout = Duration::from_secs(self.timeouts.processing); + let timeout = Duration::from_secs(self.timeouts.processing_secs); let plugins = self .manager .get_enabled_by_kind_sorted("search_backend") @@ -790,7 +790,11 @@ impl PluginPipeline { if !self.is_healthy(id).await { continue; } - if !CapabilityEnforcer::validate_function_call(kinds, "search") { + if !self + .manager + .enforcer() + .validate_function_call(kinds, "search") + { continue; } @@ -847,7 +851,7 @@ impl PluginPipeline { /// Index a media item in all search backend plugins (fan-out). pub async fn index_item(&self, req: &IndexItemRequest) { - let timeout = Duration::from_secs(self.timeouts.processing); + let timeout = Duration::from_secs(self.timeouts.processing_secs); let plugins = self .manager .get_enabled_by_kind_sorted("search_backend") @@ -857,7 +861,11 @@ impl PluginPipeline { if !self.is_healthy(id).await { continue; } - if !CapabilityEnforcer::validate_function_call(kinds, "index_item") { + if !self + .manager + .enforcer() + .validate_function_call(kinds, "index_item") + { continue; } @@ -886,7 +894,7 @@ impl PluginPipeline { /// Remove a media item from all search backend plugins (fan-out). pub async fn remove_item(&self, media_id: &str) { - let timeout = Duration::from_secs(self.timeouts.processing); + let timeout = Duration::from_secs(self.timeouts.processing_secs); let plugins = self .manager .get_enabled_by_kind_sorted("search_backend") @@ -900,7 +908,11 @@ impl PluginPipeline { if !self.is_healthy(id).await { continue; } - if !CapabilityEnforcer::validate_function_call(kinds, "remove_item") { + if !self + .manager + .enforcer() + .validate_function_call(kinds, "remove_item") + { continue; } @@ -940,7 +952,7 @@ impl PluginPipeline { /// Load a specific theme by ID from the provider that registered it. pub async fn load_theme(&self, theme_id: &str) -> Option { - let timeout = Duration::from_secs(self.timeouts.processing); + let timeout = Duration::from_secs(self.timeouts.processing_secs); // Find which plugin owns this theme let owner_id = { @@ -967,7 +979,11 @@ impl PluginPipeline { let plugin = plugins.iter().find(|(id, ..)| id == &owner_id)?; let (id, _priority, kinds, wasm) = plugin; - if !CapabilityEnforcer::validate_function_call(kinds, "load_theme") { + if !self + .manager + .enforcer() + .validate_function_call(kinds, "load_theme") + { return None; } @@ -1146,10 +1162,10 @@ fn merge_extracted(base: &mut ExtractedMetadata, source: ExtractedMetadata) { #[cfg(test)] mod tests { - use pinakes_plugin::{PluginManager, PluginManagerConfig}; use tempfile::TempDir; use super::*; + use crate::plugin::{PluginManager, PluginManagerConfig}; /// Create a `PluginPipeline` backed by an empty `PluginManager`. fn create_test_pipeline() -> (TempDir, Arc) { diff --git a/crates/pinakes-plugin/src/registry.rs b/crates/pinakes-core/src/plugin/registry.rs similarity index 100% rename from crates/pinakes-plugin/src/registry.rs rename to crates/pinakes-core/src/plugin/registry.rs diff --git a/crates/pinakes-plugin/src/rpc.rs b/crates/pinakes-core/src/plugin/rpc.rs similarity index 100% rename from crates/pinakes-plugin/src/rpc.rs rename to crates/pinakes-core/src/plugin/rpc.rs diff --git a/crates/pinakes-plugin/src/runtime.rs b/crates/pinakes-core/src/plugin/runtime.rs similarity index 89% rename from crates/pinakes-plugin/src/runtime.rs rename to crates/pinakes-core/src/plugin/runtime.rs index 72afb43..b550b05 100644 --- a/crates/pinakes-plugin/src/runtime.rs +++ b/crates/pinakes-core/src/plugin/runtime.rs @@ -86,23 +86,20 @@ impl WasmPlugin { &self.context } - /// Execute a plugin function, returning both the result bytes and any - /// events the plugin queued via `host_emit_event`. + /// Execute a plugin function /// /// Creates a fresh store and instance per invocation with host functions - /// linked, calls the requested exported function, drains both the exchange - /// buffer and the pending events list before the store is dropped, and - /// returns both. + /// linked, calls the requested exported function, and returns the result. /// /// # Errors /// /// Returns an error if the function cannot be found, instantiation fails, /// or the function call returns an error. - pub async fn call_function_with_events( + pub async fn call_function( &self, function_name: &str, params: &[u8], - ) -> Result<(Vec, Vec<(String, String)>)> { + ) -> Result> { let engine = self.module.engine(); // Build memory limiter from capabilities @@ -208,38 +205,18 @@ impl WasmPlugin { .await?; } - // Drain both buffers before the store is dropped. - let pending_events = std::mem::take(&mut store.data_mut().pending_events); + // Prefer data written into the exchange buffer by host functions let exchange = std::mem::take(&mut store.data_mut().exchange_buffer); + if !exchange.is_empty() { + return Ok(exchange); + } - let result = if !exchange.is_empty() { - exchange - } else if let Some(Val::I32(ret)) = results.first() { - ret.to_le_bytes().to_vec() + // Fall back to serialising the WASM return value + if let Some(Val::I32(ret)) = results.first() { + Ok(ret.to_le_bytes().to_vec()) } else { - Vec::new() - }; - - Ok((result, pending_events)) - } - - /// Execute a plugin function, discarding any events the plugin queued. - /// - /// This is a thin wrapper around [`Self::call_function_with_events`]. - /// - /// # Errors - /// - /// Returns an error if the function cannot be found, instantiation fails, - /// or the function call returns an error. - pub async fn call_function( - &self, - function_name: &str, - params: &[u8], - ) -> Result> { - let (data, _events) = self - .call_function_with_events(function_name, params) - .await?; - Ok(data) + Ok(Vec::new()) + } } /// Call a plugin function with JSON request/response serialization. @@ -282,51 +259,6 @@ impl WasmPlugin { ) }) } - - /// Call a plugin function with JSON serialization, also returning any - /// events the plugin queued via `host_emit_event`. - /// - /// Mirrors [`Self::call_function_json`] but delegates to - /// [`Self::call_function_with_events`] so the pending events list is not - /// discarded before returning. - /// - /// # Errors - /// - /// Returns an error if serialization fails, the call times out, the plugin - /// traps, or the response is malformed JSON. - #[allow(clippy::future_not_send)] // Req doesn't need Sync; called within local tasks - pub async fn call_function_json_with_events( - &self, - function_name: &str, - request: &Req, - timeout: std::time::Duration, - ) -> anyhow::Result<(Resp, Vec<(String, String)>)> - where - Req: serde::Serialize, - Resp: serde::de::DeserializeOwned, - { - let request_bytes = serde_json::to_vec(request) - .map_err(|e| anyhow::anyhow!("failed to serialize request: {e}"))?; - - let (result, pending_events) = tokio::time::timeout( - timeout, - self.call_function_with_events(function_name, &request_bytes), - ) - .await - .map_err(|_| { - anyhow::anyhow!( - "plugin call '{function_name}' timed out after {timeout:?}" - ) - })??; - - let resp = serde_json::from_slice(&result).map_err(|e| { - anyhow::anyhow!( - "failed to deserialize response from '{function_name}': {e}" - ) - })?; - - Ok((resp, pending_events)) - } } #[cfg(test)] @@ -561,7 +493,9 @@ impl HostFunctions { if let Some(ref allowed) = caller.data().context.capabilities.network.allowed_domains { - let Ok(parsed) = url::Url::parse(&url_str) else { + let parsed = if let Ok(u) = url::Url::parse(&url_str) { + u + } else { tracing::warn!(url = %url_str, "plugin provided invalid URL"); return -1; }; @@ -715,12 +649,15 @@ impl HostFunctions { return -2; } - std::env::var(&key_str).map_or(-1, |value| { - let bytes = value.into_bytes(); - let len = i32::try_from(bytes.len()).unwrap_or(i32::MAX); - caller.data_mut().exchange_buffer = bytes; - len - }) + match std::env::var(&key_str) { + Ok(value) => { + let bytes = value.into_bytes(); + let len = i32::try_from(bytes.len()).unwrap_or(i32::MAX); + caller.data_mut().exchange_buffer = bytes; + len + }, + Err(_) => -1, + } }, )?; diff --git a/crates/pinakes-plugin/src/security.rs b/crates/pinakes-core/src/plugin/security.rs similarity index 88% rename from crates/pinakes-plugin/src/security.rs rename to crates/pinakes-core/src/plugin/security.rs index b9810a3..6bebb94 100644 --- a/crates/pinakes-plugin/src/security.rs +++ b/crates/pinakes-core/src/plugin/security.rs @@ -242,6 +242,7 @@ impl CapabilityEnforcer { /// bugs from calling wrong functions on plugins. Returns `true` if allowed. #[must_use] pub fn validate_function_call( + &self, plugin_kinds: &[String], function_name: &str, ) -> bool { @@ -422,91 +423,51 @@ mod tests { #[test] fn test_validate_function_call_lifecycle_always_allowed() { + let enforcer = CapabilityEnforcer::new(); let kinds = vec!["metadata_extractor".to_string()]; - assert!(CapabilityEnforcer::validate_function_call( - &kinds, - "initialize" - )); - assert!(CapabilityEnforcer::validate_function_call( - &kinds, "shutdown" - )); - assert!(CapabilityEnforcer::validate_function_call( - &kinds, - "health_check" - )); + assert!(enforcer.validate_function_call(&kinds, "initialize")); + assert!(enforcer.validate_function_call(&kinds, "shutdown")); + assert!(enforcer.validate_function_call(&kinds, "health_check")); } #[test] fn test_validate_function_call_metadata_extractor() { + let enforcer = CapabilityEnforcer::new(); let kinds = vec!["metadata_extractor".to_string()]; - assert!(CapabilityEnforcer::validate_function_call( - &kinds, - "extract_metadata" - )); - assert!(CapabilityEnforcer::validate_function_call( - &kinds, - "supported_types" - )); - assert!(!CapabilityEnforcer::validate_function_call( - &kinds, "search" - )); - assert!(!CapabilityEnforcer::validate_function_call( - &kinds, - "generate_thumbnail" - )); - assert!(!CapabilityEnforcer::validate_function_call( - &kinds, - "can_handle" - )); + assert!(enforcer.validate_function_call(&kinds, "extract_metadata")); + assert!(enforcer.validate_function_call(&kinds, "supported_types")); + assert!(!enforcer.validate_function_call(&kinds, "search")); + assert!(!enforcer.validate_function_call(&kinds, "generate_thumbnail")); + assert!(!enforcer.validate_function_call(&kinds, "can_handle")); } #[test] fn test_validate_function_call_multi_kind() { + let enforcer = CapabilityEnforcer::new(); let kinds = vec!["media_type".to_string(), "metadata_extractor".to_string()]; - assert!(CapabilityEnforcer::validate_function_call( - &kinds, - "can_handle" - )); - assert!(CapabilityEnforcer::validate_function_call( - &kinds, - "supported_media_types" - )); - assert!(CapabilityEnforcer::validate_function_call( - &kinds, - "extract_metadata" - )); - assert!(!CapabilityEnforcer::validate_function_call( - &kinds, "search" - )); + assert!(enforcer.validate_function_call(&kinds, "can_handle")); + assert!(enforcer.validate_function_call(&kinds, "supported_media_types")); + assert!(enforcer.validate_function_call(&kinds, "extract_metadata")); + assert!(!enforcer.validate_function_call(&kinds, "search")); } #[test] fn test_validate_function_call_unknown_function() { + let enforcer = CapabilityEnforcer::new(); let kinds = vec!["metadata_extractor".to_string()]; - assert!(!CapabilityEnforcer::validate_function_call( - &kinds, - "unknown_func" - )); - assert!(!CapabilityEnforcer::validate_function_call(&kinds, "")); + assert!(!enforcer.validate_function_call(&kinds, "unknown_func")); + assert!(!enforcer.validate_function_call(&kinds, "")); } #[test] fn test_validate_function_call_shared_supported_types() { + let enforcer = CapabilityEnforcer::new(); let extractor = vec!["metadata_extractor".to_string()]; let generator = vec!["thumbnail_generator".to_string()]; let search = vec!["search_backend".to_string()]; - assert!(CapabilityEnforcer::validate_function_call( - &extractor, - "supported_types" - )); - assert!(CapabilityEnforcer::validate_function_call( - &generator, - "supported_types" - )); - assert!(!CapabilityEnforcer::validate_function_call( - &search, - "supported_types" - )); + assert!(enforcer.validate_function_call(&extractor, "supported_types")); + assert!(enforcer.validate_function_call(&generator, "supported_types")); + assert!(!enforcer.validate_function_call(&search, "supported_types")); } } diff --git a/crates/pinakes-plugin/src/signature.rs b/crates/pinakes-core/src/plugin/signature.rs similarity index 100% rename from crates/pinakes-plugin/src/signature.rs rename to crates/pinakes-core/src/plugin/signature.rs diff --git a/crates/pinakes-core/src/scheduler.rs b/crates/pinakes-core/src/scheduler.rs index 0dc4b18..fa66f37 100644 --- a/crates/pinakes-core/src/scheduler.rs +++ b/crates/pinakes-core/src/scheduler.rs @@ -1,7 +1,6 @@ use std::{path::PathBuf, sync::Arc}; -use chrono::{DateTime, Utc}; -pub use pinakes_types::config::Schedule; +use chrono::{DateTime, Datelike, Utc}; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; use tokio_util::sync::CancellationToken; @@ -12,6 +11,102 @@ use crate::{ 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, + }, +} + +impl Schedule { + #[must_use] + pub fn next_run(&self, from: DateTime) -> DateTime { + match self { + Self::Interval { secs } => { + from + + chrono::Duration::seconds(i64::try_from(*secs).unwrap_or(i64::MAX)) + }, + Self::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) + } + }, + Self::Weekly { day, hour, minute } => { + let current_day = from.weekday().num_days_from_monday(); + let target_day = *day; + let days_ahead = match target_day.cmp(¤t_day) { + std::cmp::Ordering::Greater => target_day - current_day, + std::cmp::Ordering::Less => 7 - (current_day - target_day), + std::cmp::Ordering::Equal => { + 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(i64::from(days_ahead)); + target_date + .and_hms_opt(*hour, *minute, 0) + .unwrap_or_default() + .and_utc() + }, + } + } + + #[must_use] + pub fn display_string(&self) -> String { + match self { + Self::Interval { secs } => { + if *secs >= 3600 { + format!("Every {}h", secs / 3600) + } else if *secs >= 60 { + format!("Every {}m", secs / 60) + } else { + format!("Every {secs}s") + } + }, + Self::Daily { hour, minute } => { + format!("Daily {hour:02}:{minute:02}") + }, + Self::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, @@ -156,7 +251,7 @@ impl TaskScheduler { } if task.enabled { let from = task.last_run.unwrap_or_else(Utc::now); - task.next_run = task.schedule.next_run(from); + task.next_run = Some(task.schedule.next_run(from)); } else { task.next_run = None; } @@ -203,7 +298,7 @@ impl TaskScheduler { if let Some(task) = tasks.iter_mut().find(|t| t.id == id) { task.enabled = !task.enabled; if task.enabled { - task.next_run = task.schedule.next_run(Utc::now()); + task.next_run = Some(task.schedule.next_run(Utc::now())); } else { task.next_run = None; } @@ -236,7 +331,7 @@ impl TaskScheduler { task.running = true; task.last_job_id = Some(job_id); if task.enabled { - task.next_run = task.schedule.next_run(Utc::now()); + task.next_run = Some(task.schedule.next_run(Utc::now())); } drop(tasks); } @@ -308,7 +403,7 @@ impl TaskScheduler { task.last_run = Some(now); task.last_status = Some("running".to_string()); task.running = true; - task.next_run = task.schedule.next_run(now); + task.next_run = Some(task.schedule.next_run(now)); } } } @@ -336,7 +431,7 @@ mod tests { 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).unwrap(); + let next = schedule.next_run(from); assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 15, 13, 0, 0).unwrap()); } @@ -348,7 +443,7 @@ mod tests { hour: 14, minute: 0, }; - let next = schedule.next_run(from).unwrap(); + let next = schedule.next_run(from); assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 15, 14, 0, 0).unwrap()); } @@ -360,7 +455,7 @@ mod tests { hour: 14, minute: 0, }; - let next = schedule.next_run(from).unwrap(); + let next = schedule.next_run(from); assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 16, 14, 0, 0).unwrap()); } @@ -373,7 +468,7 @@ mod tests { hour: 3, minute: 0, }; - let next = schedule.next_run(from).unwrap(); + let next = schedule.next_run(from); assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 16, 3, 0, 0).unwrap()); } @@ -387,7 +482,7 @@ mod tests { hour: 14, minute: 0, }; - let next = schedule.next_run(from).unwrap(); + let next = schedule.next_run(from); assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 15, 14, 0, 0).unwrap()); } @@ -401,7 +496,7 @@ mod tests { hour: 8, minute: 0, }; - let next = schedule.next_run(from).unwrap(); + let next = schedule.next_run(from); assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 22, 8, 0, 0).unwrap()); } diff --git a/crates/pinakes-core/src/sharing.rs b/crates/pinakes-core/src/sharing.rs index 66fc2dc..62856bc 100644 --- a/crates/pinakes-core/src/sharing.rs +++ b/crates/pinakes-core/src/sharing.rs @@ -204,7 +204,7 @@ impl SharePermissions { /// Merges two permission sets, taking the most permissive values. #[must_use] - pub const fn merge(self, other: Self) -> Self { + pub const fn merge(&self, other: &Self) -> Self { Self { view: ShareViewPermissions { can_view: self.view.can_view || other.view.can_view, diff --git a/crates/pinakes-core/src/storage/migrations.rs b/crates/pinakes-core/src/storage/migrations.rs index 2de24b7..cedd987 100644 --- a/crates/pinakes-core/src/storage/migrations.rs +++ b/crates/pinakes-core/src/storage/migrations.rs @@ -1,25 +1,28 @@ -/// # Errors -/// -/// Returns an error if migrations fail to apply. -#[cfg(feature = "sqlite")] -pub fn run_sqlite_migrations( - conn: &mut rusqlite::Connection, -) -> crate::error::Result<()> { - pinakes_migrations::sqlite_migrations() - .to_latest(conn) - .map_err(|e| crate::error::PinakesError::Migration(e.to_string())) +use crate::error::{PinakesError, Result}; + +mod sqlite_migrations { + use refinery::embed_migrations; + embed_migrations!("../../migrations/sqlite"); +} + +mod postgres_migrations { + 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(()) } -/// # Errors -/// -/// Returns an error if migrations fail to apply. -#[cfg(feature = "postgres")] pub async fn run_postgres_migrations( client: &mut tokio_postgres::Client, -) -> crate::error::Result<()> { - pinakes_migrations::postgres_runner() +) -> Result<()> { + postgres_migrations::migrations::runner() .run_async(client) .await - .map(|_| ()) - .map_err(|e| crate::error::PinakesError::Migration(e.to_string())) + .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 a505e42..e1d93bc 100644 --- a/crates/pinakes-core/src/storage/mod.rs +++ b/crates/pinakes-core/src/storage/mod.rs @@ -1,16 +1,16 @@ pub mod migrations; -#[cfg(feature = "postgres")] pub mod postgres; -#[cfg(feature = "sqlite")] pub mod sqlite; +pub mod postgres; +pub mod sqlite; use std::{path::PathBuf, sync::Arc}; use chrono::{DateTime, Utc}; -use pinakes_enrichment::ExternalMetadata; use rustc_hash::FxHashMap; use uuid::Uuid; use crate::{ analytics::UsageEvent, + enrichment::ExternalMetadata, error::Result, model::{ AuditEntry, @@ -412,7 +412,7 @@ pub trait StorageBackend: Send + Sync + 'static { } } - Err(pinakes_types::error::PinakesError::Authorization(format!( + Err(crate::error::PinakesError::Authorization(format!( "user {user_id} has no access to media {media_id}" ))) } @@ -423,12 +423,10 @@ pub trait StorageBackend: Send + Sync + 'static { user_id: crate::users::UserId, media_id: crate::model::MediaId, ) -> Result { - Ok( - self - .check_library_access(user_id, media_id) - .await - .is_ok_and(|_perm| crate::users::LibraryPermission::can_read()), - ) + 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 @@ -437,12 +435,10 @@ pub trait StorageBackend: Send + Sync + 'static { user_id: crate::users::UserId, media_id: crate::model::MediaId, ) -> Result { - Ok( - self - .check_library_access(user_id, media_id) - .await - .is_ok_and(crate::users::LibraryPermission::can_write), - ) + match self.check_library_access(user_id, media_id).await { + Ok(perm) => Ok(perm.can_write()), + Err(_) => Ok(false), + } } /// Rate a media item (1-5 stars) with an optional text review. @@ -845,44 +841,42 @@ pub trait StorageBackend: Send + Sync + 'static { /// Register a new sync device async fn register_device( &self, - device: &pinakes_sync::SyncDevice, + device: &crate::sync::SyncDevice, token_hash: &str, - ) -> Result; + ) -> Result; /// Get a sync device by ID async fn get_device( &self, - id: pinakes_sync::DeviceId, - ) -> Result; + id: crate::sync::DeviceId, + ) -> Result; /// Get a sync device by its token hash async fn get_device_by_token( &self, token_hash: &str, - ) -> Result>; + ) -> Result>; /// List all devices for a user async fn list_user_devices( &self, user_id: UserId, - ) -> Result>; + ) -> Result>; /// Update a sync device - async fn update_device( - &self, - device: &pinakes_sync::SyncDevice, - ) -> Result<()>; + async fn update_device(&self, device: &crate::sync::SyncDevice) + -> Result<()>; /// Delete a sync device - async fn delete_device(&self, id: pinakes_sync::DeviceId) -> Result<()>; + 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: pinakes_sync::DeviceId) -> Result<()>; + async fn touch_device(&self, id: crate::sync::DeviceId) -> Result<()>; /// Record a change in the sync log async fn record_sync_change( &self, - change: &pinakes_sync::SyncLogEntry, + change: &crate::sync::SyncLogEntry, ) -> Result<()>; /// Get changes since a cursor position @@ -890,7 +884,7 @@ pub trait StorageBackend: Send + Sync + 'static { &self, cursor: i64, limit: u64, - ) -> Result>; + ) -> Result>; /// Get the current sync cursor (highest sequence number) async fn get_current_sync_cursor(&self) -> Result; @@ -901,52 +895,52 @@ pub trait StorageBackend: Send + Sync + 'static { /// Get sync state for a device and path async fn get_device_sync_state( &self, - device_id: pinakes_sync::DeviceId, + device_id: crate::sync::DeviceId, path: &str, - ) -> Result>; + ) -> Result>; /// Insert or update device sync state async fn upsert_device_sync_state( &self, - state: &pinakes_sync::DeviceSyncState, + state: &crate::sync::DeviceSyncState, ) -> Result<()>; /// List all pending sync items for a device async fn list_pending_sync( &self, - device_id: pinakes_sync::DeviceId, - ) -> Result>; + device_id: crate::sync::DeviceId, + ) -> Result>; /// Create a new upload session async fn create_upload_session( &self, - session: &pinakes_sync::UploadSession, + session: &crate::sync::UploadSession, ) -> Result<()>; /// Get an upload session by ID async fn get_upload_session( &self, id: Uuid, - ) -> Result; + ) -> Result; /// Update an upload session async fn update_upload_session( &self, - session: &pinakes_sync::UploadSession, + session: &crate::sync::UploadSession, ) -> Result<()>; /// Record a received chunk async fn record_chunk( &self, upload_id: Uuid, - chunk: &pinakes_sync::ChunkInfo, + chunk: &crate::sync::ChunkInfo, ) -> Result<()>; /// Get all chunks for an upload async fn get_upload_chunks( &self, upload_id: Uuid, - ) -> Result>; + ) -> Result>; /// Clean up expired upload sessions async fn cleanup_expired_uploads(&self) -> Result; @@ -954,20 +948,20 @@ pub trait StorageBackend: Send + Sync + 'static { /// Record a sync conflict async fn record_conflict( &self, - conflict: &pinakes_sync::SyncConflict, + conflict: &crate::sync::SyncConflict, ) -> Result<()>; /// Get unresolved conflicts for a device async fn get_unresolved_conflicts( &self, - device_id: pinakes_sync::DeviceId, - ) -> Result>; + device_id: crate::sync::DeviceId, + ) -> Result>; /// Resolve a conflict async fn resolve_conflict( &self, id: Uuid, - resolution: pinakes_types::config::ConflictResolution, + resolution: crate::config::ConflictResolution, ) -> Result<()>; /// Create a new share @@ -1182,7 +1176,7 @@ pub trait StorageBackend: Send + Sync + 'static { /// deployments should use `pg_dump` directly; this method returns /// `PinakesError::InvalidOperation` for unsupported backends. async fn backup(&self, _dest: &std::path::Path) -> Result<()> { - Err(pinakes_types::error::PinakesError::InvalidOperation( + Err(crate::error::PinakesError::InvalidOperation( "backup not supported for this storage backend; use pg_dump for \ PostgreSQL" .to_string(), diff --git a/crates/pinakes-core/src/storage/postgres.rs b/crates/pinakes-core/src/storage/postgres.rs index 6935ff7..dbeb0e2 100644 --- a/crates/pinakes-core/src/storage/postgres.rs +++ b/crates/pinakes-core/src/storage/postgres.rs @@ -10,7 +10,7 @@ use uuid::Uuid; use crate::{ config::PostgresConfig, - error::{PinakesError, Result, db_ctx}, + error::{PinakesError, Result}, media_type::MediaType, model::{ AuditAction, @@ -629,8 +629,7 @@ impl StorageBackend for PostgresBackend { NOTHING", &[&path.to_string_lossy().as_ref()], ) - .await - .map_err(db_ctx("insert_root_dirs", path.display()))?; + .await?; Ok(()) } @@ -644,8 +643,7 @@ impl StorageBackend for PostgresBackend { let rows = client .query("SELECT path FROM root_dirs ORDER BY path", &[]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok( rows @@ -666,8 +664,7 @@ impl StorageBackend for PostgresBackend { .execute("DELETE FROM root_dirs WHERE path = $1", &[&path .to_string_lossy() .as_ref()]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -727,8 +724,7 @@ impl StorageBackend for PostgresBackend { &item.updated_at, ], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; // Insert custom fields for (name, field) in &item.custom_fields { @@ -743,8 +739,7 @@ impl StorageBackend for PostgresBackend { EXCLUDED.field_value", &[&item.id.0, &name, &ft, &field.value], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; } Ok(()) @@ -761,8 +756,7 @@ impl StorageBackend for PostgresBackend { "SELECT COUNT(*) FROM media_items WHERE deleted_at IS NULL", &[], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let count: i64 = row.get(0); Ok(count.cast_unsigned()) } @@ -788,8 +782,7 @@ impl StorageBackend for PostgresBackend { FROM media_items WHERE id = $1", &[&id.0], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? + .await? .ok_or_else(|| PinakesError::NotFound(format!("media item {id}")))?; let mut item = row_to_media_item(&row)?; @@ -821,8 +814,7 @@ impl StorageBackend for PostgresBackend { FROM media_items WHERE content_hash = $1", &[&hash.0], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; match row { Some(r) => { @@ -859,8 +851,7 @@ impl StorageBackend for PostgresBackend { FROM media_items WHERE path = $1", &[&path_str], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; match row { Some(r) => { @@ -913,8 +904,7 @@ impl StorageBackend for PostgresBackend { &(pagination.limit.cast_signed()), &(pagination.offset.cast_signed()), ]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let mut items = Vec::with_capacity(rows.len()); for row in &rows { @@ -931,8 +921,7 @@ impl StorageBackend for PostgresBackend { FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let mut cf_map: FxHashMap> = FxHashMap::default(); @@ -1019,8 +1008,7 @@ impl StorageBackend for PostgresBackend { &item.updated_at, ], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; if rows_affected == 0 { return Err(PinakesError::NotFound(format!("media item {}", item.id))); @@ -1031,8 +1019,7 @@ impl StorageBackend for PostgresBackend { .execute("DELETE FROM custom_fields WHERE media_id = $1", &[&item .id .0]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; for (name, field) in &item.custom_fields { let ft = custom_field_type_to_string(field.field_type); @@ -1043,8 +1030,7 @@ impl StorageBackend for PostgresBackend { VALUES ($1, $2, $3, $4)", &[&item.id.0, &name, &ft, &field.value], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; } txn.commit().await.map_err(|e| { @@ -1063,8 +1049,7 @@ impl StorageBackend for PostgresBackend { let rows_affected = client .execute("DELETE FROM media_items WHERE id = $1", &[&id.0]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; if rows_affected == 0 { return Err(PinakesError::NotFound(format!("media item {id}"))); @@ -1082,14 +1067,10 @@ impl StorageBackend for PostgresBackend { let count: i64 = client .query_one("SELECT COUNT(*) FROM media_items", &[]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? + .await? .get(0); - client - .execute("DELETE FROM media_items", &[]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + client.execute("DELETE FROM media_items", &[]).await?; Ok(count.cast_unsigned()) } @@ -1109,8 +1090,7 @@ impl StorageBackend for PostgresBackend { let uuids: Vec = ids.iter().map(|id| id.0).collect(); let rows = client .execute("DELETE FROM media_items WHERE id = ANY($1)", &[&uuids]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(rows) } @@ -1146,8 +1126,7 @@ impl StorageBackend for PostgresBackend { ON CONFLICT DO NOTHING", &[&media_uuids, &tag_uuids], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(rows) } @@ -1230,10 +1209,7 @@ impl StorageBackend for PostgresBackend { .iter() .map(|p| p.as_ref() as &(dyn tokio_postgres::types::ToSql + Sync)) .collect(); - let rows = client - .execute(&sql, ¶m_refs) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + let rows = client.execute(&sql, ¶m_refs).await?; Ok(rows) } @@ -1258,8 +1234,7 @@ impl StorageBackend for PostgresBackend { $3, $4)", &[&id, &name, &parent_id, &now], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(Tag { id, @@ -1281,8 +1256,7 @@ impl StorageBackend for PostgresBackend { "SELECT id, name, parent_id, created_at FROM tags WHERE id = $1", &[&id], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? + .await? .ok_or_else(|| PinakesError::TagNotFound(id.to_string()))?; Ok(row_to_tag(&row)) @@ -1300,8 +1274,7 @@ impl StorageBackend for PostgresBackend { "SELECT id, name, parent_id, created_at FROM tags ORDER BY name", &[], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(rows.iter().map(row_to_tag).collect()) } @@ -1315,8 +1288,7 @@ impl StorageBackend for PostgresBackend { let rows_affected = client .execute("DELETE FROM tags WHERE id = $1", &[&id]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; if rows_affected == 0 { return Err(PinakesError::TagNotFound(id.to_string())); @@ -1338,8 +1310,7 @@ impl StorageBackend for PostgresBackend { ON CONFLICT (media_id, tag_id) DO NOTHING", &[&media_id.0, &tag_id], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -1356,8 +1327,7 @@ impl StorageBackend for PostgresBackend { "DELETE FROM media_tags WHERE media_id = $1 AND tag_id = $2", &[&media_id.0, &tag_id], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -1378,8 +1348,7 @@ impl StorageBackend for PostgresBackend { ORDER BY t.name", &[&media_id.0], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(rows.iter().map(row_to_tag).collect()) } @@ -1406,8 +1375,7 @@ impl StorageBackend for PostgresBackend { BY name", &[&tag_id], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(rows.iter().map(row_to_tag).collect()) } @@ -1445,8 +1413,7 @@ impl StorageBackend for PostgresBackend { &now, ], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(Collection { id, @@ -1473,8 +1440,7 @@ impl StorageBackend for PostgresBackend { FROM collections WHERE id = $1", &[&id], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? + .await? .ok_or_else(|| PinakesError::CollectionNotFound(id.to_string()))?; row_to_collection(&row) @@ -1494,8 +1460,7 @@ impl StorageBackend for PostgresBackend { FROM collections ORDER BY name", &[], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; rows.iter().map(row_to_collection).collect() } @@ -1509,8 +1474,7 @@ impl StorageBackend for PostgresBackend { let rows_affected = client .execute("DELETE FROM collections WHERE id = $1", &[&id]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; if rows_affected == 0 { return Err(PinakesError::CollectionNotFound(id.to_string())); @@ -1542,8 +1506,7 @@ impl StorageBackend for PostgresBackend { = EXCLUDED.position", &[&collection_id, &media_id.0, &position, &now], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; // Update the collection's updated_at timestamp client @@ -1551,8 +1514,7 @@ impl StorageBackend for PostgresBackend { &collection_id, &now, ]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -1574,8 +1536,7 @@ impl StorageBackend for PostgresBackend { = $2", &[&collection_id, &media_id.0], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let now = Utc::now(); client @@ -1583,8 +1544,7 @@ impl StorageBackend for PostgresBackend { &collection_id, &now, ]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -1619,8 +1579,7 @@ impl StorageBackend for PostgresBackend { ORDER BY cm.position ASC", &[&collection_id], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let mut items = Vec::with_capacity(rows.len()); for row in &rows { @@ -1636,8 +1595,7 @@ impl StorageBackend for PostgresBackend { FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let mut cf_map: FxHashMap> = FxHashMap::default(); @@ -1772,10 +1730,7 @@ impl StorageBackend for PostgresBackend { .map(|p| p.as_ref() as &(dyn ToSql + Sync)) .collect(); - let count_row = client - .query_one(&count_sql, &count_params) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + let count_row = client.query_one(&count_sql, &count_params).await?; let total_count: i64 = count_row.get(0); // Add pagination params @@ -1787,10 +1742,7 @@ impl StorageBackend for PostgresBackend { .map(|p| p.as_ref() as &(dyn ToSql + Sync)) .collect(); - let rows = client - .query(&select_sql, &select_params) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + let rows = client.query(&select_sql, &select_params).await?; let mut items = Vec::with_capacity(rows.len()); for row in &rows { @@ -1806,8 +1758,7 @@ impl StorageBackend for PostgresBackend { FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let mut cf_map: FxHashMap> = FxHashMap::default(); @@ -1859,8 +1810,7 @@ impl StorageBackend for PostgresBackend { &entry.timestamp, ], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -1891,8 +1841,7 @@ impl StorageBackend for PostgresBackend { &(pagination.offset.cast_signed()), ], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? + .await? }, None => { client @@ -1906,8 +1855,7 @@ impl StorageBackend for PostgresBackend { &(pagination.offset.cast_signed()), ], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? + .await? }, }; @@ -1939,8 +1887,7 @@ impl StorageBackend for PostgresBackend { EXCLUDED.field_value", &[&media_id.0, &name, &ft, &field.value], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -1961,8 +1908,7 @@ impl StorageBackend for PostgresBackend { FROM custom_fields WHERE media_id = $1", &[&media_id.0], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let mut map = FxHashMap::default(); for row in &rows { @@ -1992,8 +1938,7 @@ impl StorageBackend for PostgresBackend { "DELETE FROM custom_fields WHERE media_id = $1 AND field_name = $2", &[&media_id.0, &name], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -2026,8 +1971,7 @@ impl StorageBackend for PostgresBackend { ORDER BY content_hash, created_at", &[], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let mut items = Vec::with_capacity(rows.len()); for row in &rows { @@ -2043,8 +1987,7 @@ impl StorageBackend for PostgresBackend { FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let mut cf_map: FxHashMap> = FxHashMap::default(); @@ -2106,8 +2049,7 @@ impl StorageBackend for PostgresBackend { FROM media_items WHERE perceptual_hash IS NOT NULL ORDER BY id", &[], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let mut items = Vec::with_capacity(rows.len()); for row in &rows { @@ -2123,8 +2065,7 @@ impl StorageBackend for PostgresBackend { FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let mut cf_map: FxHashMap> = FxHashMap::default(); @@ -2211,28 +2152,23 @@ impl StorageBackend for PostgresBackend { let media_count: i64 = client .query_one("SELECT COUNT(*) FROM media_items", &[]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? + .await? .get(0); let tag_count: i64 = client .query_one("SELECT COUNT(*) FROM tags", &[]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? + .await? .get(0); let collection_count: i64 = client .query_one("SELECT COUNT(*) FROM collections", &[]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? + .await? .get(0); let audit_count: i64 = client .query_one("SELECT COUNT(*) FROM audit_log", &[]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? + .await? .get(0); let database_size_bytes: i64 = client .query_one("SELECT pg_database_size(current_database())", &[]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? + .await? .get(0); Ok(crate::storage::DatabaseStats { @@ -2252,10 +2188,7 @@ impl StorageBackend for PostgresBackend { .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; - client - .execute("VACUUM ANALYZE", &[]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + client.execute("VACUUM ANALYZE", &[]).await?; Ok(()) } @@ -2273,8 +2206,7 @@ impl StorageBackend for PostgresBackend { media_items, tags, collections CASCADE", &[], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -2289,8 +2221,7 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client .query("SELECT id, path, content_hash FROM media_items", &[]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let mut results = Vec::with_capacity(rows.len()); for row in rows { let id: Uuid = row.get(0); @@ -2322,8 +2253,7 @@ impl StorageBackend for PostgresBackend { sort_order = $4", &[&id, &name, &query, &sort_order, &now], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -2341,8 +2271,7 @@ impl StorageBackend for PostgresBackend { ORDER BY created_at DESC", &[], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let mut results = Vec::with_capacity(rows.len()); for row in rows { results.push(crate::model::SavedSearch { @@ -2371,8 +2300,7 @@ impl StorageBackend for PostgresBackend { WHERE id = $1", &[&id], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? + .await? .ok_or_else(|| PinakesError::NotFound(format!("saved search {id}")))?; Ok(crate::model::SavedSearch { id: row.get(0), @@ -2391,8 +2319,7 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute("DELETE FROM saved_searches WHERE id = $1", &[&id]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -2411,10 +2338,7 @@ impl StorageBackend for PostgresBackend { } else { "SELECT id FROM media_items ORDER BY created_at DESC" }; - let rows = client - .query(sql, &[]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + let rows = client.query(sql, &[]).await?; let ids = rows .iter() .map(|r| { @@ -2448,8 +2372,7 @@ impl StorageBackend for PostgresBackend { FROM users ORDER BY created_at DESC", &[], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let mut users = Vec::with_capacity(rows.len()); for row in rows { let user_id: uuid::Uuid = row.get::<_, uuid::Uuid>(0); @@ -2483,8 +2406,7 @@ impl StorageBackend for PostgresBackend { FROM users WHERE id = $1", &[&id.0], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? + .await? .ok_or_else(|| PinakesError::NotFound(format!("user {}", id.0)))?; let profile = self.load_user_profile(id.0).await?; Ok(crate::users::User { @@ -2514,8 +2436,7 @@ impl StorageBackend for PostgresBackend { FROM users WHERE username = $1", &[&username], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? + .await? .ok_or_else(|| { PinakesError::NotFound(format!("user with username {username}")) })?; @@ -2555,8 +2476,7 @@ impl StorageBackend for PostgresBackend { updated_at) VALUES ($1, $2, $3, $4, $5, $6)", &[&id, &username, &password_hash, &role_json, &now, &now], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let user_profile = if let Some(prof) = profile.clone() { let prefs_json = serde_json::to_value(&prof.preferences)?; @@ -2567,8 +2487,7 @@ impl StorageBackend for PostgresBackend { $5, $6)", &[&id, &prof.avatar_path, &prof.bio, &prefs_json, &now, &now], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; prof } else { crate::users::UserProfile { @@ -2645,10 +2564,7 @@ impl StorageBackend for PostgresBackend { } params.push(&id.0); - client - .execute(&sql, ¶ms) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + client.execute(&sql, ¶ms).await?; } // Update profile if provided @@ -2663,8 +2579,7 @@ impl StorageBackend for PostgresBackend { = $3, preferences_json = $4, updated_at = $6", &[&id.0, &prof.avatar_path, &prof.bio, &prefs_json, &now, &now], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; } // Fetch updated user @@ -2681,18 +2596,15 @@ impl StorageBackend for PostgresBackend { // Delete profile first due to foreign key client .execute("DELETE FROM user_profiles WHERE user_id = $1", &[&id.0]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; // Delete library access client .execute("DELETE FROM user_libraries WHERE user_id = $1", &[&id.0]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; // Delete user let affected = client .execute("DELETE FROM users WHERE id = $1", &[&id.0]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; if affected == 0 { return Err(PinakesError::NotFound(format!("user {}", id.0))); } @@ -2714,8 +2626,7 @@ impl StorageBackend for PostgresBackend { user_libraries WHERE user_id = $1", &[&user_id.0], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let mut libraries = Vec::with_capacity(rows.len()); for row in rows { libraries.push(crate::users::UserLibraryAccess { @@ -2750,8 +2661,7 @@ impl StorageBackend for PostgresBackend { $3, granted_at = $4", &[&user_id.0, &root_path, &perm_json, &now], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -2770,8 +2680,7 @@ impl StorageBackend for PostgresBackend { "DELETE FROM user_libraries WHERE user_id = $1 AND root_path = $2", &[&user_id.0, &root_path], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -2798,8 +2707,7 @@ impl StorageBackend for PostgresBackend { created_at", &[&id, &user_id.0, &media_id.0, &stars_i32, &review, &now], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let actual_id: Uuid = row.get(0); let actual_created_at: chrono::DateTime = row.get(1); Ok(crate::social::Rating { @@ -2827,8 +2735,7 @@ impl StorageBackend for PostgresBackend { ratings WHERE media_id = $1 ORDER BY created_at DESC", &[&media_id.0], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok( rows .iter() @@ -2862,8 +2769,7 @@ impl StorageBackend for PostgresBackend { ratings WHERE user_id = $1 AND media_id = $2", &[&user_id.0, &media_id.0], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(rows.first().map(|row| { crate::social::Rating { id: row.get("id"), @@ -2884,8 +2790,7 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute("DELETE FROM ratings WHERE id = $1", &[&id]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -2909,8 +2814,7 @@ impl StorageBackend for PostgresBackend { text, created_at) VALUES ($1, $2, $3, $4, $5, $6)", &[&id, &user_id.0, &media_id.0, &parent_id, &text, &now], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(crate::social::Comment { id, user_id, @@ -2936,8 +2840,7 @@ impl StorageBackend for PostgresBackend { FROM comments WHERE media_id = $1 ORDER BY created_at ASC", &[&media_id.0], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok( rows .iter() @@ -2963,8 +2866,7 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute("DELETE FROM comments WHERE id = $1", &[&id]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -2985,8 +2887,7 @@ impl StorageBackend for PostgresBackend { $2, $3) ON CONFLICT DO NOTHING", &[&user_id.0, &media_id.0, &now], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -3005,8 +2906,7 @@ impl StorageBackend for PostgresBackend { "DELETE FROM favorites WHERE user_id = $1 AND media_id = $2", &[&user_id.0, &media_id.0], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -3037,8 +2937,7 @@ impl StorageBackend for PostgresBackend { &(pagination.offset.cast_signed()), ], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let mut items: Vec = rows .iter() .map(row_to_media_item) @@ -3053,8 +2952,7 @@ impl StorageBackend for PostgresBackend { FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let mut cf_map: FxHashMap> = FxHashMap::default(); for row in &cf_rows { @@ -3093,8 +2991,7 @@ impl StorageBackend for PostgresBackend { "SELECT COUNT(*) FROM favorites WHERE user_id = $1 AND media_id = $2", &[&user_id.0, &media_id.0], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let count: i64 = row.get(0); Ok(count > 0) } @@ -3131,8 +3028,7 @@ impl StorageBackend for PostgresBackend { &now, ], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(crate::social::ShareLink { id, media_id, @@ -3160,8 +3056,7 @@ impl StorageBackend for PostgresBackend { view_count, created_at FROM share_links WHERE token = $1", &[&token], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let row = rows .first() .ok_or_else(|| PinakesError::NotFound("share link not found".into()))?; @@ -3190,8 +3085,7 @@ impl StorageBackend for PostgresBackend { "UPDATE share_links SET view_count = view_count + 1 WHERE token = $1", &[&token], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -3203,8 +3097,7 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute("DELETE FROM share_links WHERE id = $1", &[&id]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -3241,8 +3134,7 @@ impl StorageBackend for PostgresBackend { &now, ], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(crate::playlists::Playlist { id, owner_id, @@ -3268,8 +3160,7 @@ impl StorageBackend for PostgresBackend { filter_query, created_at, updated_at FROM playlists WHERE id = $1", &[&id], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let row = rows .first() .ok_or_else(|| PinakesError::NotFound(format!("playlist {id}")))?; @@ -3304,8 +3195,7 @@ impl StorageBackend for PostgresBackend { owner_id = $1 OR is_public = true ORDER BY updated_at DESC", &[&uid.0], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? + .await? }, None => { client @@ -3315,8 +3205,7 @@ impl StorageBackend for PostgresBackend { updated_at DESC", &[], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? + .await? }, }; Ok( @@ -3381,10 +3270,7 @@ impl StorageBackend for PostgresBackend { .iter() .map(|p| &**p as &(dyn tokio_postgres::types::ToSql + Sync)) .collect(); - client - .execute(&sql, ¶m_refs) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + client.execute(&sql, ¶m_refs).await?; self.get_playlist(id).await } @@ -3396,8 +3282,7 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute("DELETE FROM playlists WHERE id = $1", &[&id]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -3420,8 +3305,7 @@ impl StorageBackend for PostgresBackend { media_id) DO UPDATE SET position = $3", &[&playlist_id, &media_id.0, &position, &now], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -3440,8 +3324,7 @@ impl StorageBackend for PostgresBackend { "DELETE FROM playlist_items WHERE playlist_id = $1 AND media_id = $2", &[&playlist_id, &media_id.0], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -3467,8 +3350,7 @@ impl StorageBackend for PostgresBackend { $1 ORDER BY pi.position ASC", &[&playlist_id], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let mut items: Vec = rows .iter() .map(row_to_media_item) @@ -3483,8 +3365,7 @@ impl StorageBackend for PostgresBackend { FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let mut cf_map: FxHashMap> = FxHashMap::default(); for row in &cf_rows { @@ -3525,8 +3406,7 @@ impl StorageBackend for PostgresBackend { media_id = $3", &[&new_position, &playlist_id, &media_id.0], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -3561,8 +3441,7 @@ impl StorageBackend for PostgresBackend { &context, ], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -3606,10 +3485,7 @@ impl StorageBackend for PostgresBackend { .iter() .map(|p| &**p as &(dyn tokio_postgres::types::ToSql + Sync)) .collect(); - let rows = client - .query(&sql, ¶m_refs) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + let rows = client.query(&sql, ¶m_refs).await?; Ok( rows .iter() @@ -3660,8 +3536,7 @@ impl StorageBackend for PostgresBackend { m.deleted_at, m.links_extracted_at ORDER BY view_count DESC LIMIT $1", &[&limit.cast_signed()], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let mut results = Vec::new(); for row in &rows { let item = row_to_media_item(row)?; @@ -3678,8 +3553,7 @@ impl StorageBackend for PostgresBackend { FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let mut cf_map: FxHashMap> = FxHashMap::default(); for row in &cf_rows { @@ -3734,8 +3608,7 @@ impl StorageBackend for PostgresBackend { LIMIT $2", &[&user_id.0, &limit.cast_signed()], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let mut items: Vec = rows .iter() .map(row_to_media_item) @@ -3750,8 +3623,7 @@ impl StorageBackend for PostgresBackend { FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let mut cf_map: FxHashMap> = FxHashMap::default(); for row in &cf_rows { @@ -3795,8 +3667,7 @@ impl StorageBackend for PostgresBackend { media_id) DO UPDATE SET progress_secs = $4, last_watched = $5", &[&id, &user_id.0, &media_id.0, &progress_secs, &now], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -3816,8 +3687,7 @@ impl StorageBackend for PostgresBackend { media_id = $2", &[&user_id.0, &media_id.0], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(rows.first().map(|row| row.get("progress_secs"))) } @@ -3832,8 +3702,7 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let affected = client .execute("DELETE FROM usage_events WHERE timestamp < $1", &[&before]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(affected) } @@ -3884,8 +3753,7 @@ impl StorageBackend for PostgresBackend { &subtitle.created_at, ], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -3904,8 +3772,7 @@ impl StorageBackend for PostgresBackend { track_index, offset_ms, created_at FROM subtitles WHERE media_id = $1", &[&media_id.0], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok( rows .iter() @@ -3941,8 +3808,7 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute("DELETE FROM subtitles WHERE id = $1", &[&id]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -3965,14 +3831,13 @@ impl StorageBackend for PostgresBackend { .execute("UPDATE subtitles SET offset_ms = $1 WHERE id = $2", &[ &offset, &id, ]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } async fn store_external_metadata( &self, - meta: &pinakes_enrichment::ExternalMetadata, + meta: &crate::enrichment::ExternalMetadata, ) -> Result<()> { let client = self .pool @@ -4005,15 +3870,14 @@ impl StorageBackend for PostgresBackend { &meta.last_updated, ], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } async fn get_external_metadata( &self, media_id: MediaId, - ) -> Result> { + ) -> Result> { let client = self .pool .get() @@ -4025,20 +3889,19 @@ impl StorageBackend for PostgresBackend { last_updated FROM external_metadata WHERE media_id = $1", &[&media_id.0], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok( rows .iter() .map(|row| { let source_str: String = row.get("source"); let metadata_json: serde_json::Value = row.get("metadata_json"); - pinakes_enrichment::ExternalMetadata { + crate::enrichment::ExternalMetadata { id: row.get("id"), media_id: MediaId(row.get("media_id")), source: source_str .parse() - .unwrap_or(pinakes_enrichment::EnrichmentSourceType::MusicBrainz), + .unwrap_or(crate::enrichment::EnrichmentSourceType::MusicBrainz), external_id: row.get("external_id"), metadata_json: metadata_json.to_string(), confidence: row.get("confidence"), @@ -4057,8 +3920,7 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute("DELETE FROM external_metadata WHERE id = $1", &[&id]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -4094,8 +3956,7 @@ impl StorageBackend for PostgresBackend { &session.expires_at, ], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -4115,8 +3976,7 @@ impl StorageBackend for PostgresBackend { transcode_sessions WHERE id = $1", &[&id], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let row = rows.first().ok_or_else(|| { PinakesError::NotFound(format!("transcode session {id}")) })?; @@ -4162,8 +4022,7 @@ impl StorageBackend for PostgresBackend { transcode_sessions WHERE media_id = $1 ORDER BY created_at DESC", &[&mid.0], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? + .await? }, None => { client @@ -4173,8 +4032,7 @@ impl StorageBackend for PostgresBackend { transcode_sessions ORDER BY created_at DESC", &[], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? + .await? }, }; Ok( @@ -4228,8 +4086,7 @@ impl StorageBackend for PostgresBackend { error_message = $3 WHERE id = $4", &[&status_str, &progress_f64, &error_message, &id], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -4248,8 +4105,7 @@ impl StorageBackend for PostgresBackend { expires_at < $1", &[&before], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(affected) } @@ -4278,8 +4134,7 @@ impl StorageBackend for PostgresBackend { &session.last_accessed, ], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -4300,8 +4155,7 @@ impl StorageBackend for PostgresBackend { FROM sessions WHERE session_token = $1", &[&session_token], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(row.map(|r| { crate::storage::SessionData { @@ -4329,8 +4183,7 @@ impl StorageBackend for PostgresBackend { "UPDATE sessions SET last_accessed = $1 WHERE session_token = $2", &[&now, &session_token], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -4352,8 +4205,7 @@ impl StorageBackend for PostgresBackend { session_token = $3 AND expires_at > NOW()", &[&new_expires_at, &now, &session_token], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; if rows > 0 { Ok(Some(new_expires_at)) } else { @@ -4372,8 +4224,7 @@ impl StorageBackend for PostgresBackend { .execute("DELETE FROM sessions WHERE session_token = $1", &[ &session_token, ]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -4386,8 +4237,7 @@ impl StorageBackend for PostgresBackend { let affected = client .execute("DELETE FROM sessions WHERE username = $1", &[&username]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(affected) } @@ -4401,8 +4251,7 @@ impl StorageBackend for PostgresBackend { let now = chrono::Utc::now(); let affected = client .execute("DELETE FROM sessions WHERE expires_at < $1", &[&now]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(affected) } @@ -4426,8 +4275,7 @@ impl StorageBackend for PostgresBackend { ORDER BY last_accessed DESC", &[&now, &user], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? + .await? } else { client .query( @@ -4437,8 +4285,7 @@ impl StorageBackend for PostgresBackend { ORDER BY last_accessed DESC", &[&now], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? + .await? }; Ok( @@ -4476,10 +4323,7 @@ impl StorageBackend for PostgresBackend { .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; - let tx = client - .transaction() - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + let tx = client.transaction().await?; // Upsert book_metadata tx.execute( @@ -4504,20 +4348,17 @@ impl StorageBackend for PostgresBackend { &metadata.format, ], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; // Clear existing authors and identifiers tx.execute("DELETE FROM book_authors WHERE media_id = $1", &[&metadata .media_id .0]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; tx.execute("DELETE FROM book_identifiers WHERE media_id = $1", &[ &metadata.media_id.0, ]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; // Insert authors for author in &metadata.authors { @@ -4533,8 +4374,7 @@ impl StorageBackend for PostgresBackend { &author.position, ], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; } // Insert identifiers @@ -4546,14 +4386,11 @@ impl StorageBackend for PostgresBackend { VALUES ($1, $2, $3)", &[&metadata.media_id.0, &id_type, &value], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; } } - tx.commit() - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + tx.commit().await?; Ok(()) } @@ -4576,8 +4413,7 @@ impl StorageBackend for PostgresBackend { FROM book_metadata WHERE media_id = $1", &[&media_id.0], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let Some(row) = row else { return Ok(None); @@ -4590,8 +4426,7 @@ impl StorageBackend for PostgresBackend { FROM book_authors WHERE media_id = $1 ORDER BY position", &[&media_id.0], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let authors: Vec = author_rows .iter() @@ -4612,8 +4447,7 @@ impl StorageBackend for PostgresBackend { FROM book_identifiers WHERE media_id = $1", &[&media_id.0], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let mut identifiers: FxHashMap> = FxHashMap::default(); for r in id_rows { @@ -4666,8 +4500,7 @@ impl StorageBackend for PostgresBackend { &author.position, ], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -4687,8 +4520,7 @@ impl StorageBackend for PostgresBackend { FROM book_authors WHERE media_id = $1 ORDER BY position", &[&media_id.0], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok( rows @@ -4727,8 +4559,7 @@ impl StorageBackend for PostgresBackend { &(pagination.offset.cast_signed()), ], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok( rows @@ -4754,8 +4585,7 @@ impl StorageBackend for PostgresBackend { ORDER BY series_name", &[], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok( rows @@ -4795,8 +4625,7 @@ impl StorageBackend for PostgresBackend { ORDER BY b.series_index, m.title", &[&series_name], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; rows.iter().map(row_to_media_item).collect() } @@ -4822,8 +4651,7 @@ impl StorageBackend for PostgresBackend { progress_secs = $3, last_watched_at = NOW()", &[&user_id, &media_id.0, &f64::from(current_page)], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(()) } @@ -4847,8 +4675,7 @@ impl StorageBackend for PostgresBackend { WHERE wh.user_id = $1 AND wh.media_id = $2", &[&user_id, &media_id.0], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(row.map(|r| { let current_page: i32 = r.get(0); @@ -4994,8 +4821,7 @@ impl StorageBackend for PostgresBackend { &(pagination.offset.cast_signed()), ], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? + .await? } else if isbn.is_none() && author.is_none() && series.is_none() @@ -5026,8 +4852,7 @@ impl StorageBackend for PostgresBackend { &(pagination.offset.cast_signed()), ], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? + .await? } else { // For other combinations, use dynamic query (simplified - just filter by // what's provided) @@ -5054,16 +4879,14 @@ impl StorageBackend for PostgresBackend { &(pagination.limit.cast_signed()), &(pagination.offset.cast_signed()), ]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? + .await? } else { client .query(&query, &[ &(pagination.limit.cast_signed()), &(pagination.offset.cast_signed()), ]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? + .await? } }; @@ -5358,9 +5181,9 @@ impl StorageBackend for PostgresBackend { async fn register_device( &self, - device: &pinakes_sync::SyncDevice, + device: &crate::sync::SyncDevice, token_hash: &str, - ) -> Result { + ) -> Result { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5395,8 +5218,8 @@ impl StorageBackend for PostgresBackend { async fn get_device( &self, - id: pinakes_sync::DeviceId, - ) -> Result { + id: crate::sync::DeviceId, + ) -> Result { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5412,9 +5235,9 @@ impl StorageBackend for PostgresBackend { .await .map_err(|e| PinakesError::Database(e.to_string()))?; - Ok(pinakes_sync::SyncDevice { - id: pinakes_sync::DeviceId(row.get(0)), - user_id: pinakes_types::model::UserId(row.get(1)), + 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), @@ -5431,7 +5254,7 @@ impl StorageBackend for PostgresBackend { async fn get_device_by_token( &self, token_hash: &str, - ) -> Result> { + ) -> Result> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5448,9 +5271,9 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(row.map(|r| { - pinakes_sync::SyncDevice { - id: pinakes_sync::DeviceId(r.get(0)), - user_id: pinakes_types::model::UserId(r.get(1)), + 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), @@ -5468,7 +5291,7 @@ impl StorageBackend for PostgresBackend { async fn list_user_devices( &self, user_id: crate::users::UserId, - ) -> Result> { + ) -> Result> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5489,9 +5312,9 @@ impl StorageBackend for PostgresBackend { rows .iter() .map(|r| { - pinakes_sync::SyncDevice { - id: pinakes_sync::DeviceId(r.get(0)), - user_id: pinakes_types::model::UserId(r.get(1)), + 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), @@ -5510,7 +5333,7 @@ impl StorageBackend for PostgresBackend { async fn update_device( &self, - device: &pinakes_sync::SyncDevice, + device: &crate::sync::SyncDevice, ) -> Result<()> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) @@ -5542,7 +5365,7 @@ impl StorageBackend for PostgresBackend { Ok(()) } - async fn delete_device(&self, id: pinakes_sync::DeviceId) -> Result<()> { + 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}")) })?; @@ -5555,7 +5378,7 @@ impl StorageBackend for PostgresBackend { Ok(()) } - async fn touch_device(&self, id: pinakes_sync::DeviceId) -> Result<()> { + 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}")) })?; @@ -5575,7 +5398,7 @@ impl StorageBackend for PostgresBackend { async fn record_sync_change( &self, - change: &pinakes_sync::SyncLogEntry, + change: &crate::sync::SyncLogEntry, ) -> Result<()> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) @@ -5621,7 +5444,7 @@ impl StorageBackend for PostgresBackend { &self, cursor: i64, limit: u64, - ) -> Result> { + ) -> Result> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5640,13 +5463,13 @@ impl StorageBackend for PostgresBackend { rows .iter() .map(|r| { - pinakes_sync::SyncLogEntry { + crate::sync::SyncLogEntry { id: r.get(0), sequence: r.get(1), change_type: r .get::<_, String>(2) .parse() - .unwrap_or(pinakes_sync::SyncChangeType::Modified), + .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), @@ -5656,7 +5479,7 @@ impl StorageBackend for PostgresBackend { metadata_json: r.get(7), changed_by_device: r .get::<_, Option>(8) - .map(pinakes_sync::DeviceId), + .map(crate::sync::DeviceId), timestamp: r.get(9), } }) @@ -5695,9 +5518,9 @@ impl StorageBackend for PostgresBackend { async fn get_device_sync_state( &self, - device_id: pinakes_sync::DeviceId, + device_id: crate::sync::DeviceId, path: &str, - ) -> Result> { + ) -> Result> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5714,8 +5537,8 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(row.map(|r| { - pinakes_sync::DeviceSyncState { - device_id: pinakes_sync::DeviceId(r.get(0)), + 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), @@ -5724,7 +5547,7 @@ impl StorageBackend for PostgresBackend { sync_status: r .get::<_, String>(6) .parse() - .unwrap_or(pinakes_sync::FileSyncStatus::Synced), + .unwrap_or(crate::sync::FileSyncStatus::Synced), last_synced_at: r.get(7), conflict_info_json: r.get(8), } @@ -5733,7 +5556,7 @@ impl StorageBackend for PostgresBackend { async fn upsert_device_sync_state( &self, - state: &pinakes_sync::DeviceSyncState, + state: &crate::sync::DeviceSyncState, ) -> Result<()> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) @@ -5774,8 +5597,8 @@ impl StorageBackend for PostgresBackend { async fn list_pending_sync( &self, - device_id: pinakes_sync::DeviceId, - ) -> Result> { + device_id: crate::sync::DeviceId, + ) -> Result> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5797,8 +5620,8 @@ impl StorageBackend for PostgresBackend { rows .iter() .map(|r| { - pinakes_sync::DeviceSyncState { - device_id: pinakes_sync::DeviceId(r.get(0)), + 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), @@ -5807,7 +5630,7 @@ impl StorageBackend for PostgresBackend { sync_status: r .get::<_, String>(6) .parse() - .unwrap_or(pinakes_sync::FileSyncStatus::Synced), + .unwrap_or(crate::sync::FileSyncStatus::Synced), last_synced_at: r.get(7), conflict_info_json: r.get(8), } @@ -5818,7 +5641,7 @@ impl StorageBackend for PostgresBackend { async fn create_upload_session( &self, - session: &pinakes_sync::UploadSession, + session: &crate::sync::UploadSession, ) -> Result<()> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) @@ -5854,7 +5677,7 @@ impl StorageBackend for PostgresBackend { async fn get_upload_session( &self, id: Uuid, - ) -> Result { + ) -> Result { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5871,9 +5694,9 @@ impl StorageBackend for PostgresBackend { .await .map_err(|e| PinakesError::Database(e.to_string()))?; - Ok(pinakes_sync::UploadSession { + Ok(crate::sync::UploadSession { id: row.get(0), - device_id: pinakes_sync::DeviceId(row.get(1)), + 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).cast_unsigned(), @@ -5882,7 +5705,7 @@ impl StorageBackend for PostgresBackend { status: row .get::<_, String>(7) .parse() - .unwrap_or(pinakes_sync::UploadStatus::Pending), + .unwrap_or(crate::sync::UploadStatus::Pending), created_at: row.get(8), expires_at: row.get(9), last_activity: row.get(10), @@ -5891,7 +5714,7 @@ impl StorageBackend for PostgresBackend { async fn update_upload_session( &self, - session: &pinakes_sync::UploadSession, + session: &crate::sync::UploadSession, ) -> Result<()> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) @@ -5916,7 +5739,7 @@ impl StorageBackend for PostgresBackend { async fn record_chunk( &self, upload_id: Uuid, - chunk: &pinakes_sync::ChunkInfo, + chunk: &crate::sync::ChunkInfo, ) -> Result<()> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) @@ -5948,7 +5771,7 @@ impl StorageBackend for PostgresBackend { async fn get_upload_chunks( &self, upload_id: Uuid, - ) -> Result> { + ) -> Result> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5966,7 +5789,7 @@ impl StorageBackend for PostgresBackend { rows .iter() .map(|r| { - pinakes_sync::ChunkInfo { + crate::sync::ChunkInfo { upload_id: r.get(0), chunk_index: r.get::<_, i64>(1).cast_unsigned(), offset: r.get::<_, i64>(2).cast_unsigned(), @@ -5995,7 +5818,7 @@ impl StorageBackend for PostgresBackend { async fn record_conflict( &self, - conflict: &pinakes_sync::SyncConflict, + conflict: &crate::sync::SyncConflict, ) -> Result<()> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) @@ -6026,8 +5849,8 @@ impl StorageBackend for PostgresBackend { async fn get_unresolved_conflicts( &self, - device_id: pinakes_sync::DeviceId, - ) -> Result> { + device_id: crate::sync::DeviceId, + ) -> Result> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -6048,9 +5871,9 @@ impl StorageBackend for PostgresBackend { rows .iter() .map(|r| { - pinakes_sync::SyncConflict { + crate::sync::SyncConflict { id: r.get(0), - device_id: pinakes_sync::DeviceId(r.get(1)), + device_id: crate::sync::DeviceId(r.get(1)), path: r.get(2), local_hash: r.get(3), local_mtime: r.get(4), @@ -6061,17 +5884,15 @@ impl StorageBackend for PostgresBackend { resolution: r.get::<_, Option>(9).and_then(|s| { match s.as_str() { "server_wins" => { - Some(pinakes_types::config::ConflictResolution::ServerWins) + Some(crate::config::ConflictResolution::ServerWins) }, "client_wins" => { - Some(pinakes_types::config::ConflictResolution::ClientWins) + Some(crate::config::ConflictResolution::ClientWins) }, "keep_both" => { - Some(pinakes_types::config::ConflictResolution::KeepBoth) - }, - "manual" => { - Some(pinakes_types::config::ConflictResolution::Manual) + Some(crate::config::ConflictResolution::KeepBoth) }, + "manual" => Some(crate::config::ConflictResolution::Manual), _ => None, } }), @@ -6084,7 +5905,7 @@ impl StorageBackend for PostgresBackend { async fn resolve_conflict( &self, id: Uuid, - resolution: pinakes_types::config::ConflictResolution, + resolution: crate::config::ConflictResolution, ) -> Result<()> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) @@ -6092,10 +5913,10 @@ impl StorageBackend for PostgresBackend { let now = chrono::Utc::now(); let resolution_str = match resolution { - pinakes_types::config::ConflictResolution::ServerWins => "server_wins", - pinakes_types::config::ConflictResolution::ClientWins => "client_wins", - pinakes_types::config::ConflictResolution::KeepBoth => "keep_both", - pinakes_types::config::ConflictResolution::Manual => "manual", + crate::config::ConflictResolution::ServerWins => "server_wins", + crate::config::ConflictResolution::ClientWins => "client_wins", + crate::config::ConflictResolution::KeepBoth => "keep_both", + crate::config::ConflictResolution::Manual => "manual", }; client @@ -7481,8 +7302,7 @@ impl PostgresBackend { user_id = $1", &[&user_id], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; Ok(row.map_or_else( || { crate::users::UserProfile { @@ -7517,8 +7337,7 @@ impl PostgresBackend { deleted_at IS NULL", &[], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let total_media: i64 = row.get(0); let total_size: i64 = row.get(1); let avg_size = if total_media > 0 { @@ -7533,8 +7352,7 @@ impl PostgresBackend { NULL GROUP BY media_type ORDER BY COUNT(*) DESC", &[], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let media_by_type: Vec<(String, u64)> = rows .iter() .map(|r| { @@ -7551,8 +7369,7 @@ impl PostgresBackend { DESC", &[], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let storage_by_type: Vec<(String, u64)> = rows .iter() .map(|r| { @@ -7568,8 +7385,7 @@ impl PostgresBackend { ORDER BY created_at DESC LIMIT 1", &[], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? + .await? .map(|r| r.get(0)); let oldest: Option = client .query_opt( @@ -7577,8 +7393,7 @@ impl PostgresBackend { ORDER BY created_at ASC LIMIT 1", &[], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? + .await? .map(|r| r.get(0)); let rows = client @@ -7587,8 +7402,7 @@ impl PostgresBackend { mt.tag_id = t.id GROUP BY t.id, t.name ORDER BY cnt DESC LIMIT 10", &[], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let top_tags: Vec<(String, u64)> = rows .iter() .map(|r| { @@ -7605,8 +7419,7 @@ impl PostgresBackend { BY cnt DESC LIMIT 10", &[], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))?; + .await?; let top_collections: Vec<(String, u64)> = rows .iter() .map(|r| { @@ -7618,13 +7431,11 @@ impl PostgresBackend { let total_tags: i64 = client .query_one("SELECT COUNT(*) FROM tags", &[]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? + .await? .get(0); let total_collections: i64 = client .query_one("SELECT COUNT(*) FROM collections", &[]) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? + .await? .get(0); let total_duplicates: i64 = client .query_one( @@ -7632,8 +7443,7 @@ impl PostgresBackend { content_hash HAVING COUNT(*) > 1) sub", &[], ) - .await - .map_err(|e| PinakesError::Database(e.to_string()))? + .await? .get(0); Ok(super::LibraryStatistics { diff --git a/crates/pinakes-core/src/storage/sqlite.rs b/crates/pinakes-core/src/storage/sqlite.rs index f11f2ee..ecc1b00 100644 --- a/crates/pinakes-core/src/storage/sqlite.rs +++ b/crates/pinakes-core/src/storage/sqlite.rs @@ -9,7 +9,7 @@ use rustc_hash::FxHashMap; use uuid::Uuid; use crate::{ - error::{PinakesError, Result, db_ctx}, + error::{PinakesError, Result}, media_type::MediaType, model::{ AuditAction, @@ -58,8 +58,7 @@ impl SqliteBackend { /// /// Returns an error if the database cannot be opened or configured. pub fn new(path: &Path) -> Result { - let conn = Connection::open(path) - .map_err(|e| PinakesError::Database(e.to_string()))?; + let conn = Connection::open(path)?; Self::configure(conn) } @@ -70,15 +69,13 @@ impl SqliteBackend { /// Returns an error if the in-memory database cannot be created or /// configured. pub fn in_memory() -> Result { - let conn = Connection::open_in_memory() - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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;") - .map_err(|e| PinakesError::Database(e.to_string()))?; + .execute_batch("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;")?; Ok(Self { conn: Arc::new(Mutex::new(conn)), }) @@ -255,6 +252,7 @@ fn row_to_audit_entry(row: &Row) -> rusqlite::Result { let action = match action_str.as_str() { "imported" => AuditAction::Imported, + "updated" => AuditAction::Updated, "deleted" => AuditAction::Deleted, "tagged" => AuditAction::Tagged, "untagged" => AuditAction::Untagged, @@ -694,8 +692,7 @@ impl StorageBackend for SqliteBackend { db.execute( "INSERT OR IGNORE INTO root_dirs (path) VALUES (?1)", params![path.to_string_lossy().as_ref()], - ) - .map_err(db_ctx("insert_root_dirs", "path"))?; + )?; } Ok(()) }) @@ -712,18 +709,16 @@ impl StorageBackend for SqliteBackend { 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") - .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)) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? - .collect::>>() - .map_err(|e| PinakesError::Database(e.to_string()))?; + })? + .collect::>>()?; drop(stmt); + drop(db); rows }; Ok(rows) @@ -743,8 +738,7 @@ impl StorageBackend for SqliteBackend { .map_err(|e| PinakesError::Database(e.to_string()))?; db.execute("DELETE FROM root_dirs WHERE path = ?1", params![ path.to_string_lossy().as_ref() - ]) - .map_err(|e| PinakesError::Database(e.to_string()))?; + ])?; } Ok(()) }) @@ -800,7 +794,7 @@ impl StorageBackend for SqliteBackend { item.updated_at.to_rfc3339(), ], ) - .map_err(db_ctx("insert_media", &item.id))?; + .map_err(crate::error::db_ctx("insert_media", &item.id))?; } Ok(()) }) @@ -815,13 +809,11 @@ impl StorageBackend for SqliteBackend { 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), - ) - .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), + )?; count }; Ok(count.cast_unsigned()) @@ -837,17 +829,15 @@ impl StorageBackend for SqliteBackend { 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", - ) - .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| { @@ -855,12 +845,12 @@ impl StorageBackend for SqliteBackend { rusqlite::Error::QueryReturnedNoRows => { PinakesError::NotFound(format!("media item {id}")) }, - other => PinakesError::Database(other.to_string()), + other => PinakesError::from(other), } })?; drop(stmt); - item.custom_fields = load_custom_fields_sync(&db, item.id) - .map_err(|e| PinakesError::Database(e.to_string()))?; + item.custom_fields = load_custom_fields_sync(&db, item.id)?; + drop(db); item }; Ok(item) @@ -880,27 +870,25 @@ impl StorageBackend for SqliteBackend { 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", - ) - .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() - .map_err(|e| PinakesError::Database(e.to_string()))?; + .optional()?; drop(stmt); if let Some(mut item) = result { - item.custom_fields = load_custom_fields_sync(&db, item.id) - .map_err(|e| PinakesError::Database(e.to_string()))?; + item.custom_fields = load_custom_fields_sync(&db, item.id)?; + drop(db); Some(item) } else { + drop(db); None } }; @@ -921,27 +909,25 @@ impl StorageBackend for SqliteBackend { 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", - ) - .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() - .map_err(|e| PinakesError::Database(e.to_string()))?; + .optional()?; drop(stmt); if let Some(mut item) = result { - item.custom_fields = load_custom_fields_sync(&db, item.id) - .map_err(|e| PinakesError::Database(e.to_string()))?; + item.custom_fields = load_custom_fields_sync(&db, item.id)?; + drop(db); Some(item) } else { + drop(db); None } }; @@ -982,9 +968,7 @@ impl StorageBackend for SqliteBackend { 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) - .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare(&sql)?; let mut rows = stmt .query_map( params![ @@ -992,13 +976,11 @@ impl StorageBackend for SqliteBackend { pagination.offset.cast_signed() ], row_to_media_item, - ) - .map_err(|e| PinakesError::Database(e.to_string()))? - .collect::>>() - .map_err(|e| PinakesError::Database(e.to_string()))?; + )? + .collect::>>()?; drop(stmt); - load_custom_fields_batch(&db, &mut rows) - .map_err(|e| PinakesError::Database(e.to_string()))?; + load_custom_fields_batch(&db, &mut rows)?; + drop(db); rows }; Ok(rows) @@ -1053,7 +1035,8 @@ impl StorageBackend for SqliteBackend { item.updated_at.to_rfc3339(), ], ) - .map_err(db_ctx("update_media", &item.id))?; + .map_err(crate::error::db_ctx("update_media", &item.id))?; + drop(db); if changed == 0 { return Err(PinakesError::NotFound(format!( "media item {}", @@ -1078,7 +1061,8 @@ impl StorageBackend for SqliteBackend { .execute("DELETE FROM media_items WHERE id = ?1", params![ id.0.to_string() ]) - .map_err(db_ctx("delete_media", id))?; + .map_err(crate::error::db_ctx("delete_media", id))?; + drop(db); if changed == 0 { return Err(PinakesError::NotFound(format!("media item {id}"))); } @@ -1096,14 +1080,11 @@ impl StorageBackend for SqliteBackend { 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::<_, i64>(0) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? - .cast_unsigned(); - db.execute("DELETE FROM media_items", []) - .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", [])?; count }; Ok(count) @@ -1136,7 +1117,8 @@ impl StorageBackend for SqliteBackend { now.to_rfc3339(), ], ) - .map_err(db_ctx("create_tag", &name))?; + .map_err(crate::error::db_ctx("create_tag", &name))?; + drop(db); Tag { id, name, @@ -1157,11 +1139,9 @@ impl StorageBackend for SqliteBackend { 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", - ) - .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", + )?; let tag = stmt .query_row(params![id.to_string()], row_to_tag) .map_err(|e| { @@ -1169,10 +1149,11 @@ impl StorageBackend for SqliteBackend { rusqlite::Error::QueryReturnedNoRows => { PinakesError::TagNotFound(id.to_string()) }, - other => PinakesError::Database(other.to_string()), + other => PinakesError::from(other), } })?; drop(stmt); + drop(db); tag }; Ok(tag) @@ -1188,17 +1169,14 @@ impl StorageBackend for SqliteBackend { 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", - ) - .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) - .map_err(|e| PinakesError::Database(e.to_string()))? - .collect::>>() - .map_err(|e| PinakesError::Database(e.to_string()))?; + .query_map([], row_to_tag)? + .collect::>>()?; drop(stmt); + drop(db); rows }; Ok(rows) @@ -1216,7 +1194,8 @@ impl StorageBackend for SqliteBackend { .map_err(|e| PinakesError::Database(e.to_string()))?; let changed = db .execute("DELETE FROM tags WHERE id = ?1", params![id.to_string()]) - .map_err(db_ctx("delete_tag", id))?; + .map_err(crate::error::db_ctx("delete_tag", id))?; + drop(db); if changed == 0 { return Err(PinakesError::TagNotFound(id.to_string())); } @@ -1238,7 +1217,10 @@ impl StorageBackend for SqliteBackend { "INSERT OR IGNORE INTO media_tags (media_id, tag_id) VALUES (?1, ?2)", params![media_id.0.to_string(), tag_id.to_string()], ) - .map_err(db_ctx("tag_media", format!("{media_id} x {tag_id}")))?; + .map_err(crate::error::db_ctx( + "tag_media", + format!("{media_id} x {tag_id}"), + ))?; } Ok(()) }) @@ -1257,7 +1239,10 @@ impl StorageBackend for SqliteBackend { "DELETE FROM media_tags WHERE media_id = ?1 AND tag_id = ?2", params![media_id.0.to_string(), tag_id.to_string()], ) - .map_err(db_ctx("untag_media", format!("{media_id} x {tag_id}")))?; + .map_err(crate::error::db_ctx( + "untag_media", + format!("{media_id} x {tag_id}"), + ))?; } Ok(()) }) @@ -1272,19 +1257,16 @@ impl StorageBackend for SqliteBackend { 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", - ) - .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) - .map_err(|e| PinakesError::Database(e.to_string()))? - .collect::>>() - .map_err(|e| PinakesError::Database(e.to_string()))?; + .query_map(params![media_id.0.to_string()], row_to_tag)? + .collect::>>()?; drop(stmt); + drop(db); rows }; Ok(rows) @@ -1300,21 +1282,18 @@ impl StorageBackend for SqliteBackend { 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", - ) - .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) - .map_err(|e| PinakesError::Database(e.to_string()))? - .collect::>>() - .map_err(|e| PinakesError::Database(e.to_string()))?; + .query_map(params![tag_id.to_string()], row_to_tag)? + .collect::>>()?; drop(stmt); + drop(db); rows }; Ok(rows) @@ -1355,7 +1334,8 @@ impl StorageBackend for SqliteBackend { now.to_rfc3339(), ], ) - .map_err(db_ctx("create_collection", &name))?; + .map_err(crate::error::db_ctx("create_collection", &name))?; + drop(db); Collection { id, name, @@ -1379,12 +1359,10 @@ impl StorageBackend for SqliteBackend { 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", - ) - .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", + )?; let collection = stmt .query_row(params![id.to_string()], row_to_collection) .map_err(|e| { @@ -1392,10 +1370,11 @@ impl StorageBackend for SqliteBackend { rusqlite::Error::QueryReturnedNoRows => { PinakesError::CollectionNotFound(id.to_string()) }, - other => PinakesError::Database(other.to_string()), + other => PinakesError::from(other), } })?; drop(stmt); + drop(db); collection }; Ok(collection) @@ -1411,18 +1390,15 @@ impl StorageBackend for SqliteBackend { 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", - ) - .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) - .map_err(|e| PinakesError::Database(e.to_string()))? - .collect::>>() - .map_err(|e| PinakesError::Database(e.to_string()))?; + .query_map([], row_to_collection)? + .collect::>>()?; drop(stmt); + drop(db); rows }; Ok(rows) @@ -1442,7 +1418,8 @@ impl StorageBackend for SqliteBackend { .execute("DELETE FROM collections WHERE id = ?1", params![ id.to_string() ]) - .map_err(db_ctx("delete_collection", id))?; + .map_err(crate::error::db_ctx("delete_collection", id))?; + drop(db); if changed == 0 { return Err(PinakesError::CollectionNotFound(id.to_string())); } @@ -1476,7 +1453,7 @@ impl StorageBackend for SqliteBackend { now.to_rfc3339(), ], ) - .map_err(db_ctx( + .map_err(crate::error::db_ctx( "add_to_collection", format!("{collection_id} <- {media_id}"), ))?; @@ -1503,7 +1480,7 @@ impl StorageBackend for SqliteBackend { media_id = ?2", params![collection_id.to_string(), media_id.0.to_string()], ) - .map_err(db_ctx( + .map_err(crate::error::db_ctx( "remove_from_collection", format!("{collection_id} <- {media_id}"), ))?; @@ -1524,27 +1501,23 @@ impl StorageBackend for SqliteBackend { 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", - ) - .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) - .map_err(|e| PinakesError::Database(e.to_string()))? - .collect::>>() - .map_err(|e| PinakesError::Database(e.to_string()))?; + .query_map(params![collection_id.to_string()], row_to_media_item)? + .collect::>>()?; drop(stmt); - load_custom_fields_batch(&db, &mut rows) - .map_err(|e| PinakesError::Database(e.to_string()))?; + load_custom_fields_batch(&db, &mut rows)?; + drop(db); rows }; Ok(rows) @@ -1611,21 +1584,16 @@ impl StorageBackend for SqliteBackend { all_params.push(request.pagination.limit.to_string()); all_params.push(request.pagination.offset.to_string()); - let mut stmt = db - .prepare(&sql) - .map_err(|e| PinakesError::Database(e.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) - .map_err(|e| PinakesError::Database(e.to_string()))? - .collect::>>() - .map_err(|e| PinakesError::Database(e.to_string()))?; + .query_map(param_refs.as_slice(), row_to_media_item)? + .collect::>>()?; drop(stmt); - load_custom_fields_batch(&db, &mut items) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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 "); @@ -1651,9 +1619,11 @@ impl StorageBackend for SqliteBackend { .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)) - .map_err(|e| PinakesError::Database(e.to_string()))?; + let total_count: i64 = + db.query_row(&count_sql, count_param_refs.as_slice(), |row| { + row.get(0) + })?; + drop(db); SearchResults { items, @@ -1684,8 +1654,7 @@ impl StorageBackend for SqliteBackend { entry.details, entry.timestamp.to_rfc3339(), ], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; } Ok(()) }) @@ -1725,9 +1694,7 @@ impl StorageBackend for SqliteBackend { }, ); - let mut stmt = db - .prepare(&sql) - .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare(&sql)?; let rows = if let Some(ref mid_str) = bind_media_id { stmt .query_map( @@ -1737,10 +1704,8 @@ impl StorageBackend for SqliteBackend { pagination.offset.cast_signed() ], row_to_audit_entry, - ) - .map_err(|e| PinakesError::Database(e.to_string()))? - .collect::>>() - .map_err(|e| PinakesError::Database(e.to_string()))? + )? + .collect::>>()? } else { stmt .query_map( @@ -1749,12 +1714,11 @@ impl StorageBackend for SqliteBackend { pagination.offset.cast_signed() ], row_to_audit_entry, - ) - .map_err(|e| PinakesError::Database(e.to_string()))? - .collect::>>() - .map_err(|e| PinakesError::Database(e.to_string()))? + )? + .collect::>>()? }; drop(stmt); + drop(db); rows }; @@ -1787,8 +1751,7 @@ impl StorageBackend for SqliteBackend { custom_field_type_to_str(field.field_type), field.value, ], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; } Ok(()) }) @@ -1806,31 +1769,27 @@ impl StorageBackend for SqliteBackend { 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", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; - 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, - })) - }) - .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 = FxHashMap::default(); for r in rows { - let (name, field) = - r.map_err(|e| PinakesError::Database(e.to_string()))?; + let (name, field) = r?; map.insert(name, field); } drop(stmt); + drop(db); map }; Ok(map) @@ -1854,8 +1813,7 @@ impl StorageBackend for SqliteBackend { db.execute( "DELETE FROM custom_fields WHERE media_id = ?1 AND field_name = ?2", params![media_id.0.to_string(), name], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; } Ok(()) }) @@ -1879,7 +1837,7 @@ impl StorageBackend for SqliteBackend { let ctx = format!("{n} items"); let tx = db .unchecked_transaction() - .map_err(db_ctx("batch_delete_media", &ctx))?; + .map_err(crate::error::db_ctx("batch_delete_media", &ctx))?; let mut count = 0u64; for chunk in ids.chunks(CHUNK_SIZE) { let placeholders: Vec = @@ -1892,10 +1850,11 @@ impl StorageBackend for SqliteBackend { chunk.iter().map(|s| s as &dyn rusqlite::ToSql).collect(); let rows = tx .execute(&sql, params.as_slice()) - .map_err(db_ctx("batch_delete_media", &ctx))?; + .map_err(crate::error::db_ctx("batch_delete_media", &ctx))?; count += rows as u64; } - tx.commit().map_err(db_ctx("batch_delete_media", &ctx))?; + tx.commit() + .map_err(crate::error::db_ctx("batch_delete_media", &ctx))?; count }; Ok(count) @@ -1927,25 +1886,26 @@ impl StorageBackend for SqliteBackend { let ctx = format!("{} media x {} tags", media_ids.len(), tag_ids.len()); let tx = db .unchecked_transaction() - .map_err(db_ctx("batch_tag_media", &ctx))?; + .map_err(crate::error::db_ctx("batch_tag_media", &ctx))?; // Prepare statement once for reuse let mut stmt = tx .prepare_cached( "INSERT OR IGNORE INTO media_tags (media_id, tag_id) VALUES (?1, \ ?2)", ) - .map_err(db_ctx("batch_tag_media", &ctx))?; + .map_err(crate::error::db_ctx("batch_tag_media", &ctx))?; let mut count = 0u64; for mid in &media_ids { for tid in &tag_ids { let rows = stmt .execute(params![mid, tid]) - .map_err(db_ctx("batch_tag_media", &ctx))?; + .map_err(crate::error::db_ctx("batch_tag_media", &ctx))?; count += rows as u64; // INSERT OR IGNORE: rows=1 if new, 0 if existed } } drop(stmt); - tx.commit().map_err(db_ctx("batch_tag_media", &ctx))?; + tx.commit() + .map_err(crate::error::db_ctx("batch_tag_media", &ctx))?; count }; Ok(count) @@ -2031,7 +1991,7 @@ impl StorageBackend for SqliteBackend { let ctx = format!("{} items", ids.len()); let tx = db .unchecked_transaction() - .map_err(db_ctx("batch_update_media", &ctx))?; + .map_err(crate::error::db_ctx("batch_update_media", &ctx))?; let mut count = 0u64; for chunk in ids.chunks(CHUNK_SIZE) { @@ -2051,11 +2011,12 @@ impl StorageBackend for SqliteBackend { let rows = tx .execute(&sql, all_params.as_slice()) - .map_err(db_ctx("batch_update_media", &ctx))?; + .map_err(crate::error::db_ctx("batch_update_media", &ctx))?; count += rows as u64; } - tx.commit().map_err(db_ctx("batch_update_media", &ctx))?; + tx.commit() + .map_err(crate::error::db_ctx("batch_update_media", &ctx))?; count }; Ok(count) @@ -2071,24 +2032,19 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db - .prepare( - "SELECT * FROM media_items WHERE deleted_at IS NULL AND \ - content_hash IN ( + let mut stmt = db.prepare( + "SELECT * FROM media_items WHERE deleted_at IS NULL AND \ + content_hash IN ( SELECT content_hash FROM media_items WHERE deleted_at IS \ - NULL + NULL GROUP BY content_hash HAVING COUNT(*) > 1 ) ORDER BY content_hash, created_at", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; let mut rows: Vec = stmt - .query_map([], row_to_media_item) - .map_err(|e| PinakesError::Database(e.to_string()))? - .collect::>>() - .map_err(|e| PinakesError::Database(e.to_string()))?; + .query_map([], row_to_media_item)? + .collect::>>()?; - load_custom_fields_batch(&db, &mut rows) - .map_err(|e| PinakesError::Database(e.to_string()))?; + load_custom_fields_batch(&db, &mut rows)?; // Group by content_hash let mut groups: Vec> = Vec::new(); @@ -2123,20 +2079,15 @@ impl StorageBackend for SqliteBackend { .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", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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) - .map_err(|e| PinakesError::Database(e.to_string()))? - .collect::>>() - .map_err(|e| PinakesError::Database(e.to_string()))?; + .query_map([], row_to_media_item)? + .collect::>>()?; - load_custom_fields_batch(&db, &mut items) - .map_err(|e| PinakesError::Database(e.to_string()))?; + load_custom_fields_batch(&db, &mut items)?; items }; @@ -2208,24 +2159,22 @@ impl StorageBackend for SqliteBackend { 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)) - .map_err(|e| PinakesError::Database(e.to_string()))?; - let tag_count: i64 = db - .query_row("SELECT COUNT(*) FROM tags", [], |row| row.get(0)) - .map_err(|e| PinakesError::Database(e.to_string()))?; - let collection_count: i64 = db - .query_row("SELECT COUNT(*) FROM collections", [], |row| row.get(0)) - .map_err(|e| PinakesError::Database(e.to_string()))?; - let audit_count: i64 = db - .query_row("SELECT COUNT(*) FROM audit_log", [], |row| row.get(0)) - .map_err(|e| PinakesError::Database(e.to_string()))?; - let page_count: i64 = db - .query_row("PRAGMA page_count", [], |row| row.get(0)) - .map_err(|e| PinakesError::Database(e.to_string()))?; - let page_size: i64 = db - .query_row("PRAGMA page_size", [], |row| row.get(0)) - .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).cast_unsigned(); crate::storage::DatabaseStats { media_count: media_count.cast_unsigned(), @@ -2249,8 +2198,7 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - db.execute_batch("VACUUM") - .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute_batch("VACUUM")?; } Ok(()) }) @@ -2273,8 +2221,7 @@ impl StorageBackend for SqliteBackend { DELETE FROM media_items; DELETE FROM tags; DELETE FROM collections;", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; } Ok(()) }) @@ -2291,28 +2238,24 @@ impl StorageBackend for SqliteBackend { 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 WHERE deleted_at \ - IS NULL", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; - 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), - )) - }) - .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare( + "SELECT id, path, content_hash FROM media_items WHERE deleted_at IS \ + NULL", + )?; + 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.map_err(|e| PinakesError::Database(e.to_string()))?); + results.push(row?); } results }; @@ -2344,8 +2287,7 @@ impl StorageBackend for SqliteBackend { "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], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; } Ok(()) }) @@ -2362,32 +2304,28 @@ impl StorageBackend for SqliteBackend { 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", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; - 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 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), }) - .map_err(|e| PinakesError::Database(e.to_string()))?; + })?; let mut results = Vec::new(); for row in rows { - results.push(row.map_err(|e| PinakesError::Database(e.to_string()))?); + results.push(row?); } results }; @@ -2451,8 +2389,9 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - db.execute("DELETE FROM saved_searches WHERE id = ?1", params![id_str]) - .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute("DELETE FROM saved_searches WHERE id = ?1", params![ + id_str + ])?; } Ok(()) }) @@ -2476,23 +2415,14 @@ impl StorageBackend for SqliteBackend { } else { "SELECT id FROM media_items ORDER BY created_at DESC" }; - let mut stmt = db - .prepare(sql) - .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare(sql)?; let ids: Vec = stmt .query_map([], |r| { let s: String = r.get(0)?; - uuid::Uuid::parse_str(&s).map(MediaId).map_err(|e| { - rusqlite::Error::FromSqlConversionFailure( - 0, - rusqlite::types::Type::Text, - Box::new(e), - ) - }) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? - .collect::, _>>() - .map_err(|e| PinakesError::Database(e.to_string()))?; + Ok(MediaId(uuid::Uuid::parse_str(&s).unwrap_or_default())) + })? + .filter_map(std::result::Result::ok) + .collect(); Ok(ids) }) .await @@ -2510,49 +2440,32 @@ impl StorageBackend for SqliteBackend { .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let total_media: u64 = db - .query_row("SELECT COUNT(*) FROM media_items", [], |r| { - r.get::<_, i64>(0) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? - .cast_unsigned(); - let total_size: u64 = db - .query_row( - "SELECT COALESCE(SUM(file_size), 0) FROM media_items", - [], - |r| r.get::<_, i64>(0), - ) - .map_err(|e| PinakesError::Database(e.to_string()))? - .cast_unsigned(); + 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); // Media count by type - let mut stmt = db - .prepare( - "SELECT media_type, COUNT(*) FROM media_items GROUP BY media_type \ - ORDER BY COUNT(*) DESC", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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::<_, i64>(1)?.cast_unsigned())) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? + .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, u64>(1)?)))? .filter_map(std::result::Result::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", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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::<_, i64>(1)?.cast_unsigned())) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? + .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, u64>(1)?)))? .filter_map(std::result::Result::ok) .collect(); @@ -2563,69 +2476,48 @@ impl StorageBackend for SqliteBackend { [], |r| r.get(0), ) - .optional() - .map_err(|e| PinakesError::Database(e.to_string()))?; + .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() - .map_err(|e| PinakesError::Database(e.to_string()))?; + .optional()?; // 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", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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::<_, i64>(1)?.cast_unsigned())) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? + .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, u64>(1)?)))? .filter_map(std::result::Result::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", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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::<_, i64>(1)?.cast_unsigned())) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? + .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, u64>(1)?)))? .filter_map(std::result::Result::ok) .collect(); - let total_tags: u64 = db - .query_row("SELECT COUNT(*) FROM tags", [], |r| r.get::<_, i64>(0)) - .map_err(|e| PinakesError::Database(e.to_string()))? - .cast_unsigned(); - let total_collections: u64 = db - .query_row("SELECT COUNT(*) FROM collections", [], |r| { - r.get::<_, i64>(0) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? - .cast_unsigned(); + 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::<_, i64>(0), - ) - .map_err(|e| PinakesError::Database(e.to_string()))? - .cast_unsigned(); + 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, @@ -2656,12 +2548,10 @@ impl StorageBackend for SqliteBackend { 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", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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)?; @@ -2684,10 +2574,8 @@ impl StorageBackend for SqliteBackend { .unwrap_or_else(|_| chrono::Utc::now().into()) .with_timezone(&chrono::Utc), }) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? - .collect::, _>>() - .map_err(|e| PinakesError::Database(e.to_string()))?; + })? + .collect::, _>>()?; Ok(users) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -2739,8 +2627,7 @@ impl StorageBackend for SqliteBackend { }) }, ) - .optional() - .map_err(|e| 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) @@ -2794,8 +2681,7 @@ impl StorageBackend for SqliteBackend { }) }, ) - .optional() - .map_err(|e| PinakesError::Database(e.to_string()))?; + .optional()?; opt.ok_or_else(|| { PinakesError::NotFound(format!("user with username {username}")) }) @@ -2832,9 +2718,7 @@ impl StorageBackend for SqliteBackend { )) })?; - let tx = db - .unchecked_transaction() - .map_err(|e| PinakesError::Database(e.to_string()))?; + let tx = db.unchecked_transaction()?; let id = crate::users::UserId(uuid::Uuid::now_v7()); let id_str = id.0.to_string(); @@ -2854,8 +2738,7 @@ impl StorageBackend for SqliteBackend { now.to_rfc3339(), now.to_rfc3339() ], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; let user_profile = if let Some(prof) = profile.clone() { let prefs_json = @@ -2876,19 +2759,17 @@ impl StorageBackend for SqliteBackend { now.to_rfc3339(), now.to_rfc3339() ], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; prof } else { crate::users::UserProfile { avatar_path: None, bio: None, - preferences: crate::users::UserPreferences::default(), + preferences: Default::default(), } }; - tx.commit() - .map_err(|e| PinakesError::Database(e.to_string()))?; + tx.commit()?; Ok(crate::users::User { id, @@ -2928,9 +2809,7 @@ impl StorageBackend for SqliteBackend { )) })?; - let tx = db - .unchecked_transaction() - .map_err(|e| PinakesError::Database(e.to_string()))?; + let tx = db.unchecked_transaction()?; let now = chrono::Utc::now(); // Update password and/or role if provided @@ -2959,8 +2838,7 @@ impl StorageBackend for SqliteBackend { format!("UPDATE users SET {} WHERE id = ?", updates.join(", ")); let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(std::convert::AsRef::as_ref).collect(); - tx.execute(&sql, param_refs.as_slice()) - .map_err(|e| PinakesError::Database(e.to_string()))?; + tx.execute(&sql, param_refs.as_slice())?; } // Update profile if provided @@ -2985,15 +2863,13 @@ impl StorageBackend for SqliteBackend { now.to_rfc3339(), now.to_rfc3339() ], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; } - tx.commit() - .map_err(|e| PinakesError::Database(e.to_string()))?; + tx.commit()?; // Fetch updated user - db.query_row( + Ok(db.query_row( "SELECT id, username, password_hash, role, created_at, updated_at \ FROM users WHERE id = ?", [&id_str], @@ -3019,8 +2895,7 @@ impl StorageBackend for SqliteBackend { .with_timezone(&chrono::Utc), }) }, - ) - .map_err(|e| PinakesError::Database(e.to_string())) + )?) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) .await @@ -3040,26 +2915,19 @@ impl StorageBackend for SqliteBackend { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let tx = db - .unchecked_transaction() - .map_err(|e| PinakesError::Database(e.to_string()))?; + let tx = db.unchecked_transaction()?; // Delete profile first due to foreign key - tx.execute("DELETE FROM user_profiles WHERE user_id = ?", [&id_str]) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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]) - .map_err(|e| PinakesError::Database(e.to_string()))?; + tx.execute("DELETE FROM user_libraries WHERE user_id = ?", [&id_str])?; // Delete user - let affected = tx - .execute("DELETE FROM users WHERE id = ?", [&id_str]) - .map_err(|e| PinakesError::Database(e.to_string()))?; + let affected = tx.execute("DELETE FROM users WHERE id = ?", [&id_str])?; if affected == 0 { return Err(PinakesError::NotFound(format!("user {id_str}"))); } - tx.commit() - .map_err(|e| PinakesError::Database(e.to_string()))?; + tx.commit()?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3082,12 +2950,10 @@ impl StorageBackend for SqliteBackend { 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 = ?", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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)?; @@ -3102,8 +2968,7 @@ impl StorageBackend for SqliteBackend { .unwrap_or_else(|_| chrono::Utc::now().into()) .with_timezone(&chrono::Utc), }) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? + })? .filter_map(std::result::Result::ok) .collect::>(); Ok(libraries) @@ -3146,8 +3011,7 @@ impl StorageBackend for SqliteBackend { &perm_str, now.to_rfc3339() ], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3177,8 +3041,7 @@ impl StorageBackend for SqliteBackend { db.execute( "DELETE FROM user_libraries WHERE user_id = ? AND root_path = ?", rusqlite::params![&user_id_str, &root_path], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3222,22 +3085,18 @@ impl StorageBackend for SqliteBackend { &review, now.to_rfc3339() ], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; // 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))) - }, - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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, @@ -3265,12 +3124,10 @@ impl StorageBackend for SqliteBackend { 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", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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)?; @@ -3285,8 +3142,7 @@ impl StorageBackend for SqliteBackend { review_text: row.get(4)?, created_at: parse_datetime(&created_str), }) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? + })? .filter_map(std::result::Result::ok) .collect(); Ok(ratings) @@ -3333,8 +3189,7 @@ impl StorageBackend for SqliteBackend { }) }, ) - .optional() - .map_err(|e| PinakesError::Database(e.to_string()))?; + .optional()?; Ok(result) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3352,8 +3207,7 @@ impl StorageBackend for SqliteBackend { 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]) - .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute("DELETE FROM ratings WHERE id = ?", [&id_str])?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3394,8 +3248,7 @@ impl StorageBackend for SqliteBackend { &text, now.to_rfc3339() ], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; Ok(crate::social::Comment { id, user_id, @@ -3423,12 +3276,10 @@ impl StorageBackend for SqliteBackend { 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", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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)?; @@ -3445,8 +3296,7 @@ impl StorageBackend for SqliteBackend { text: row.get(4)?, created_at: parse_datetime(&created_str), }) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? + })? .filter_map(std::result::Result::ok) .collect(); Ok(comments) @@ -3468,8 +3318,7 @@ impl StorageBackend for SqliteBackend { 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]) - .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute("DELETE FROM comments WHERE id = ?", [&id_str])?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3497,8 +3346,7 @@ impl StorageBackend for SqliteBackend { "INSERT OR IGNORE INTO favorites (user_id, media_id, created_at) \ VALUES (?, ?, ?)", params![&user_id_str, &media_id_str, now.to_rfc3339()], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3524,8 +3372,7 @@ impl StorageBackend for SqliteBackend { db.execute( "DELETE FROM favorites WHERE user_id = ? AND media_id = ?", params![&user_id_str, &media_id_str], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3549,23 +3396,19 @@ impl StorageBackend for SqliteBackend { 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 ?", - ) - .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.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) - .map_err(|e| PinakesError::Database(e.to_string()))? + .query_map(params![&user_id_str, limit, offset], row_to_media_item)? .filter_map(std::result::Result::ok) .collect(); - load_custom_fields_batch(&db, &mut items) - .map_err(|e| PinakesError::Database(e.to_string()))?; + load_custom_fields_batch(&db, &mut items)?; Ok(items) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3590,13 +3433,11 @@ impl StorageBackend for SqliteBackend { 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), - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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) @@ -3641,8 +3482,7 @@ impl StorageBackend for SqliteBackend { &expires_str, now.to_rfc3339() ], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; Ok(crate::social::ShareLink { id, media_id, @@ -3723,8 +3563,7 @@ impl StorageBackend for SqliteBackend { db.execute( "UPDATE share_links SET view_count = view_count + 1 WHERE token = ?", [&token], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3744,8 +3583,7 @@ impl StorageBackend for SqliteBackend { 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]) - .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute("DELETE FROM share_links WHERE id = ?", [&id_str])?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3794,8 +3632,7 @@ impl StorageBackend for SqliteBackend { now.to_rfc3339(), now.to_rfc3339() ], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; Ok(crate::playlists::Playlist { id, owner_id, @@ -3891,9 +3728,7 @@ impl StorageBackend for SqliteBackend { ) }, ); - let mut stmt = db - .prepare(&sql) - .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare(&sql)?; let rows = if let Some(ref p) = param { stmt .query_map([p], |row| { @@ -3912,8 +3747,7 @@ impl StorageBackend for SqliteBackend { created_at: parse_datetime(&created_str), updated_at: parse_datetime(&updated_str), }) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? + })? .filter_map(std::result::Result::ok) .collect() } else { @@ -3934,8 +3768,7 @@ impl StorageBackend for SqliteBackend { created_at: parse_datetime(&created_str), updated_at: parse_datetime(&updated_str), }) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? + })? .filter_map(std::result::Result::ok) .collect() }; @@ -3985,8 +3818,7 @@ impl StorageBackend for SqliteBackend { format!("UPDATE playlists SET {} WHERE id = ?", updates.join(", ")); let param_refs: Vec<&dyn rusqlite::ToSql> = sql_params.iter().map(std::convert::AsRef::as_ref).collect(); - db.execute(&sql, param_refs.as_slice()) - .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute(&sql, param_refs.as_slice())?; // Fetch updated db.query_row( "SELECT id, owner_id, name, description, is_public, is_smart, \ @@ -4034,8 +3866,7 @@ impl StorageBackend for SqliteBackend { 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]) - .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute("DELETE FROM playlists WHERE id = ?", [&id_str])?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4064,8 +3895,7 @@ impl StorageBackend for SqliteBackend { "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()], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4091,8 +3921,7 @@ impl StorageBackend for SqliteBackend { db.execute( "DELETE FROM playlist_items WHERE playlist_id = ? AND media_id = ?", params![&playlist_id_str, &media_id_str], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4115,22 +3944,18 @@ impl StorageBackend for SqliteBackend { 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", - ) - .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.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) - .map_err(|e| PinakesError::Database(e.to_string()))? + .query_map([&playlist_id_str], row_to_media_item)? .filter_map(std::result::Result::ok) .collect(); - load_custom_fields_batch(&db, &mut items) - .map_err(|e| PinakesError::Database(e.to_string()))?; + load_custom_fields_batch(&db, &mut items)?; Ok(items) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4160,8 +3985,7 @@ impl StorageBackend for SqliteBackend { "UPDATE playlist_items SET position = ? WHERE playlist_id = ? AND \ media_id = ?", params![new_position, &playlist_id_str, &media_id_str], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4200,8 +4024,7 @@ impl StorageBackend for SqliteBackend { &duration, &context ], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4246,9 +4069,7 @@ impl StorageBackend for SqliteBackend { context_json FROM usage_events {where_clause} ORDER BY timestamp \ DESC LIMIT ?" ); - let mut stmt = db - .prepare(&sql) - .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare(&sql)?; let param_refs: Vec<&dyn rusqlite::ToSql> = sql_params.iter().map(std::convert::AsRef::as_ref).collect(); let events = stmt @@ -4273,8 +4094,7 @@ impl StorageBackend for SqliteBackend { duration_secs: row.get(5)?, context_json: row.get(6)?, }) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? + })? .filter_map(std::result::Result::ok) .collect(); Ok(events) @@ -4293,30 +4113,26 @@ impl StorageBackend for SqliteBackend { 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 ?", - ) - .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.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.cast_signed()], |row| { let item = row_to_media_item(row)?; let count: i64 = row.get(16)?; Ok((item, count.cast_unsigned())) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? + })? .filter_map(std::result::Result::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) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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); } @@ -4341,26 +4157,22 @@ impl StorageBackend for SqliteBackend { 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 ?", - ) - .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.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.cast_signed()], row_to_media_item, - ) - .map_err(|e| PinakesError::Database(e.to_string()))? + )? .filter_map(std::result::Result::ok) .collect(); - load_custom_fields_batch(&db, &mut items) - .map_err(|e| PinakesError::Database(e.to_string()))?; + load_custom_fields_batch(&db, &mut items)?; Ok(items) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4400,8 +4212,7 @@ impl StorageBackend for SqliteBackend { progress_secs, now.to_rfc3339() ], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4433,8 +4244,7 @@ impl StorageBackend for SqliteBackend { params![&user_id_str, &media_id_str], |row| row.get(0), ) - .optional() - .map_err(|e| PinakesError::Database(e.to_string()))?; + .optional()?; Ok(result) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4460,8 +4270,7 @@ impl StorageBackend for SqliteBackend { let affected = db .execute("DELETE FROM usage_events WHERE timestamp < ?", [ &before_str, - ]) - .map_err(|e| PinakesError::Database(e.to_string()))?; + ])?; Ok(affected as u64) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4510,8 +4319,7 @@ impl StorageBackend for SqliteBackend { offset_ms, &now ], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4532,13 +4340,10 @@ impl StorageBackend for SqliteBackend { 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 = \ - ?", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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)?; @@ -4562,8 +4367,7 @@ impl StorageBackend for SqliteBackend { offset_ms: row.get(7)?, created_at: parse_datetime(&created_str), }) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? + })? .filter_map(std::result::Result::ok) .collect(); Ok(subtitles) @@ -4585,8 +4389,7 @@ impl StorageBackend for SqliteBackend { 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]) - .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute("DELETE FROM subtitles WHERE id = ?", [&id_str])?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4610,8 +4413,7 @@ impl StorageBackend for SqliteBackend { })?; db.execute("UPDATE subtitles SET offset_ms = ? WHERE id = ?", params![ offset_ms, &id_str - ]) - .map_err(|e| PinakesError::Database(e.to_string()))?; + ])?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4626,7 +4428,7 @@ impl StorageBackend for SqliteBackend { async fn store_external_metadata( &self, - meta: &pinakes_enrichment::ExternalMetadata, + meta: &crate::enrichment::ExternalMetadata, ) -> Result<()> { let conn = Arc::clone(&self.conn); let id_str = meta.id.to_string(); @@ -4653,8 +4455,7 @@ impl StorageBackend for SqliteBackend { confidence, &last_updated ], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4670,38 +4471,35 @@ impl StorageBackend for SqliteBackend { async fn get_external_metadata( &self, media_id: MediaId, - ) -> Result> { + ) -> Result> { let conn = Arc::clone(&self.conn); 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 = ?", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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(pinakes_enrichment::ExternalMetadata { + Ok(crate::enrichment::ExternalMetadata { id: parse_uuid(&id_str)?, media_id: MediaId(parse_uuid(&mid_str)?), source: source_str .parse() - .unwrap_or(pinakes_enrichment::EnrichmentSourceType::MusicBrainz), + .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), }) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? + })? .filter_map(std::result::Result::ok) .collect(); Ok(metas) @@ -4723,8 +4521,7 @@ impl StorageBackend for SqliteBackend { 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]) - .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute("DELETE FROM external_metadata WHERE id = ?", [&id_str])?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4772,8 +4569,7 @@ impl StorageBackend for SqliteBackend { &created_at, &expires_at ], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4877,9 +4673,7 @@ impl StorageBackend for SqliteBackend { ) }, ); - let mut stmt = db - .prepare(&sql) - .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare(&sql)?; let parse_row = |row: &Row| -> rusqlite::Result { let id_str: String = row.get(0)?; @@ -4910,14 +4704,12 @@ impl StorageBackend for SqliteBackend { }; let sessions: Vec<_> = if let Some(ref p) = param { stmt - .query_map([p], parse_row) - .map_err(|e| PinakesError::Database(e.to_string()))? + .query_map([p], parse_row)? .filter_map(std::result::Result::ok) .collect() } else { stmt - .query_map([], parse_row) - .map_err(|e| PinakesError::Database(e.to_string()))? + .query_map([], parse_row)? .filter_map(std::result::Result::ok) .collect() }; @@ -4951,8 +4743,7 @@ impl StorageBackend for SqliteBackend { "UPDATE transcode_sessions SET status = ?, progress = ?, \ error_message = ? WHERE id = ?", params![&status_str, progress, &error_message, &id_str], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4975,13 +4766,11 @@ impl StorageBackend for SqliteBackend { 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], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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) @@ -5024,8 +4813,7 @@ impl StorageBackend for SqliteBackend { &expires_at, &last_accessed ], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -5088,8 +4876,7 @@ impl StorageBackend for SqliteBackend { }) }, ) - .optional() - .map_err(|e| PinakesError::Database(e.to_string()))?; + .optional()?; Ok(result) }); @@ -5113,8 +4900,7 @@ impl StorageBackend for SqliteBackend { db.execute( "UPDATE sessions SET last_accessed = ? WHERE session_token = ?", params![&now, &token], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -5139,13 +4925,11 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let rows = db - .execute( - "UPDATE sessions SET expires_at = ?, last_accessed = ? WHERE \ - session_token = ? AND expires_at > datetime('now')", - params![&expires, &now, &token], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + let rows = db.execute( + "UPDATE sessions SET expires_at = ?, last_accessed = ? WHERE \ + session_token = ? AND expires_at > datetime('now')", + params![&expires, &now, &token], + )?; if rows > 0 { Ok(Some(new_expires_at)) } else { @@ -5168,8 +4952,7 @@ impl StorageBackend for SqliteBackend { 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]) - .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute("DELETE FROM sessions WHERE session_token = ?", [&token])?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -5188,9 +4971,8 @@ impl StorageBackend for SqliteBackend { 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]) - .map_err(|e| PinakesError::Database(e.to_string()))?; + let affected = + db.execute("DELETE FROM sessions WHERE username = ?", [&user])?; Ok(affected as u64) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -5211,9 +4993,8 @@ impl StorageBackend for SqliteBackend { 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]) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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) @@ -5258,44 +5039,36 @@ impl StorageBackend for SqliteBackend { ) }; - let mut stmt = db - .prepare(query) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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 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, - ) + 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, - ) + 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), - }) + last_accessed: chrono::DateTime::parse_from_rfc3339( + &last_accessed_str, + ) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))? + .with_timezone(&chrono::Utc), }) - .map_err(|e| PinakesError::Database(e.to_string()))?; + })?; rows .collect::, _>>() - .map_err(|e| PinakesError::Database(e.to_string())) + .map_err(std::convert::Into::into) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) .await @@ -5336,9 +5109,7 @@ impl StorageBackend for SqliteBackend { let mut conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let tx = conn - .transaction() - .map_err(|e| PinakesError::Database(e.to_string()))?; + let tx = conn.transaction()?; // Upsert book_metadata tx.execute( @@ -5363,18 +5134,15 @@ impl StorageBackend for SqliteBackend { series_index, format ], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; // Clear existing authors and identifiers tx.execute("DELETE FROM book_authors WHERE media_id = ?1", [ &media_id_str, - ]) - .map_err(|e| PinakesError::Database(e.to_string()))?; + ])?; tx.execute("DELETE FROM book_identifiers WHERE media_id = ?1", [ &media_id_str, - ]) - .map_err(|e| PinakesError::Database(e.to_string()))?; + ])?; // Insert authors for author in &authors { @@ -5389,8 +5157,7 @@ impl StorageBackend for SqliteBackend { author.role, author.position ], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; } // Insert identifiers @@ -5401,13 +5168,11 @@ impl StorageBackend for SqliteBackend { identifier_value) VALUES (?1, ?2, ?3)", rusqlite::params![media_id_str, id_type, value], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; } } - tx.commit() - .map_err(|e| PinakesError::Database(e.to_string()))?; + tx.commit()?; Ok::<_, PinakesError>(()) }); @@ -5458,8 +5223,7 @@ impl StorageBackend for SqliteBackend { )) }, ) - .optional() - .map_err(|e| PinakesError::Database(e.to_string()))?; + .optional()?; let Some(( isbn, @@ -5479,12 +5243,10 @@ impl StorageBackend for SqliteBackend { }; // Get authors - let mut stmt = conn - .prepare( - "SELECT author_name, author_sort, role, position + let mut stmt = conn.prepare( + "SELECT author_name, author_sort, role, position FROM book_authors WHERE media_id = ?1 ORDER BY position", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; let authors: Vec = stmt .query_map([&media_id_str], |row| { Ok(crate::model::AuthorInfo { @@ -5493,28 +5255,20 @@ impl StorageBackend for SqliteBackend { role: row.get(2)?, position: row.get(3)?, }) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? - .collect::>>() - .map_err(|e| PinakesError::Database(e.to_string()))?; + })? + .collect::>>()?; // Get identifiers - let mut stmt = conn - .prepare( - "SELECT identifier_type, identifier_value + let mut stmt = conn.prepare( + "SELECT identifier_type, identifier_value FROM book_identifiers WHERE media_id = ?1", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; let mut identifiers: FxHashMap> = FxHashMap::default(); - for row in stmt - .query_map([&media_id_str], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? - { - let (id_type, value) = - row.map_err(|e| PinakesError::Database(e.to_string()))?; + 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); } @@ -5572,22 +5326,20 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn - .execute( - "INSERT INTO book_authors (media_id, author_name, author_sort, \ - role, position) + 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 - ], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + rusqlite::params![ + media_id_str, + author_clone.name, + author_clone.file_as, + author_clone.role, + author_clone.position + ], + )?; Ok::<_, PinakesError>(()) }); @@ -5611,12 +5363,10 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn - .prepare( - "SELECT author_name, author_sort, role, position + let mut stmt = conn.prepare( + "SELECT author_name, author_sort, role, position FROM book_authors WHERE media_id = ?1 ORDER BY position", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; let authors: Vec = stmt .query_map([&media_id_str], |row| { Ok(crate::model::AuthorInfo { @@ -5625,10 +5375,8 @@ impl StorageBackend for SqliteBackend { role: row.get(2)?, position: row.get(3)?, }) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? - .collect::>>() - .map_err(|e| PinakesError::Database(e.to_string()))?; + })? + .collect::>>()?; Ok::<_, PinakesError>(authors) }); @@ -5656,22 +5404,18 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn - .prepare( - "SELECT author_name, COUNT(DISTINCT media_id) as book_count + 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", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; let authors: Vec<(String, u64)> = stmt .query_map([limit.cast_signed(), offset.cast_signed()], |row| { Ok((row.get(0)?, row.get::<_, i64>(1)?.cast_unsigned())) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? - .collect::>>() - .map_err(|e| PinakesError::Database(e.to_string()))?; + })? + .collect::>>()?; Ok::<_, PinakesError>(authors) }); @@ -5694,22 +5438,18 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn - .prepare( - "SELECT series_name, COUNT(*) as book_count + 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", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; let series: Vec<(String, u64)> = stmt .query_map([], |row| { Ok((row.get(0)?, row.get::<_, i64>(1)?.cast_unsigned())) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? - .collect::>>() - .map_err(|e| PinakesError::Database(e.to_string()))?; + })? + .collect::>>()?; Ok::<_, PinakesError>(series) }); @@ -5734,25 +5474,21 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn - .prepare( - "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, + 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.year, m.duration_secs, m.description, m.thumbnail_path, \ - m.file_mtime, + 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", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; let items = stmt - .query_map([&series], row_to_media_item) - .map_err(|e| PinakesError::Database(e.to_string()))? - .collect::>>() - .map_err(|e| PinakesError::Database(e.to_string()))?; + .query_map([&series], row_to_media_item)? + .collect::>>()?; Ok::<_, PinakesError>(items) }); @@ -5783,16 +5519,14 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn - .execute( - "INSERT INTO watch_history (user_id, media_id, progress_secs, \ - last_watched) + conn.execute( + "INSERT INTO watch_history (user_id, media_id, progress_secs, \ + last_watched) VALUES (?1, ?2, ?3, datetime('now')) ON CONFLICT(user_id, media_id) DO UPDATE SET progress_secs = ?3, last_watched = datetime('now')", - rusqlite::params![user_id_str, media_id_str, f64::from(current_page)], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + rusqlite::params![user_id_str, media_id_str, f64::from(current_page)], + )?; Ok::<_, PinakesError>(()) }); @@ -5836,8 +5570,7 @@ impl StorageBackend for SqliteBackend { Ok((current_page, total_pages, last_read_str)) }, ) - .optional() - .map_err(|e| PinakesError::Database(e.to_string()))?; + .optional()?; let progress = match result { Some((current_page, total_pages, last_read_str)) => { @@ -5897,59 +5630,61 @@ impl StorageBackend for SqliteBackend { // 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 + 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 DESC", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; - let rows = stmt - .query_map([&user_id_str], |row| { - // Parse the media item - let item = row_to_media_item(row)?; - // Read the extra columns by name, this is safe *regardless* of column - // count. - let current_page = row - .get::<_, Option>("progress_secs")? - .map_or(0, |v| i32::try_from(v).unwrap_or(0)); - let total_pages = row.get::<_, Option>("page_count")?; - Ok((item, current_page, total_pages)) - }) - .map_err(|e| PinakesError::Database(e.to_string()))?; + let rows = stmt.query_map([&user_id_str], |row| { + // Parse the media item + let item = row_to_media_item(row)?; + // Read the extra columns by name, this is safe *regardless* of column + // count. + let current_page = row + .get::<_, Option>("progress_secs")? + .map_or(0, |v| i32::try_from(v).unwrap_or(0)); + let total_pages = row.get::<_, Option>("page_count")?; + Ok((item, current_page, total_pages)) + })?; let mut results = Vec::new(); - for (item, current_page, total_pages) in rows.flatten() { - // Calculate status based on progress - let calculated_status = total_pages.map_or( - // No total pages known, assume reading - crate::model::ReadingStatus::Reading, - |total| { - if total > 0 { - let percent = - (f64::from(current_page) / f64::from(total) * 100.0).min(100.0); - if percent >= 100.0 { - crate::model::ReadingStatus::Completed - } else if percent > 0.0 { - crate::model::ReadingStatus::Reading - } else { - crate::model::ReadingStatus::ToRead - } - } else { - crate::model::ReadingStatus::Reading + for row in rows { + match row { + Ok((item, current_page, total_pages)) => { + // Calculate status based on progress + let calculated_status = total_pages.map_or( + // No total pages known, assume reading + crate::model::ReadingStatus::Reading, + |total| { + if total > 0 { + let percent = (f64::from(current_page) / f64::from(total) + * 100.0) + .min(100.0); + if percent >= 100.0 { + crate::model::ReadingStatus::Completed + } else if percent > 0.0 { + crate::model::ReadingStatus::Reading + } else { + crate::model::ReadingStatus::ToRead + } + } else { + crate::model::ReadingStatus::Reading + } + }, + ); + + // Filter by status if specified + match status { + None => results.push(item), + Some(s) if s == calculated_status => results.push(item), + _ => {}, } }, - ); - - // Filter by status if specified - match status { - None => results.push(item), - Some(s) if s == calculated_status => results.push(item), - _ => {}, + Err(_) => continue, } } Ok::<_, PinakesError>(results) @@ -6041,14 +5776,10 @@ impl StorageBackend for SqliteBackend { let params_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(std::convert::AsRef::as_ref).collect(); - let mut stmt = conn - .prepare(&query) - .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = conn.prepare(&query)?; let items = stmt - .query_map(&*params_refs, row_to_media_item) - .map_err(|e| PinakesError::Database(e.to_string()))? - .collect::>>() - .map_err(|e| PinakesError::Database(e.to_string()))?; + .query_map(&*params_refs, row_to_media_item)? + .collect::>>()?; Ok::<_, PinakesError>(items) }); @@ -6069,43 +5800,41 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn - .execute( - "INSERT INTO media_items (id, path, file_name, media_type, \ - content_hash, file_size, + 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, + description, thumbnail_path, storage_mode, original_filename, uploaded_at, storage_key, \ - created_at, updated_at) + 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.cast_signed(), - 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(), - ], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + ?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.cast_signed(), + 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::<_, PinakesError>(()) }) .await @@ -6151,22 +5880,19 @@ impl StorageBackend for SqliteBackend { }) }, ) - .optional() - .map_err(|e| PinakesError::Database(e.to_string()))?; + .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) + 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.cast_signed(), &mime, &now], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + params![&hash_str, size.cast_signed(), &mime, &now], + )?; Ok(ManagedBlob { content_hash: ContentHash(hash_str), @@ -6223,13 +5949,11 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn - .execute( - "UPDATE managed_blobs SET reference_count = reference_count + 1 \ - WHERE content_hash = ?1", - params![&hash_str], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + conn.execute( + "UPDATE managed_blobs SET reference_count = reference_count + 1 WHERE \ + content_hash = ?1", + params![&hash_str], + )?; Ok::<_, PinakesError>(()) }) .await @@ -6247,13 +5971,11 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn - .execute( - "UPDATE managed_blobs SET reference_count = reference_count - 1 \ - WHERE content_hash = ?1", - params![&hash_str], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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 @@ -6284,12 +6006,10 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn - .execute( - "UPDATE managed_blobs SET last_verified = ?1 WHERE content_hash = ?2", - params![&now, &hash_str], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + conn.execute( + "UPDATE managed_blobs SET last_verified = ?1 WHERE content_hash = ?2", + params![&now, &hash_str], + )?; Ok::<_, PinakesError>(()) }) .await @@ -6306,13 +6026,11 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn - .prepare( - "SELECT content_hash, file_size, mime_type, reference_count, \ - stored_at, last_verified + 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", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; let blobs = stmt .query_map([], |row| { Ok(ManagedBlob { @@ -6325,10 +6043,8 @@ impl StorageBackend for SqliteBackend { .get::<_, Option>(5)? .map(|s| parse_datetime(&s)), }) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? - .collect::>>() - .map_err(|e| PinakesError::Database(e.to_string()))?; + })? + .collect::>>()?; Ok::<_, PinakesError>(blobs) }) .await @@ -6346,12 +6062,10 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn - .execute( - "DELETE FROM managed_blobs WHERE content_hash = ?1", - params![&hash_str], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + conn.execute( + "DELETE FROM managed_blobs WHERE content_hash = ?1", + params![&hash_str], + )?; Ok::<_, PinakesError>(()) }) .await @@ -6370,8 +6084,7 @@ impl StorageBackend for SqliteBackend { let total_blobs: u64 = conn .query_row("SELECT COUNT(*) FROM managed_blobs", [], |row| { row.get::<_, i64>(0) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? + })? .cast_unsigned(); let total_size: u64 = conn @@ -6379,8 +6092,7 @@ impl StorageBackend for SqliteBackend { "SELECT COALESCE(SUM(file_size), 0) FROM managed_blobs", [], |row| row.get::<_, i64>(0), - ) - .map_err(|e| PinakesError::Database(e.to_string()))? + )? .cast_unsigned(); let unique_size: u64 = conn @@ -6389,8 +6101,7 @@ impl StorageBackend for SqliteBackend { reference_count = 1", [], |row| row.get::<_, i64>(0), - ) - .map_err(|e| PinakesError::Database(e.to_string()))? + )? .cast_unsigned(); let managed_media_count: u64 = conn @@ -6398,8 +6109,7 @@ impl StorageBackend for SqliteBackend { "SELECT COUNT(*) FROM media_items WHERE storage_mode = 'managed'", [], |row| row.get::<_, i64>(0), - ) - .map_err(|e| PinakesError::Database(e.to_string()))? + )? .cast_unsigned(); let orphaned_blobs: u64 = conn @@ -6407,8 +6117,7 @@ impl StorageBackend for SqliteBackend { "SELECT COUNT(*) FROM managed_blobs WHERE reference_count <= 0", [], |row| row.get::<_, i64>(0), - ) - .map_err(|e| PinakesError::Database(e.to_string()))? + )? .cast_unsigned(); let dedup_ratio = if total_size > 0 { @@ -6440,9 +6149,9 @@ impl StorageBackend for SqliteBackend { async fn register_device( &self, - device: &pinakes_sync::SyncDevice, + device: &crate::sync::SyncDevice, token_hash: &str, - ) -> Result { + ) -> Result { let conn = Arc::clone(&self.conn); let device = device.clone(); let token_hash = token_hash.to_string(); @@ -6451,29 +6160,27 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn - .execute( - "INSERT INTO sync_devices (id, user_id, name, device_type, \ - client_version, os_info, + 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) + 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(), - ], - ) - .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::<_, PinakesError>(device) }) .await @@ -6483,8 +6190,8 @@ impl StorageBackend for SqliteBackend { async fn get_device( &self, - id: pinakes_sync::DeviceId, - ) -> Result { + id: crate::sync::DeviceId, + ) -> Result { let conn = Arc::clone(&self.conn); tokio::task::spawn_blocking(move || { @@ -6499,11 +6206,11 @@ impl StorageBackend for SqliteBackend { FROM sync_devices WHERE id = ?1", params![id.0.to_string()], |row| { - Ok(pinakes_sync::SyncDevice { - id: pinakes_sync::DeviceId(parse_uuid( + Ok(crate::sync::SyncDevice { + id: crate::sync::DeviceId(parse_uuid( &row.get::<_, String>(0)?, )?), - user_id: pinakes_types::model::UserId(parse_uuid( + user_id: crate::users::UserId(parse_uuid( &row.get::<_, String>(1)?, )?), name: row.get(2)?, @@ -6533,7 +6240,7 @@ impl StorageBackend for SqliteBackend { async fn get_device_by_token( &self, token_hash: &str, - ) -> Result> { + ) -> Result> { let conn = Arc::clone(&self.conn); let token_hash = token_hash.to_string(); @@ -6549,11 +6256,11 @@ impl StorageBackend for SqliteBackend { FROM sync_devices WHERE device_token_hash = ?1", params![&token_hash], |row| { - Ok(pinakes_sync::SyncDevice { - id: pinakes_sync::DeviceId(parse_uuid( + Ok(crate::sync::SyncDevice { + id: crate::sync::DeviceId(parse_uuid( &row.get::<_, String>(0)?, )?), - user_id: pinakes_types::model::UserId(parse_uuid( + user_id: crate::users::UserId(parse_uuid( &row.get::<_, String>(1)?, )?), name: row.get(2)?, @@ -6586,29 +6293,27 @@ impl StorageBackend for SqliteBackend { async fn list_user_devices( &self, user_id: crate::users::UserId, - ) -> Result> { + ) -> Result> { let conn = Arc::clone(&self.conn); tokio::task::spawn_blocking(move || { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn - .prepare( - "SELECT id, user_id, name, device_type, client_version, os_info, + 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 + created_at, updated_at FROM sync_devices WHERE user_id = ?1 ORDER BY last_seen_at \ - DESC", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + DESC", + )?; let devices = stmt .query_map(params![user_id.0.to_string()], |row| { - Ok(pinakes_sync::SyncDevice { - id: pinakes_sync::DeviceId(parse_uuid( + Ok(crate::sync::SyncDevice { + id: crate::sync::DeviceId(parse_uuid( &row.get::<_, String>(0)?, )?), - user_id: pinakes_types::model::UserId(parse_uuid( + user_id: crate::users::UserId(parse_uuid( &row.get::<_, String>(1)?, )?), name: row.get(2)?, @@ -6627,10 +6332,8 @@ impl StorageBackend for SqliteBackend { created_at: parse_datetime(&row.get::<_, String>(10)?), updated_at: parse_datetime(&row.get::<_, String>(11)?), }) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? - .collect::>>() - .map_err(|e| PinakesError::Database(e.to_string()))?; + })? + .collect::>>()?; Ok::<_, PinakesError>(devices) }) .await @@ -6642,7 +6345,7 @@ impl StorageBackend for SqliteBackend { async fn update_device( &self, - device: &pinakes_sync::SyncDevice, + device: &crate::sync::SyncDevice, ) -> Result<()> { let conn = Arc::clone(&self.conn); let device = device.clone(); @@ -6651,27 +6354,25 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn - .execute( - "UPDATE sync_devices SET name = ?1, device_type = ?2, \ - client_version = ?3, + 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, + 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(), - ], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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::<_, PinakesError>(()) }) .await @@ -6679,18 +6380,16 @@ impl StorageBackend for SqliteBackend { Ok(()) } - async fn delete_device(&self, id: pinakes_sync::DeviceId) -> Result<()> { + async fn delete_device(&self, id: crate::sync::DeviceId) -> Result<()> { let conn = Arc::clone(&self.conn); tokio::task::spawn_blocking(move || { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn - .execute("DELETE FROM sync_devices WHERE id = ?1", params![ - id.0.to_string() - ]) - .map_err(|e| PinakesError::Database(e.to_string()))?; + conn.execute("DELETE FROM sync_devices WHERE id = ?1", params![ + id.0.to_string() + ])?; Ok::<_, PinakesError>(()) }) .await @@ -6698,7 +6397,7 @@ impl StorageBackend for SqliteBackend { Ok(()) } - async fn touch_device(&self, id: pinakes_sync::DeviceId) -> Result<()> { + async fn touch_device(&self, id: crate::sync::DeviceId) -> Result<()> { let conn = Arc::clone(&self.conn); let now = chrono::Utc::now().to_rfc3339(); @@ -6706,13 +6405,11 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn - .execute( - "UPDATE sync_devices SET last_seen_at = ?1, updated_at = ?1 WHERE \ - id = ?2", - params![&now, id.0.to_string()], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + conn.execute( + "UPDATE sync_devices SET last_seen_at = ?1, updated_at = ?1 WHERE id \ + = ?2", + params![&now, id.0.to_string()], + )?; Ok::<_, PinakesError>(()) }) .await @@ -6722,7 +6419,7 @@ impl StorageBackend for SqliteBackend { async fn record_sync_change( &self, - change: &pinakes_sync::SyncLogEntry, + change: &crate::sync::SyncLogEntry, ) -> Result<()> { let conn = Arc::clone(&self.conn); let change = change.clone(); @@ -6733,35 +6430,31 @@ impl StorageBackend for SqliteBackend { })?; // 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), - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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(u64::cast_signed), - change.metadata_json, - change.changed_by_device.map(|d| d.0.to_string()), - change.timestamp.to_rfc3339(), - ], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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(u64::cast_signed), + change.metadata_json, + change.changed_by_device.map(|d| d.0.to_string()), + change.timestamp.to_rfc3339(), + ], + )?; Ok::<_, PinakesError>(()) }) .await @@ -6775,29 +6468,27 @@ impl StorageBackend for SqliteBackend { &self, cursor: i64, limit: u64, - ) -> Result> { + ) -> Result> { let conn = Arc::clone(&self.conn); tokio::task::spawn_blocking(move || { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn - .prepare( - "SELECT id, sequence, change_type, media_id, path, content_hash, + 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", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; let entries = stmt .query_map(params![cursor, limit.cast_signed()], |row| { - Ok(pinakes_sync::SyncLogEntry { + Ok(crate::sync::SyncLogEntry { id: parse_uuid(&row.get::<_, String>(0)?)?, sequence: row.get(1)?, change_type: row .get::<_, String>(2)? .parse() - .unwrap_or(pinakes_sync::SyncChangeType::Modified), + .unwrap_or(crate::sync::SyncChangeType::Modified), media_id: row .get::<_, Option>(3)? .and_then(|s| Uuid::parse_str(&s).ok().map(MediaId)), @@ -6810,14 +6501,12 @@ impl StorageBackend for SqliteBackend { .map(i64::cast_unsigned), metadata_json: row.get(7)?, changed_by_device: row.get::<_, Option>(8)?.and_then(|s| { - Uuid::parse_str(&s).ok().map(pinakes_sync::DeviceId) + Uuid::parse_str(&s).ok().map(crate::sync::DeviceId) }), timestamp: parse_datetime(&row.get::<_, String>(9)?), }) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? - .collect::>>() - .map_err(|e| PinakesError::Database(e.to_string()))?; + })? + .collect::>>()?; Ok::<_, PinakesError>(entries) }) .await @@ -6873,9 +6562,9 @@ impl StorageBackend for SqliteBackend { async fn get_device_sync_state( &self, - device_id: pinakes_sync::DeviceId, + device_id: crate::sync::DeviceId, path: &str, - ) -> Result> { + ) -> Result> { let conn = Arc::clone(&self.conn); let path = path.to_string(); @@ -6891,8 +6580,8 @@ impl StorageBackend for SqliteBackend { FROM device_sync_state WHERE device_id = ?1 AND path = ?2", params![device_id.0.to_string(), &path], |row| { - Ok(pinakes_sync::DeviceSyncState { - device_id: pinakes_sync::DeviceId(parse_uuid( + Ok(crate::sync::DeviceSyncState { + device_id: crate::sync::DeviceId(parse_uuid( &row.get::<_, String>(0)?, )?), path: row.get(1)?, @@ -6903,7 +6592,7 @@ impl StorageBackend for SqliteBackend { sync_status: row .get::<_, String>(6)? .parse() - .unwrap_or(pinakes_sync::FileSyncStatus::Synced), + .unwrap_or(crate::sync::FileSyncStatus::Synced), last_synced_at: row .get::<_, Option>(7)? .map(|s| parse_datetime(&s)), @@ -6924,7 +6613,7 @@ impl StorageBackend for SqliteBackend { async fn upsert_device_sync_state( &self, - state: &pinakes_sync::DeviceSyncState, + state: &crate::sync::DeviceSyncState, ) -> Result<()> { let conn = Arc::clone(&self.conn); let state = state.clone(); @@ -6933,12 +6622,11 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn - .execute( - "INSERT INTO device_sync_state (device_id, path, local_hash, \ - server_hash, + 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) + 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, @@ -6948,19 +6636,18 @@ 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, - ], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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::<_, PinakesError>(()) }) .await @@ -6972,28 +6659,26 @@ impl StorageBackend for SqliteBackend { async fn list_pending_sync( &self, - device_id: pinakes_sync::DeviceId, - ) -> Result> { + device_id: crate::sync::DeviceId, + ) -> Result> { let conn = Arc::clone(&self.conn); tokio::task::spawn_blocking(move || { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn - .prepare( - "SELECT device_id, path, local_hash, server_hash, local_mtime, \ - server_mtime, + 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')", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 'pending_download', 'conflict')", + )?; let states = stmt .query_map(params![device_id.0.to_string()], |row| { - Ok(pinakes_sync::DeviceSyncState { - device_id: pinakes_sync::DeviceId(parse_uuid( + Ok(crate::sync::DeviceSyncState { + device_id: crate::sync::DeviceId(parse_uuid( &row.get::<_, String>(0)?, )?), path: row.get(1)?, @@ -7004,16 +6689,14 @@ impl StorageBackend for SqliteBackend { sync_status: row .get::<_, String>(6)? .parse() - .unwrap_or(pinakes_sync::FileSyncStatus::Synced), + .unwrap_or(crate::sync::FileSyncStatus::Synced), last_synced_at: row .get::<_, Option>(7)? .map(|s| parse_datetime(&s)), conflict_info_json: row.get(8)?, }) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? - .collect::>>() - .map_err(|e| PinakesError::Database(e.to_string()))?; + })? + .collect::>>()?; Ok::<_, PinakesError>(states) }) .await @@ -7025,7 +6708,7 @@ impl StorageBackend for SqliteBackend { async fn create_upload_session( &self, - session: &pinakes_sync::UploadSession, + session: &crate::sync::UploadSession, ) -> Result<()> { let conn = Arc::clone(&self.conn); let session = session.clone(); @@ -7034,28 +6717,26 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn - .execute( - "INSERT INTO upload_sessions (id, device_id, target_path, \ - expected_hash, + 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) + 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.cast_signed(), - session.chunk_size.cast_signed(), - session.chunk_count.cast_signed(), - session.status.to_string(), - session.created_at.to_rfc3339(), - session.expires_at.to_rfc3339(), - session.last_activity.to_rfc3339(), - ], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + params![ + session.id.to_string(), + session.device_id.0.to_string(), + session.target_path, + session.expected_hash.0, + session.expected_size.cast_signed(), + session.chunk_size.cast_signed(), + session.chunk_count.cast_signed(), + session.status.to_string(), + session.created_at.to_rfc3339(), + session.expires_at.to_rfc3339(), + session.last_activity.to_rfc3339(), + ], + )?; Ok::<_, PinakesError>(()) }) .await @@ -7068,7 +6749,7 @@ impl StorageBackend for SqliteBackend { async fn get_upload_session( &self, id: Uuid, - ) -> Result { + ) -> Result { let conn = Arc::clone(&self.conn); tokio::task::spawn_blocking(move || { @@ -7084,9 +6765,9 @@ impl StorageBackend for SqliteBackend { FROM upload_sessions WHERE id = ?1", params![id.to_string()], |row| { - Ok(pinakes_sync::UploadSession { + Ok(crate::sync::UploadSession { id: parse_uuid(&row.get::<_, String>(0)?)?, - device_id: pinakes_sync::DeviceId(parse_uuid( + device_id: crate::sync::DeviceId(parse_uuid( &row.get::<_, String>(1)?, )?), target_path: row.get(2)?, @@ -7097,7 +6778,7 @@ impl StorageBackend for SqliteBackend { status: row .get::<_, String>(7)? .parse() - .unwrap_or(pinakes_sync::UploadStatus::Pending), + .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)?), @@ -7114,7 +6795,7 @@ impl StorageBackend for SqliteBackend { async fn update_upload_session( &self, - session: &pinakes_sync::UploadSession, + session: &crate::sync::UploadSession, ) -> Result<()> { let conn = Arc::clone(&self.conn); let session = session.clone(); @@ -7123,17 +6804,15 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - 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(), - ], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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::<_, PinakesError>(()) }) .await @@ -7146,7 +6825,7 @@ impl StorageBackend for SqliteBackend { async fn record_chunk( &self, upload_id: Uuid, - chunk: &pinakes_sync::ChunkInfo, + chunk: &crate::sync::ChunkInfo, ) -> Result<()> { let conn = Arc::clone(&self.conn); let chunk = chunk.clone(); @@ -7155,24 +6834,22 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn - .execute( - "INSERT INTO upload_chunks (upload_id, chunk_index, offset, size, \ - hash, received_at) + 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.cast_signed(), - chunk.offset.cast_signed(), - chunk.size.cast_signed(), - chunk.hash, - chunk.received_at.to_rfc3339(), - ], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + params![ + upload_id.to_string(), + chunk.chunk_index.cast_signed(), + chunk.offset.cast_signed(), + chunk.size.cast_signed(), + chunk.hash, + chunk.received_at.to_rfc3339(), + ], + )?; Ok::<_, PinakesError>(()) }) .await @@ -7183,22 +6860,20 @@ impl StorageBackend for SqliteBackend { async fn get_upload_chunks( &self, upload_id: Uuid, - ) -> Result> { + ) -> Result> { let conn = Arc::clone(&self.conn); tokio::task::spawn_blocking(move || { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn - .prepare( - "SELECT upload_id, chunk_index, offset, size, hash, received_at + 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", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; let chunks = stmt .query_map(params![upload_id.to_string()], |row| { - Ok(pinakes_sync::ChunkInfo { + Ok(crate::sync::ChunkInfo { upload_id: parse_uuid(&row.get::<_, String>(0)?)?, chunk_index: row.get::<_, i64>(1)?.cast_unsigned(), offset: row.get::<_, i64>(2)?.cast_unsigned(), @@ -7206,10 +6881,8 @@ impl StorageBackend for SqliteBackend { hash: row.get(4)?, received_at: parse_datetime(&row.get::<_, String>(5)?), }) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? - .collect::>>() - .map_err(|e| PinakesError::Database(e.to_string()))?; + })? + .collect::>>()?; Ok::<_, PinakesError>(chunks) }) .await @@ -7245,7 +6918,7 @@ impl StorageBackend for SqliteBackend { async fn record_conflict( &self, - conflict: &pinakes_sync::SyncConflict, + conflict: &crate::sync::SyncConflict, ) -> Result<()> { let conn = Arc::clone(&self.conn); let conflict = conflict.clone(); @@ -7254,24 +6927,22 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn - .execute( - "INSERT INTO sync_conflicts (id, device_id, path, local_hash, \ - local_mtime, + 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(), - ], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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::<_, PinakesError>(()) }) .await @@ -7281,28 +6952,26 @@ impl StorageBackend for SqliteBackend { async fn get_unresolved_conflicts( &self, - device_id: pinakes_sync::DeviceId, - ) -> Result> { + device_id: crate::sync::DeviceId, + ) -> Result> { let conn = Arc::clone(&self.conn); tokio::task::spawn_blocking(move || { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn - .prepare( - "SELECT id, device_id, path, local_hash, local_mtime, server_hash, \ - server_mtime, + 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", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + NULL", + )?; let conflicts = stmt .query_map(params![device_id.0.to_string()], |row| { - Ok(pinakes_sync::SyncConflict { + Ok(crate::sync::SyncConflict { id: parse_uuid(&row.get::<_, String>(0)?)?, - device_id: pinakes_sync::DeviceId(parse_uuid( + device_id: crate::sync::DeviceId(parse_uuid( &row.get::<_, String>(1)?, )?), path: row.get(2)?, @@ -7317,25 +6986,21 @@ impl StorageBackend for SqliteBackend { resolution: row.get::<_, Option>(9)?.and_then(|s| { match s.as_str() { "server_wins" => { - Some(pinakes_types::config::ConflictResolution::ServerWins) + Some(crate::config::ConflictResolution::ServerWins) }, "client_wins" => { - Some(pinakes_types::config::ConflictResolution::ClientWins) + Some(crate::config::ConflictResolution::ClientWins) }, "keep_both" => { - Some(pinakes_types::config::ConflictResolution::KeepBoth) - }, - "manual" => { - Some(pinakes_types::config::ConflictResolution::Manual) + Some(crate::config::ConflictResolution::KeepBoth) }, + "manual" => Some(crate::config::ConflictResolution::Manual), _ => None, } }), }) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? - .collect::>>() - .map_err(|e| PinakesError::Database(e.to_string()))?; + })? + .collect::>>()?; Ok::<_, PinakesError>(conflicts) }) .await @@ -7350,28 +7015,26 @@ impl StorageBackend for SqliteBackend { async fn resolve_conflict( &self, id: Uuid, - resolution: pinakes_types::config::ConflictResolution, + resolution: crate::config::ConflictResolution, ) -> Result<()> { let conn = Arc::clone(&self.conn); let now = chrono::Utc::now().to_rfc3339(); let resolution_str = match resolution { - pinakes_types::config::ConflictResolution::ServerWins => "server_wins", - pinakes_types::config::ConflictResolution::ClientWins => "client_wins", - pinakes_types::config::ConflictResolution::KeepBoth => "keep_both", - pinakes_types::config::ConflictResolution::Manual => "manual", + 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().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn - .execute( - "UPDATE sync_conflicts SET resolved_at = ?1, resolution = ?2 WHERE \ - id = ?3", - params![&now, resolution_str, id.to_string()], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + conn.execute( + "UPDATE sync_conflicts SET resolved_at = ?1, resolution = ?2 WHERE id \ + = ?3", + params![&now, resolution_str, id.to_string()], + )?; Ok::<_, PinakesError>(()) }) .await @@ -7415,43 +7078,41 @@ impl StorageBackend for SqliteBackend { }, }; - 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, + perm_reshare, perm_add, note, expires_at, access_count, inherit_to_children, \ - parent_share_id, + 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.view.can_view, - share.permissions.view.can_download, - share.permissions.mutate.can_edit, - share.permissions.mutate.can_delete, - share.permissions.view.can_reshare, - share.permissions.mutate.can_add, - share.note, - share.expires_at.map(|dt| dt.to_rfc3339()), - share.access_count.cast_signed(), - share.inherit_to_children, - share.parent_share_id.map(|s| s.0.to_string()), - share.created_at.to_rfc3339(), - share.updated_at.to_rfc3339(), - ], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + ?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.view.can_view, + share.permissions.view.can_download, + share.permissions.mutate.can_edit, + share.permissions.mutate.can_delete, + share.permissions.view.can_reshare, + share.permissions.mutate.can_add, + share.note, + share.expires_at.map(|dt| dt.to_rfc3339()), + share.access_count.cast_signed(), + 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::<_, PinakesError>(share) }) .await @@ -7535,20 +7196,18 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn - .prepare( - "SELECT id, target_type, target_id, owner_id, recipient_type, \ - recipient_user_id, + 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_download, perm_edit, perm_delete, perm_reshare, perm_add, note, expires_at, \ - access_count, + access_count, last_accessed, inherit_to_children, parent_share_id, \ - created_at, updated_at + created_at, updated_at FROM shares WHERE owner_id = ?1 ORDER BY created_at DESC \ - LIMIT ?2 OFFSET ?3", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + LIMIT ?2 OFFSET ?3", + )?; let shares = stmt .query_map( params![ @@ -7557,10 +7216,8 @@ impl StorageBackend for SqliteBackend { offset.cast_signed() ], row_to_share, - ) - .map_err(|e| PinakesError::Database(e.to_string()))? - .collect::>>() - .map_err(|e| PinakesError::Database(e.to_string()))?; + )? + .collect::>>()?; Ok::<_, PinakesError>(shares) }) .await @@ -7583,20 +7240,18 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn - .prepare( - "SELECT id, target_type, target_id, owner_id, recipient_type, \ - recipient_user_id, + 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_download, perm_edit, perm_delete, perm_reshare, perm_add, note, expires_at, \ - access_count, + access_count, last_accessed, inherit_to_children, parent_share_id, \ - created_at, updated_at + created_at, updated_at FROM shares WHERE recipient_user_id = ?1 ORDER BY created_at \ - DESC LIMIT ?2 OFFSET ?3", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + DESC LIMIT ?2 OFFSET ?3", + )?; let shares = stmt .query_map( params![ @@ -7605,10 +7260,8 @@ impl StorageBackend for SqliteBackend { offset.cast_signed() ], row_to_share, - ) - .map_err(|e| PinakesError::Database(e.to_string()))? - .collect::>>() - .map_err(|e| PinakesError::Database(e.to_string()))?; + )? + .collect::>>()?; Ok::<_, PinakesError>(shares) }) .await @@ -7630,24 +7283,20 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn - .prepare( - "SELECT id, target_type, target_id, owner_id, recipient_type, \ - recipient_user_id, + 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_download, perm_edit, perm_delete, perm_reshare, perm_add, note, expires_at, \ - access_count, + access_count, last_accessed, inherit_to_children, parent_share_id, \ - created_at, updated_at + created_at, updated_at FROM shares WHERE target_type = ?1 AND target_id = ?2", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; let shares = stmt - .query_map(params![&target_type, &target_id], row_to_share) - .map_err(|e| PinakesError::Database(e.to_string()))? - .collect::>>() - .map_err(|e| PinakesError::Database(e.to_string()))?; + .query_map(params![&target_type, &target_id], row_to_share)? + .collect::>>()?; Ok::<_, PinakesError>(shares) }) .await @@ -7670,30 +7319,28 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn - .execute( - "UPDATE shares SET + conn.execute( + "UPDATE shares SET perm_view = ?1, perm_download = ?2, perm_edit = ?3, \ - perm_delete = ?4, + perm_delete = ?4, perm_reshare = ?5, perm_add = ?6, note = ?7, expires_at = \ - ?8, + ?8, inherit_to_children = ?9, updated_at = ?10 WHERE id = ?11", - params![ - share.permissions.view.can_view, - share.permissions.view.can_download, - share.permissions.mutate.can_edit, - share.permissions.mutate.can_delete, - share.permissions.view.can_reshare, - share.permissions.mutate.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(), - ], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + params![ + share.permissions.view.can_view, + share.permissions.view.can_download, + share.permissions.mutate.can_edit, + share.permissions.mutate.can_delete, + share.permissions.view.can_reshare, + share.permissions.mutate.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::<_, PinakesError>(share) }) .await @@ -7708,11 +7355,9 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn - .execute("DELETE FROM shares WHERE id = ?1", params![ - id.0.to_string() - ]) - .map_err(|e| PinakesError::Database(e.to_string()))?; + conn.execute("DELETE FROM shares WHERE id = ?1", params![ + id.0.to_string() + ])?; Ok::<_, PinakesError>(()) }) .await @@ -7731,13 +7376,11 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn - .execute( - "UPDATE shares SET access_count = access_count + 1, last_accessed = \ - ?1 WHERE id = ?2", - params![&now, id.0.to_string()], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + conn.execute( + "UPDATE shares SET access_count = access_count + 1, last_accessed = \ + ?1 WHERE id = ?2", + params![&now, id.0.to_string()], + )?; Ok::<_, PinakesError>(()) }) .await @@ -7777,7 +7420,7 @@ impl StorageBackend for SqliteBackend { ) if *share_user == uid => { return Ok(Some(share.permissions)); }, - _ => {}, + _ => continue, } } @@ -7803,17 +7446,14 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn - .prepare( - "SELECT collection_id FROM collection_members WHERE media_id = ?1", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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()) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? + })? .filter_map(|r| r.ok().flatten()) .collect::>(); Ok::<_, PinakesError>(ids) @@ -7845,15 +7485,13 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn - .prepare("SELECT tag_id FROM media_tags WHERE media_id = ?1") - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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()) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? + })? .filter_map(|r| r.ok().flatten()) .collect::>(); Ok::<_, PinakesError>(ids) @@ -7949,22 +7587,20 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn - .execute( - "INSERT INTO share_activity (id, share_id, actor_id, actor_ip, \ - action, details, timestamp) + 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(), - ], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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::<_, PinakesError>(()) }) .await @@ -7987,13 +7623,11 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn - .prepare( - "SELECT id, share_id, actor_id, actor_ip, action, details, timestamp + 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", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + DESC LIMIT ?2 OFFSET ?3", + )?; let activities = stmt .query_map( params![ @@ -8019,10 +7653,8 @@ impl StorageBackend for SqliteBackend { timestamp: parse_datetime(&row.get::<_, String>(6)?), }) }, - ) - .map_err(|e| PinakesError::Database(e.to_string()))? - .collect::>>() - .map_err(|e| PinakesError::Database(e.to_string()))?; + )? + .collect::>>()?; Ok::<_, PinakesError>(activities) }) .await @@ -8043,21 +7675,19 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn - .execute( - "INSERT INTO share_notifications (id, user_id, share_id, \ - notification_type, is_read, created_at) + 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(), - ], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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::<_, PinakesError>(()) }) .await @@ -8077,14 +7707,11 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn - .prepare( - "SELECT id, user_id, share_id, notification_type, is_read, \ - created_at + 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", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + ORDER BY created_at DESC", + )?; let notifications = stmt .query_map(params![user_id.0.to_string()], |row| { Ok(crate::sharing::ShareNotification { @@ -8102,10 +7729,8 @@ impl StorageBackend for SqliteBackend { is_read: row.get(4)?, created_at: parse_datetime(&row.get::<_, String>(5)?), }) - }) - .map_err(|e| PinakesError::Database(e.to_string()))? - .collect::>>() - .map_err(|e| PinakesError::Database(e.to_string()))?; + })? + .collect::>>()?; Ok::<_, PinakesError>(notifications) }) .await @@ -8128,13 +7753,11 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn - .execute( - "UPDATE share_notifications SET is_read = 1 WHERE id = ?1 AND \ - user_id = ?2", - params![id.to_string(), user_id.0.to_string()], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + conn.execute( + "UPDATE share_notifications SET is_read = 1 WHERE id = ?1 AND user_id \ + = ?2", + params![id.to_string(), user_id.0.to_string()], + )?; Ok::<_, PinakesError>(()) }) .await @@ -8154,12 +7777,10 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn - .execute( - "UPDATE share_notifications SET is_read = 1 WHERE user_id = ?1", - params![user_id.0.to_string()], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + conn.execute( + "UPDATE share_notifications SET is_read = 1 WHERE user_id = ?1", + params![user_id.0.to_string()], + )?; Ok::<_, PinakesError>(()) }) .await @@ -8184,20 +7805,18 @@ impl StorageBackend for SqliteBackend { let new_name = new_name.to_string(); let (old_path, storage_mode) = tokio::task::spawn_blocking({ - let conn = Arc::clone(&conn); + let conn = conn.clone(); let id_str = id_str.clone(); move || { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - 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)?)), - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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::<_, PinakesError>(row) } }) @@ -8207,7 +7826,7 @@ impl StorageBackend for SqliteBackend { })??; let old_path_buf = std::path::PathBuf::from(&old_path); - let parent = old_path_buf.parent().unwrap_or_else(|| Path::new("")); + 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(); @@ -8229,13 +7848,11 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - 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], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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::<_, PinakesError>(()) }) .await @@ -8256,20 +7873,18 @@ impl StorageBackend for SqliteBackend { let new_dir = new_directory.to_path_buf(); let (old_path, file_name, storage_mode) = tokio::task::spawn_blocking({ - let conn = Arc::clone(&conn); + let conn = conn.clone(); let id_str = id_str.clone(); move || { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - 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)?)), - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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::<_, PinakesError>(row) } }) @@ -8305,12 +7920,10 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn - .execute( - "UPDATE media_items SET path = ?1, updated_at = ?2 WHERE id = ?3", - params![new_path_str, now, id_str], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + conn.execute( + "UPDATE media_items SET path = ?1, updated_at = ?2 WHERE id = ?3", + params![new_path_str, now, id_str], + )?; Ok::<_, PinakesError>(()) }) .await @@ -8395,32 +8008,28 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn - .prepare( - "SELECT id, path, file_name, media_type, content_hash, file_size, + let mut stmt = conn.prepare( + "SELECT id, path, file_name, media_type, content_hash, file_size, title, artist, album, genre, year, duration_secs, \ - description, + description, thumbnail_path, created_at, updated_at, file_mtime, date_taken, latitude, longitude, camera_make, \ - camera_model, rating, + camera_model, rating, storage_mode, original_filename, uploaded_at, \ - storage_key, + storage_key, perceptual_hash, deleted_at FROM media_items WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC LIMIT ?1 OFFSET ?2", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; - let rows = stmt - .query_map( - params![limit.cast_signed(), offset.cast_signed()], - row_to_media_item, - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; + let rows = stmt.query_map( + params![limit.cast_signed(), offset.cast_signed()], + row_to_media_item, + )?; let mut items = Vec::new(); for row in rows { - items.push(row.map_err(|e| PinakesError::Database(e.to_string()))?); + items.push(row?); } Ok::<_, PinakesError>(items) }) @@ -8440,34 +8049,29 @@ impl StorageBackend for SqliteBackend { // 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") - .map_err(|e| PinakesError::Database(e.to_string()))?; + .prepare("SELECT id FROM media_items WHERE deleted_at IS NOT NULL")?; let ids: Vec = stmt - .query_map([], |row| row.get(0)) - .map_err(|e| PinakesError::Database(e.to_string()))? + .query_map([], |row| row.get(0))? .filter_map(std::result::Result::ok) .collect(); // Delete related data for id in &ids { conn - .execute("DELETE FROM media_tags WHERE media_id = ?1", params![id]) - .map_err(|e| PinakesError::Database(e.to_string()))?; + .execute("DELETE FROM media_tags WHERE media_id = ?1", params![id])?; + conn.execute( + "DELETE FROM collection_members WHERE media_id = ?1", + params![id], + )?; conn - .execute( - "DELETE FROM collection_members WHERE media_id = ?1", - params![id], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; - conn - .execute("DELETE FROM custom_fields WHERE media_id = ?1", params![id]) - .map_err(|e| PinakesError::Database(e.to_string()))?; + .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", []) - .map_err(|e| PinakesError::Database(e.to_string()))?; + .execute("DELETE FROM media_items WHERE deleted_at IS NOT NULL", [])?; Ok::<_, PinakesError>(count as u64) }) .await @@ -8489,42 +8093,35 @@ impl StorageBackend for SqliteBackend { })?; // 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", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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)) - .map_err(|e| PinakesError::Database(e.to_string()))? + .query_map(params![before_str], |row| row.get(0))? .filter_map(std::result::Result::ok) .collect(); // Delete related data for id in &ids { conn - .execute("DELETE FROM media_tags WHERE media_id = ?1", params![id]) - .map_err(|e| PinakesError::Database(e.to_string()))?; + .execute("DELETE FROM media_tags WHERE media_id = ?1", params![id])?; + conn.execute( + "DELETE FROM collection_members WHERE media_id = ?1", + params![id], + )?; conn - .execute( - "DELETE FROM collection_members WHERE media_id = ?1", - params![id], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; - conn - .execute("DELETE FROM custom_fields WHERE media_id = ?1", params![id]) - .map_err(|e| PinakesError::Database(e.to_string()))?; + .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], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + let count = conn.execute( + "DELETE FROM media_items WHERE deleted_at IS NOT NULL AND deleted_at \ + < ?1", + params![before_str], + )?; Ok::<_, PinakesError>(count as u64) }) .await @@ -8540,13 +8137,11 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM media_items WHERE deleted_at IS NOT NULL", - [], - |row| row.get(0), - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM media_items WHERE deleted_at IS NOT NULL", + [], + |row| row.get(0), + )?; Ok::<_, PinakesError>(count.cast_unsigned()) }) .await @@ -8570,46 +8165,38 @@ impl StorageBackend for SqliteBackend { })?; // Wrap DELETE + INSERT in transaction to ensure atomicity - let tx = conn - .transaction() - .map_err(|e| PinakesError::Database(e.to_string()))?; + let tx = conn.transaction()?; // Delete existing links for this source tx.execute("DELETE FROM markdown_links WHERE source_media_id = ?1", [ &media_id_str, - ]) - .map_err(|e| PinakesError::Database(e.to_string()))?; + ])?; // Insert new links - let mut stmt = tx - .prepare( - "INSERT INTO markdown_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)", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; 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(), - ]) - .map_err(|e| PinakesError::Database(e.to_string()))?; + 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() - .map_err(|e| PinakesError::Database(e.to_string()))?; + tx.commit()?; Ok::<_, PinakesError>(()) }) @@ -8632,23 +8219,19 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn - .prepare( - "SELECT id, source_media_id, target_path, target_media_id, + 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", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; - let rows = stmt - .query_map([&media_id_str], row_to_markdown_link) - .map_err(|e| PinakesError::Database(e.to_string()))?; + let rows = stmt.query_map([&media_id_str], row_to_markdown_link)?; let mut links = Vec::new(); for row in rows { - links.push(row.map_err(|e| PinakesError::Database(e.to_string()))?); + links.push(row?); } Ok::<_, PinakesError>(links) }) @@ -8671,46 +8254,42 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn - .prepare( - "SELECT l.id, l.source_media_id, m.title, m.path, + 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", - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + )?; - 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), - }) + 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), }) - .map_err(|e| PinakesError::Database(e.to_string()))?; + })?; let mut backlinks = Vec::new(); for row in rows { - backlinks.push(row.map_err(|e| PinakesError::Database(e.to_string()))?); + backlinks.push(row?); } Ok::<_, PinakesError>(backlinks) }) @@ -8731,8 +8310,7 @@ impl StorageBackend for SqliteBackend { conn .execute("DELETE FROM markdown_links WHERE source_media_id = ?1", [ &media_id_str, - ]) - .map_err(|e| PinakesError::Database(e.to_string()))?; + ])?; Ok::<_, PinakesError>(()) }) .await @@ -8756,8 +8334,10 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| PinakesError::Database(format!("connection mutex poisoned: {e}")))?; let mut nodes = Vec::new(); let mut edges = Vec::new(); + let mut node_ids = rustc_hash::FxHashSet::default(); + // Get nodes - either all markdown files or those connected to center - let node_ids = if let Some(center_id) = center_id_str { + if let Some(center_id) = center_id_str { // BFS to find connected nodes within depth let mut frontier = vec![center_id.clone()]; let mut visited = rustc_hash::FxHashSet::default(); @@ -8771,13 +8351,13 @@ impl StorageBackend for SqliteBackend { let mut stmt = conn.prepare( "SELECT target_media_id FROM markdown_links WHERE source_media_id = ?1 AND target_media_id IS NOT NULL", - ).map_err(|e| PinakesError::Database(e.to_string()))?; + )?; let rows = stmt.query_map([node_id], |row| { let id: String = row.get(0)?; Ok(id) - }).map_err(|e| PinakesError::Database(e.to_string()))?; + })?; for row in rows { - let id = row.map_err(|e| PinakesError::Database(e.to_string()))?; + let id = row?; if !visited.contains(&id) { visited.insert(id.clone()); next_frontier.push(id); @@ -8788,13 +8368,13 @@ impl StorageBackend for SqliteBackend { let mut stmt = conn.prepare( "SELECT source_media_id FROM markdown_links WHERE target_media_id = ?1", - ).map_err(|e| PinakesError::Database(e.to_string()))?; + )?; let rows = stmt.query_map([node_id], |row| { let id: String = row.get(0)?; Ok(id) - }).map_err(|e| PinakesError::Database(e.to_string()))?; + })?; for row in rows { - let id = row.map_err(|e| PinakesError::Database(e.to_string()))?; + let id = row?; if !visited.contains(&id) { visited.insert(id.clone()); next_frontier.push(id); @@ -8805,31 +8385,29 @@ impl StorageBackend for SqliteBackend { frontier = next_frontier; } - visited + node_ids = visited; } else { // Get all markdown files with links (limit to 500 for performance) - let mut ids = rustc_hash::FxHashSet::default(); let mut stmt = conn.prepare( "SELECT DISTINCT id FROM media_items WHERE media_type = 'markdown' AND deleted_at IS NULL LIMIT 500", - ).map_err(|e| PinakesError::Database(e.to_string()))?; + )?; let rows = stmt.query_map([], |row| { let id: String = row.get(0)?; Ok(id) - }).map_err(|e| PinakesError::Database(e.to_string()))?; + })?; for row in rows { - ids.insert(row.map_err(|e| PinakesError::Database(e.to_string()))?); + node_ids.insert(row?); } - ids - }; + } // Build nodes with metadata for node_id in &node_ids { let mut stmt = conn.prepare( "SELECT id, COALESCE(title, file_name) as label, title, media_type FROM media_items WHERE id = ?1", - ).map_err(|e| PinakesError::Database(e.to_string()))?; + )?; if let Ok((id, label, title, media_type)) = stmt.query_row([node_id], |row| { Ok(( row.get::<_, String>(0)?, @@ -8843,14 +8421,14 @@ impl StorageBackend for SqliteBackend { "SELECT COUNT(*) FROM markdown_links WHERE source_media_id = ?1", [&id], |row| row.get(0), - ).map_err(|e| PinakesError::Database(e.to_string()))?; + )?; // Count incoming links let backlink_count: i64 = conn.query_row( "SELECT COUNT(*) FROM markdown_links WHERE target_media_id = ?1", [&id], |row| row.get(0), - ).map_err(|e| PinakesError::Database(e.to_string()))?; + )?; nodes.push(crate::model::GraphNode { id: id.clone(), @@ -8869,15 +8447,15 @@ impl StorageBackend for SqliteBackend { "SELECT source_media_id, target_media_id, link_type FROM markdown_links WHERE source_media_id = ?1 AND target_media_id IS NOT NULL", - ).map_err(|e| PinakesError::Database(e.to_string()))?; + )?; let rows = stmt.query_map([node_id], |row| { let source: String = row.get(0)?; let target: String = row.get(1)?; let link_type_str: String = row.get(2)?; Ok((source, target, link_type_str)) - }).map_err(|e| PinakesError::Database(e.to_string()))?; + })?; for row in rows { - let (source, target, link_type_str) = row.map_err(|e| PinakesError::Database(e.to_string()))?; + let (source, target, link_type_str) = row?; if node_ids.contains(&target) { edges.push(crate::model::GraphEdge { source, @@ -8908,9 +8486,8 @@ impl StorageBackend for SqliteBackend { // Find unresolved links and try to resolve them // Strategy 1: Exact path match - let updated1 = conn - .execute( - "UPDATE markdown_links + let updated1 = conn.execute( + "UPDATE markdown_links SET target_media_id = ( SELECT id FROM media_items WHERE path = markdown_links.target_path @@ -8923,21 +8500,19 @@ impl StorageBackend for SqliteBackend { WHERE path = markdown_links.target_path AND deleted_at IS NULL )", - [], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + [], + )?; // Strategy 2: Filename match (Obsidian-style) // Match target_path to file_name (with or without .md extension) - let updated2 = conn - .execute( - "UPDATE markdown_links + 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) + markdown_links.target_path) AND deleted_at IS NULL LIMIT 1 ) @@ -8947,12 +8522,11 @@ impl StorageBackend for SqliteBackend { WHERE (file_name = markdown_links.target_path OR file_name = markdown_links.target_path || '.md' OR REPLACE(file_name, '.md', '') = \ - markdown_links.target_path) + markdown_links.target_path) AND deleted_at IS NULL )", - [], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + [], + )?; Ok::<_, PinakesError>((updated1 + updated2) as u64) }) @@ -8971,12 +8545,10 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn - .execute( - "UPDATE media_items SET links_extracted_at = ?1 WHERE id = ?2", - params![now, media_id_str], - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + conn.execute( + "UPDATE media_items SET links_extracted_at = ?1 WHERE id = ?2", + params![now, media_id_str], + )?; Ok::<_, PinakesError>(()) }) .await @@ -8994,13 +8566,11 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM markdown_links WHERE target_media_id IS NULL", - [], - |row| row.get(0), - ) - .map_err(|e| PinakesError::Database(e.to_string()))?; + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM markdown_links WHERE target_media_id IS NULL", + [], + |row| row.get(0), + )?; Ok::<_, PinakesError>(count.cast_unsigned()) }) .await @@ -9019,8 +8589,7 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - db.execute("VACUUM INTO ?1", params![dest.to_string_lossy()]) - .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute("VACUUM INTO ?1", params![dest.to_string_lossy()])?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_mins(5), fut) @@ -9075,6 +8644,11 @@ fn row_to_share(row: &Row) -> rusqlite::Result { let password_hash: Option = row.get(7)?; let target = match target_type.as_str() { + "media" => { + crate::sharing::ShareTarget::Media { + media_id: MediaId(parse_uuid(&target_id_str)?), + } + }, "collection" => { crate::sharing::ShareTarget::Collection { collection_id: parse_uuid(&target_id_str)?, @@ -9098,6 +8672,12 @@ fn row_to_share(row: &Row) -> rusqlite::Result { }; let recipient = match recipient_type.as_str() { + "public_link" => { + crate::sharing::ShareRecipient::PublicLink { + token: public_token.unwrap_or_default(), + password_hash, + } + }, "user" => { crate::sharing::ShareRecipient::User { user_id: crate::users::UserId(parse_uuid( diff --git a/crates/pinakes-core/src/subtitles.rs b/crates/pinakes-core/src/subtitles.rs index d6ddb9c..76cc8b5 100644 --- a/crates/pinakes-core/src/subtitles.rs +++ b/crates/pinakes-core/src/subtitles.rs @@ -172,8 +172,9 @@ pub async fn list_embedded_tracks( } })?; - let Some(streams) = json.get("streams").and_then(|s| s.as_array()) else { - return Ok(vec![]); + let streams = match json.get("streams").and_then(|s| s.as_array()) { + Some(s) => s, + None => return Ok(vec![]), }; let mut tracks = Vec::new(); @@ -202,7 +203,7 @@ pub async fn list_embedded_tracks( .map(str::to_owned); tracks.push(SubtitleTrackInfo { - index: u32::try_from(idx).unwrap_or(u32::MAX), + index: idx as u32, language, format, title, diff --git a/crates/pinakes-sync/src/chunked.rs b/crates/pinakes-core/src/sync/chunked.rs similarity index 98% rename from crates/pinakes-sync/src/chunked.rs rename to crates/pinakes-core/src/sync/chunked.rs index 7fb7802..e3e29a2 100644 --- a/crates/pinakes-sync/src/chunked.rs +++ b/crates/pinakes-core/src/sync/chunked.rs @@ -3,7 +3,6 @@ use std::path::{Path, PathBuf}; use chrono::Utc; -use pinakes_types::error::{PinakesError, Result}; use tokio::{ fs, io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}, @@ -12,6 +11,7 @@ use tracing::{debug, info}; use uuid::Uuid; use super::{ChunkInfo, UploadSession}; +use crate::error::{PinakesError, Result}; /// Manager for chunked uploads. #[derive(Debug, Clone)] @@ -271,11 +271,10 @@ async fn compute_file_hash(path: &Path) -> Result { #[cfg(test)] mod tests { - use pinakes_types::model::ContentHash; use tempfile::tempdir; use super::*; - use crate::UploadStatus; + use crate::{model::ContentHash, sync::UploadStatus}; #[tokio::test] async fn test_chunked_upload() { diff --git a/crates/pinakes-sync/src/conflict.rs b/crates/pinakes-core/src/sync/conflict.rs similarity index 95% rename from crates/pinakes-sync/src/conflict.rs rename to crates/pinakes-core/src/sync/conflict.rs index 0a9993b..eab7787 100644 --- a/crates/pinakes-sync/src/conflict.rs +++ b/crates/pinakes-core/src/sync/conflict.rs @@ -1,8 +1,7 @@ //! Conflict detection and resolution for sync. -use pinakes_types::config::ConflictResolution; - use super::DeviceSyncState; +use crate::config::ConflictResolution; /// Detect if there's a conflict between local and server state. #[must_use] @@ -94,14 +93,15 @@ pub const fn resolve_by_mtime(conflict: &ConflictInfo) -> ConflictOutcome { } }, (Some(_), None) => ConflictOutcome::UseLocal, - (None, Some(_) | None) => ConflictOutcome::UseServer, // Default to server + (None, Some(_)) => ConflictOutcome::UseServer, + (None, None) => ConflictOutcome::UseServer, // Default to server } } #[cfg(test)] mod tests { use super::*; - use crate::FileSyncStatus; + use crate::sync::FileSyncStatus; #[test] fn test_generate_conflict_path() { diff --git a/crates/pinakes-core/src/sync/mod.rs b/crates/pinakes-core/src/sync/mod.rs index b19f5d7..77181f1 100644 --- a/crates/pinakes-core/src/sync/mod.rs +++ b/crates/pinakes-core/src/sync/mod.rs @@ -2,10 +2,13 @@ //! //! Provides device registration, change tracking, and conflict resolution //! for syncing media libraries across multiple devices. -//! -//! Pure domain types and non-storage logic live in `pinakes-sync`. -//! Protocol functions that need `DynStorageBackend` stay in this module. +mod chunked; +mod conflict; +mod models; mod protocol; +pub use chunked::*; +pub use conflict::*; +pub use models::*; pub use protocol::*; diff --git a/crates/pinakes-sync/src/models.rs b/crates/pinakes-core/src/sync/models.rs similarity index 98% rename from crates/pinakes-sync/src/models.rs rename to crates/pinakes-core/src/sync/models.rs index 6588d47..5814c20 100644 --- a/crates/pinakes-sync/src/models.rs +++ b/crates/pinakes-core/src/sync/models.rs @@ -3,13 +3,15 @@ use std::fmt; use chrono::{DateTime, Utc}; -use pinakes_types::{ - config::ConflictResolution, - model::{ContentHash, MediaId, UserId}, -}; use serde::{Deserialize, Serialize}; use uuid::Uuid; +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); @@ -364,7 +366,7 @@ impl UploadSession { chunk_count, status: UploadStatus::Pending, created_at: now, - expires_at: now + chrono::Duration::hours(timeout_hours.cast_signed()), + expires_at: now + chrono::Duration::hours(timeout_hours as i64), last_activity: now, } } diff --git a/crates/pinakes-core/src/sync/protocol.rs b/crates/pinakes-core/src/sync/protocol.rs index 5e796b2..5c0c486 100644 --- a/crates/pinakes-core/src/sync/protocol.rs +++ b/crates/pinakes-core/src/sync/protocol.rs @@ -3,16 +3,16 @@ //! Handles the bidirectional sync protocol between clients and server. use chrono::Utc; -use pinakes_sync::{ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::{ DeviceId, DeviceSyncState, FileSyncStatus, SyncChangeType, SyncLogEntry, }; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - use crate::{ error::Result, model::{ContentHash, MediaId}, diff --git a/crates/pinakes-core/src/thumbnail.rs b/crates/pinakes-core/src/thumbnail.rs index 118b568..3c79b25 100644 --- a/crates/pinakes-core/src/thumbnail.rs +++ b/crates/pinakes-core/src/thumbnail.rs @@ -367,7 +367,7 @@ pub enum CoverSize { impl CoverSize { #[must_use] - pub const fn dimensions(self) -> Option<(u32, u32)> { + pub const fn dimensions(&self) -> Option<(u32, u32)> { match self { Self::Tiny => Some((64, 64)), Self::Grid => Some((320, 320)), @@ -377,7 +377,7 @@ impl CoverSize { } #[must_use] - pub const fn filename(self) -> &'static str { + pub const fn filename(&self) -> &'static str { match self { Self::Tiny => "tiny.jpg", Self::Grid => "grid.jpg", @@ -541,7 +541,7 @@ pub enum ThumbnailSize { impl ThumbnailSize { /// Get the pixel size for this thumbnail variant #[must_use] - pub const fn pixels(self) -> u32 { + pub const fn pixels(&self) -> u32 { match self { Self::Tiny => 64, Self::Grid => 320, @@ -551,7 +551,7 @@ impl ThumbnailSize { /// Get the subdirectory name for this size #[must_use] - pub const fn subdir_name(self) -> &'static str { + pub const fn subdir_name(&self) -> &'static str { match self { Self::Tiny => "tiny", Self::Grid => "grid", diff --git a/crates/pinakes-core/src/transcode.rs b/crates/pinakes-core/src/transcode.rs index 1edfd8d..416c1a6 100644 --- a/crates/pinakes-core/src/transcode.rs +++ b/crates/pinakes-core/src/transcode.rs @@ -103,9 +103,9 @@ impl TranscodeService { pub fn new(config: TranscodingConfig) -> Self { let max_concurrent = config.max_concurrent.max(1); Self { - config, sessions: Arc::new(RwLock::new(FxHashMap::default())), semaphore: Arc::new(Semaphore::new(max_concurrent)), + config, } } diff --git a/crates/pinakes-core/src/upload.rs b/crates/pinakes-core/src/upload.rs index ad2d162..837b34d 100644 --- a/crates/pinakes-core/src/upload.rs +++ b/crates/pinakes-core/src/upload.rs @@ -13,6 +13,7 @@ use crate::{ error::{PinakesError, Result}, managed_storage::ManagedStorageService, media_type::MediaType, + metadata, model::{MediaId, MediaItem, StorageMode, UploadResult}, storage::DynStorageBackend, }; @@ -57,8 +58,7 @@ pub async fn process_upload( let blob_path = managed.path(&content_hash); // Extract metadata - let extracted = - pinakes_metadata::extract_metadata(&blob_path, &media_type).ok(); + let extracted = metadata::extract_metadata(&blob_path, &media_type).ok(); // Create or get blob record let mime = mime_type.map_or_else(|| media_type.mime_type(), String::from); diff --git a/crates/pinakes-core/src/users.rs b/crates/pinakes-core/src/users.rs index 2c3a774..030bd46 100644 --- a/crates/pinakes-core/src/users.rs +++ b/crates/pinakes-core/src/users.rs @@ -1,15 +1,45 @@ //! User management and authentication use chrono::{DateTime, Utc}; -pub use pinakes_types::model::UserId; use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; +use uuid::Uuid; 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 { + /// Creates a new user ID. + #[must_use] + pub fn new() -> Self { + Self(Uuid::now_v7()) + } +} + +impl Default for UserId { + 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) + } +} + +impl From for UserId { + fn from(id: Uuid) -> Self { + Self(id) + } +} + /// User account with profile information #[derive(Debug, Clone, Serialize, Deserialize)] pub struct User { @@ -67,24 +97,24 @@ pub enum LibraryPermission { impl LibraryPermission { /// Checks if read permission is granted. #[must_use] - pub const fn can_read() -> bool { + pub const fn can_read(&self) -> bool { true } /// Checks if write permission is granted. #[must_use] - pub const fn can_write(self) -> bool { + pub const fn can_write(&self) -> bool { matches!(self, Self::Write | Self::Admin) } /// Checks if admin permission is granted. #[must_use] - pub const fn can_admin(self) -> bool { + pub const fn can_admin(&self) -> bool { matches!(self, Self::Admin) } #[must_use] - pub const fn as_str(self) -> &'static str { + pub const fn as_str(&self) -> &'static str { match self { Self::Read => "read", Self::Write => "write", @@ -132,10 +162,6 @@ pub mod auth { use super::{PinakesError, Result}; /// Hash a password using Argon2 - /// - /// # Errors - /// - /// Returns an error if password hashing fails. pub fn hash_password(password: &str) -> Result { use argon2::{ Argon2, @@ -154,10 +180,6 @@ pub mod auth { } /// Verify a password against a hash - /// - /// # Errors - /// - /// Returns an error if the hash is invalid or cannot be parsed. pub fn verify_password(password: &str, hash: &str) -> Result { use argon2::{ Argon2, @@ -201,17 +223,17 @@ mod tests { #[test] fn test_library_permission_levels() { let read = LibraryPermission::Read; - assert!(LibraryPermission::can_read()); + assert!(read.can_read()); assert!(!read.can_write()); assert!(!read.can_admin()); let write = LibraryPermission::Write; - assert!(LibraryPermission::can_read()); + assert!(write.can_read()); assert!(write.can_write()); assert!(!write.can_admin()); let admin = LibraryPermission::Admin; - assert!(LibraryPermission::can_read()); + assert!(admin.can_read()); assert!(admin.can_write()); assert!(admin.can_admin()); } diff --git a/crates/pinakes-core/src/webhooks.rs b/crates/pinakes-core/src/webhooks.rs index fae9669..424d86a 100644 --- a/crates/pinakes-core/src/webhooks.rs +++ b/crates/pinakes-core/src/webhooks.rs @@ -69,7 +69,7 @@ impl WebhookDispatcher { /// Dispatch an event to all matching webhooks. /// This is fire-and-forget, errors are logged but not propagated. pub fn dispatch(self: &Arc, event: WebhookEvent) { - let this = Arc::clone(self); + let this = self.clone(); tokio::spawn(async move { this.dispatch_inner(&event).await; }); diff --git a/crates/pinakes-core/tests/book_metadata.rs b/crates/pinakes-core/tests/book_metadata.rs index 81e156c..2223441 100644 --- a/crates/pinakes-core/tests/book_metadata.rs +++ b/crates/pinakes-core/tests/book_metadata.rs @@ -1,12 +1,12 @@ 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}, }; -use pinakes_enrichment::{ - books::BookEnricher, - googlebooks::GoogleBooksClient, - openlibrary::OpenLibraryClient, -}; #[test] fn test_isbn_normalization() { diff --git a/crates/pinakes-core/tests/integration.rs b/crates/pinakes-core/tests/integration.rs index 78b6f1a..9033f9c 100644 --- a/crates/pinakes-core/tests/integration.rs +++ b/crates/pinakes-core/tests/integration.rs @@ -841,10 +841,10 @@ async fn test_external_metadata() { let item = make_test_media("enrich1"); storage.insert_media(&item).await.unwrap(); - let meta = pinakes_enrichment::ExternalMetadata { + let meta = pinakes_core::enrichment::ExternalMetadata { id: uuid::Uuid::now_v7(), media_id: item.id, - source: pinakes_enrichment::EnrichmentSourceType::MusicBrainz, + source: pinakes_core::enrichment::EnrichmentSourceType::MusicBrainz, external_id: Some("mb-123".to_string()), metadata_json: r#"{"title":"Test"}"#.to_string(), confidence: 0.85, @@ -857,7 +857,7 @@ async fn test_external_metadata() { assert_eq!(metas.len(), 1); assert_eq!( metas[0].source, - pinakes_enrichment::EnrichmentSourceType::MusicBrainz + 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); diff --git a/crates/pinakes-core/tests/plugin_integration.rs b/crates/pinakes-core/tests/plugin_integration.rs index 5130e33..c48a250 100644 --- a/crates/pinakes-core/tests/plugin_integration.rs +++ b/crates/pinakes-core/tests/plugin_integration.rs @@ -9,9 +9,10 @@ #![allow(clippy::print_stderr, reason = "Fine for tests")] use std::{path::Path, sync::Arc}; -use pinakes_core::plugin::PluginPipeline; -use pinakes_plugin::{PluginManager, PluginManagerConfig}; -use pinakes_types::config::PluginTimeoutConfig; +use pinakes_core::{ + config::PluginTimeoutConfig, + plugin::{PluginManager, PluginManagerConfig, PluginPipeline}, +}; use tempfile::TempDir; /// Path to the compiled test plugin fixture. diff --git a/crates/pinakes-enrichment/Cargo.toml b/crates/pinakes-enrichment/Cargo.toml deleted file mode 100644 index bffcdc1..0000000 --- a/crates/pinakes-enrichment/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -name = "pinakes-enrichment" -edition.workspace = true -version.workspace = true -license.workspace = true - -[dependencies] -pinakes-types = { workspace = true } -reqwest = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -tokio = { workspace = true } -tracing = { workspace = true } -url = { workspace = true } -chrono = { workspace = true } -uuid = { workspace = true } -async-trait = { workspace = true } -regex = { workspace = true } -urlencoding = { workspace = true } - -[lints] -workspace = true diff --git a/crates/pinakes-enrichment/src/lib.rs b/crates/pinakes-enrichment/src/lib.rs deleted file mode 100644 index ed6c3dc..0000000 --- a/crates/pinakes-enrichment/src/lib.rs +++ /dev/null @@ -1,76 +0,0 @@ -pub mod books; -pub mod googlebooks; -pub mod lastfm; -pub mod musicbrainz; -pub mod openlibrary; -pub mod tmdb; - -use chrono::{DateTime, Utc}; -use pinakes_types::{ - error::Result, - model::{MediaId, MediaItem}, -}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -/// 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, -} - -/// 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, -} - -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}") - } -} - -impl std::str::FromStr for EnrichmentSourceType { - 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}")), - } - } -} - -/// 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>; -} diff --git a/crates/pinakes-metadata/Cargo.toml b/crates/pinakes-metadata/Cargo.toml deleted file mode 100644 index 5c26a23..0000000 --- a/crates/pinakes-metadata/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "pinakes-metadata" -edition.workspace = true -version.workspace = true -license.workspace = true - -[dependencies] -pinakes-types = { workspace = true } -lofty = { workspace = true } -lopdf = { workspace = true } -epub = { workspace = true } -matroska = { workspace = true } -image = { workspace = true } -kamadak-exif = { workspace = true } -gray_matter = { workspace = true } -rustc-hash = { workspace = true } -chrono = { workspace = true } -image_hasher = { workspace = true } -tracing = { workspace = true } -regex = { workspace = true } - -[lints] -workspace = true diff --git a/crates/pinakes-metadata/src/lib.rs b/crates/pinakes-metadata/src/lib.rs deleted file mode 100644 index 7a89362..0000000 --- a/crates/pinakes-metadata/src/lib.rs +++ /dev/null @@ -1,73 +0,0 @@ -pub mod audio; -pub mod document; -pub mod image; -pub mod markdown; -pub mod video; - -use std::path::Path; - -use pinakes_types::{ - error::Result, - media_type::MediaType, - model::BookMetadata, -}; -use rustc_hash::FxHashMap; - -#[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: FxHashMap, - 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, -} - -pub trait MetadataExtractor: Send + Sync { - /// Extract metadata from a file at the given path. - /// - /// # Errors - /// - /// Returns an error if the file cannot be read or parsed. - fn extract(&self, path: &Path) -> Result; - fn supported_types(&self) -> Vec; -} - -/// Extract metadata from a file using the appropriate extractor for the given -/// media type. -/// -/// # Errors -/// -/// Returns an error if extraction fails. Returns a default `ExtractedMetadata` -/// when no extractor supports the media type. -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); - } - } - - Ok(ExtractedMetadata::default()) -} diff --git a/crates/pinakes-migrations/Cargo.toml b/crates/pinakes-migrations/Cargo.toml deleted file mode 100644 index 5e65ed9..0000000 --- a/crates/pinakes-migrations/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "pinakes-migrations" -edition.workspace = true -version.workspace = true -license.workspace = true -publish = false - -[dependencies] -rusqlite = { workspace = true } -tokio-postgres = { workspace = true } -rusqlite_migration = { workspace = true } -refinery = { workspace = true } - -[lints] -workspace = true diff --git a/crates/pinakes-migrations/migrations/postgres/V11__session_persistence.sql b/crates/pinakes-migrations/migrations/postgres/V11__session_persistence.sql deleted file mode 100644 index d9b5f69..0000000 --- a/crates/pinakes-migrations/migrations/postgres/V11__session_persistence.sql +++ /dev/null @@ -1,17 +0,0 @@ --- Session persistence for database-backed sessions --- Replaces in-memory session storage -CREATE TABLE IF NOT EXISTS sessions ( - session_token TEXT PRIMARY KEY NOT NULL, - user_id TEXT, - username TEXT NOT NULL, - role TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL, - expires_at TIMESTAMPTZ NOT NULL, - last_accessed TIMESTAMPTZ NOT NULL -); - --- Index for efficient cleanup of expired sessions -CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions (expires_at); - --- Index for listing sessions by username -CREATE INDEX IF NOT EXISTS idx_sessions_username ON sessions (username); diff --git a/crates/pinakes-migrations/migrations/postgres/V12__book_management.sql b/crates/pinakes-migrations/migrations/postgres/V12__book_management.sql deleted file mode 100644 index 71e29f5..0000000 --- a/crates/pinakes-migrations/migrations/postgres/V12__book_management.sql +++ /dev/null @@ -1,61 +0,0 @@ --- V12: Book Management Schema (PostgreSQL) --- Adds comprehensive book metadata tracking, authors, and identifiers --- Book metadata (supplements media_items for EPUB/PDF/MOBI) -CREATE TABLE book_metadata ( - media_id UUID PRIMARY KEY REFERENCES media_items (id) ON DELETE CASCADE, - isbn TEXT, - isbn13 TEXT, -- Normalized ISBN-13 for lookups - publisher TEXT, - language TEXT, -- ISO 639-1 code - page_count INTEGER, - publication_date DATE, - series_name TEXT, - series_index DOUBLE PRECISION, -- Supports 1.5, etc. - format TEXT, -- 'epub', 'pdf', 'mobi', 'azw3' - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX idx_book_isbn13 ON book_metadata (isbn13); - -CREATE INDEX idx_book_series ON book_metadata (series_name, series_index); - -CREATE INDEX idx_book_publisher ON book_metadata (publisher); - -CREATE INDEX idx_book_language ON book_metadata (language); - --- Multiple authors per book (many-to-many) -CREATE TABLE book_authors ( - media_id UUID NOT NULL REFERENCES media_items (id) ON DELETE CASCADE, - author_name TEXT NOT NULL, - author_sort TEXT, -- "Last, First" for sorting - role TEXT NOT NULL DEFAULT 'author', -- author, translator, editor, illustrator - position INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (media_id, author_name, role) -); - -CREATE INDEX idx_book_authors_name ON book_authors (author_name); - -CREATE INDEX idx_book_authors_sort ON book_authors (author_sort); - --- Multiple identifiers (ISBN variants, ASIN, DOI, etc.) -CREATE TABLE book_identifiers ( - media_id UUID NOT NULL REFERENCES media_items (id) ON DELETE CASCADE, - identifier_type TEXT NOT NULL, -- isbn, isbn13, asin, doi, lccn, oclc - identifier_value TEXT NOT NULL, - PRIMARY KEY (media_id, identifier_type, identifier_value) -); - -CREATE INDEX idx_book_identifiers ON book_identifiers (identifier_type, identifier_value); - --- Trigger to update updated_at on book_metadata changes -CREATE OR REPLACE FUNCTION update_book_metadata_timestamp () RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER update_book_metadata_timestamp BEFORE -UPDATE ON book_metadata FOR EACH ROW -EXECUTE FUNCTION update_book_metadata_timestamp (); diff --git a/crates/pinakes-migrations/migrations/postgres/V13__photo_metadata.sql b/crates/pinakes-migrations/migrations/postgres/V13__photo_metadata.sql deleted file mode 100644 index 7c66bb8..0000000 --- a/crates/pinakes-migrations/migrations/postgres/V13__photo_metadata.sql +++ /dev/null @@ -1,40 +0,0 @@ --- V13: Enhanced photo metadata support --- Add photo-specific fields to media_items table -ALTER TABLE media_items -ADD COLUMN date_taken TIMESTAMPTZ; - -ALTER TABLE media_items -ADD COLUMN latitude DOUBLE PRECISION; - -ALTER TABLE media_items -ADD COLUMN longitude DOUBLE PRECISION; - -ALTER TABLE media_items -ADD COLUMN camera_make TEXT; - -ALTER TABLE media_items -ADD COLUMN camera_model TEXT; - -ALTER TABLE media_items -ADD COLUMN rating INTEGER CHECK ( - rating >= 0 - AND rating <= 5 -); - --- Indexes for photo queries -CREATE INDEX idx_media_date_taken ON media_items (date_taken) -WHERE - date_taken IS NOT NULL; - -CREATE INDEX idx_media_location ON media_items (latitude, longitude) -WHERE - latitude IS NOT NULL - AND longitude IS NOT NULL; - -CREATE INDEX idx_media_camera ON media_items (camera_make) -WHERE - camera_make IS NOT NULL; - -CREATE INDEX idx_media_rating ON media_items (rating) -WHERE - rating IS NOT NULL; diff --git a/crates/pinakes-migrations/migrations/postgres/V14__perceptual_hash.sql b/crates/pinakes-migrations/migrations/postgres/V14__perceptual_hash.sql deleted file mode 100644 index 1d3c634..0000000 --- a/crates/pinakes-migrations/migrations/postgres/V14__perceptual_hash.sql +++ /dev/null @@ -1,9 +0,0 @@ --- V14: Perceptual hash for duplicate detection --- Add perceptual hash column for image similarity detection -ALTER TABLE media_items -ADD COLUMN perceptual_hash TEXT; - --- Index for perceptual hash lookups -CREATE INDEX idx_media_phash ON media_items (perceptual_hash) -WHERE - perceptual_hash IS NOT NULL; diff --git a/crates/pinakes-migrations/migrations/postgres/V16__sync_system.sql b/crates/pinakes-migrations/migrations/postgres/V16__sync_system.sql deleted file mode 100644 index 8647e12..0000000 --- a/crates/pinakes-migrations/migrations/postgres/V16__sync_system.sql +++ /dev/null @@ -1,122 +0,0 @@ --- V16: Cross-Device Sync System --- Adds device registration, change tracking, and chunked upload support --- Sync devices table -CREATE TABLE sync_devices ( - id TEXT PRIMARY KEY NOT NULL, - user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, - name TEXT NOT NULL, - device_type TEXT NOT NULL, - client_version TEXT NOT NULL, - os_info TEXT, - device_token_hash TEXT NOT NULL UNIQUE, - last_sync_at TIMESTAMPTZ, - last_seen_at TIMESTAMPTZ NOT NULL, - sync_cursor BIGINT DEFAULT 0, - enabled BOOLEAN NOT NULL DEFAULT TRUE, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL -); - -CREATE INDEX idx_sync_devices_user ON sync_devices (user_id); - -CREATE INDEX idx_sync_devices_token ON sync_devices (device_token_hash); - --- Sync log table - tracks all changes for sync -CREATE TABLE sync_log ( - id TEXT PRIMARY KEY NOT NULL, - sequence BIGSERIAL UNIQUE NOT NULL, - change_type TEXT NOT NULL, - media_id TEXT REFERENCES media_items (id) ON DELETE SET NULL, - path TEXT NOT NULL, - content_hash TEXT, - file_size BIGINT, - metadata_json TEXT, - changed_by_device TEXT REFERENCES sync_devices (id) ON DELETE SET NULL, - timestamp TIMESTAMPTZ NOT NULL -); - -CREATE INDEX idx_sync_log_sequence ON sync_log (sequence); - -CREATE INDEX idx_sync_log_path ON sync_log (path); - -CREATE INDEX idx_sync_log_timestamp ON sync_log (timestamp); - --- Sequence counter for sync log -CREATE TABLE sync_sequence ( - id INTEGER PRIMARY KEY CHECK (id = 1), - current_value BIGINT NOT NULL DEFAULT 0 -); - -INSERT INTO - sync_sequence (id, current_value) -VALUES - (1, 0); - --- Device sync state - tracks sync status per device per file -CREATE TABLE device_sync_state ( - device_id TEXT NOT NULL REFERENCES sync_devices (id) ON DELETE CASCADE, - path TEXT NOT NULL, - local_hash TEXT, - server_hash TEXT, - local_mtime BIGINT, - server_mtime BIGINT, - sync_status TEXT NOT NULL, - last_synced_at TIMESTAMPTZ, - conflict_info_json TEXT, - PRIMARY KEY (device_id, path) -); - -CREATE INDEX idx_device_sync_status ON device_sync_state (device_id, sync_status); - --- Upload sessions for chunked uploads -CREATE TABLE upload_sessions ( - id TEXT PRIMARY KEY NOT NULL, - device_id TEXT NOT NULL REFERENCES sync_devices (id) ON DELETE CASCADE, - target_path TEXT NOT NULL, - expected_hash TEXT NOT NULL, - expected_size BIGINT NOT NULL, - chunk_size BIGINT NOT NULL, - chunk_count BIGINT NOT NULL, - status TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL, - expires_at TIMESTAMPTZ NOT NULL, - last_activity TIMESTAMPTZ NOT NULL -); - -CREATE INDEX idx_upload_sessions_device ON upload_sessions (device_id); - -CREATE INDEX idx_upload_sessions_status ON upload_sessions (status); - -CREATE INDEX idx_upload_sessions_expires ON upload_sessions (expires_at); - --- Upload chunks - tracks received chunks -CREATE TABLE upload_chunks ( - upload_id TEXT NOT NULL REFERENCES upload_sessions (id) ON DELETE CASCADE, - chunk_index BIGINT NOT NULL, - offset - BIGINT NOT NULL, - size BIGINT NOT NULL, - hash TEXT NOT NULL, - received_at TIMESTAMPTZ NOT NULL, - PRIMARY KEY (upload_id, chunk_index) -); - --- Sync conflicts -CREATE TABLE sync_conflicts ( - id TEXT PRIMARY KEY NOT NULL, - device_id TEXT NOT NULL REFERENCES sync_devices (id) ON DELETE CASCADE, - path TEXT NOT NULL, - local_hash TEXT NOT NULL, - local_mtime BIGINT NOT NULL, - server_hash TEXT NOT NULL, - server_mtime BIGINT NOT NULL, - detected_at TIMESTAMPTZ NOT NULL, - resolved_at TIMESTAMPTZ, - resolution TEXT -); - -CREATE INDEX idx_sync_conflicts_device ON sync_conflicts (device_id); - -CREATE INDEX idx_sync_conflicts_unresolved ON sync_conflicts (device_id) -WHERE - resolved_at IS NULL; diff --git a/crates/pinakes-migrations/migrations/postgres/V17__enhanced_sharing.sql b/crates/pinakes-migrations/migrations/postgres/V17__enhanced_sharing.sql deleted file mode 100644 index b068ae7..0000000 --- a/crates/pinakes-migrations/migrations/postgres/V17__enhanced_sharing.sql +++ /dev/null @@ -1,100 +0,0 @@ --- V17: Enhanced Sharing System --- Replaces simple share_links with comprehensive sharing capabilities --- Enhanced shares table -CREATE TABLE shares ( - id TEXT PRIMARY KEY NOT NULL, - target_type TEXT NOT NULL CHECK ( - target_type IN ('media', 'collection', 'tag', 'saved_search') - ), - target_id TEXT NOT NULL, - owner_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, - recipient_type TEXT NOT NULL CHECK ( - recipient_type IN ('public_link', 'user', 'group', 'federated') - ), - recipient_user_id TEXT REFERENCES users (id) ON DELETE CASCADE, - recipient_group_id TEXT, - recipient_federated_handle TEXT, - recipient_federated_server TEXT, - public_token TEXT UNIQUE, - public_password_hash TEXT, - perm_view BOOLEAN NOT NULL DEFAULT TRUE, - perm_download BOOLEAN NOT NULL DEFAULT FALSE, - perm_edit BOOLEAN NOT NULL DEFAULT FALSE, - perm_delete BOOLEAN NOT NULL DEFAULT FALSE, - perm_reshare BOOLEAN NOT NULL DEFAULT FALSE, - perm_add BOOLEAN NOT NULL DEFAULT FALSE, - note TEXT, - expires_at TIMESTAMPTZ, - access_count BIGINT NOT NULL DEFAULT 0, - last_accessed TIMESTAMPTZ, - inherit_to_children BOOLEAN NOT NULL DEFAULT TRUE, - parent_share_id TEXT REFERENCES shares (id) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL, - UNIQUE ( - owner_id, - target_type, - target_id, - recipient_type, - recipient_user_id - ) -); - -CREATE INDEX idx_shares_owner ON shares (owner_id); - -CREATE INDEX idx_shares_recipient_user ON shares (recipient_user_id); - -CREATE INDEX idx_shares_target ON shares (target_type, target_id); - -CREATE INDEX idx_shares_token ON shares (public_token); - -CREATE INDEX idx_shares_expires ON shares (expires_at); - --- Share activity log -CREATE TABLE share_activity ( - id TEXT PRIMARY KEY NOT NULL, - share_id TEXT NOT NULL REFERENCES shares (id) ON DELETE CASCADE, - actor_id TEXT REFERENCES users (id) ON DELETE SET NULL, - actor_ip TEXT, - action TEXT NOT NULL, - details TEXT, - timestamp TIMESTAMPTZ NOT NULL -); - -CREATE INDEX idx_share_activity_share ON share_activity (share_id); - -CREATE INDEX idx_share_activity_timestamp ON share_activity (timestamp); - --- Share notifications -CREATE TABLE share_notifications ( - id TEXT PRIMARY KEY NOT NULL, - user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, - share_id TEXT NOT NULL REFERENCES shares (id) ON DELETE CASCADE, - notification_type TEXT NOT NULL, - is_read BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMPTZ NOT NULL -); - -CREATE INDEX idx_share_notifications_user ON share_notifications (user_id); - -CREATE INDEX idx_share_notifications_unread ON share_notifications (user_id) -WHERE - is_read = FALSE; - --- Migrate existing share_links to new shares table -DO $$ -BEGIN - IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'share_links') THEN - INSERT INTO shares ( - id, target_type, target_id, owner_id, recipient_type, - public_token, public_password_hash, perm_view, perm_download, - access_count, expires_at, created_at, updated_at - ) - SELECT - id, 'media', media_id, created_by, 'public_link', - token, password_hash, TRUE, TRUE, - view_count, expires_at, created_at, created_at - FROM share_links - ON CONFLICT DO NOTHING; - END IF; -END $$; diff --git a/crates/pinakes-migrations/migrations/postgres/V19__markdown_links.sql b/crates/pinakes-migrations/migrations/postgres/V19__markdown_links.sql deleted file mode 100644 index 084726e..0000000 --- a/crates/pinakes-migrations/migrations/postgres/V19__markdown_links.sql +++ /dev/null @@ -1,35 +0,0 @@ --- V19: Markdown Links (Obsidian-style bidirectional links) --- Adds support for wikilinks, markdown links, embeds, and backlink tracking --- Table for storing extracted markdown links -CREATE TABLE IF NOT EXISTS markdown_links ( - id TEXT PRIMARY KEY NOT NULL, - source_media_id TEXT NOT NULL, - target_path TEXT NOT NULL, -- raw link target (wikilink or path) - target_media_id TEXT, -- resolved media_id (nullable if unresolved) - link_type TEXT NOT NULL, -- 'wikilink', 'markdown_link', 'embed' - link_text TEXT, -- display text for the link - line_number INTEGER, -- line number in source file - context TEXT, -- surrounding text for preview - created_at TIMESTAMPTZ NOT NULL, - FOREIGN KEY (source_media_id) REFERENCES media_items (id) ON DELETE CASCADE, - FOREIGN KEY (target_media_id) REFERENCES media_items (id) ON DELETE SET NULL -); - --- Index for efficient outgoing link queries (what does this note link to?) -CREATE INDEX idx_links_source ON markdown_links (source_media_id); - --- Index for efficient backlink queries (what links to this note?) -CREATE INDEX idx_links_target ON markdown_links (target_media_id); - --- Index for path-based resolution (finding unresolved links) -CREATE INDEX idx_links_target_path ON markdown_links (target_path); - --- Index for link type filtering -CREATE INDEX idx_links_type ON markdown_links (link_type); - --- Track when links were last extracted from a media item -ALTER TABLE media_items -ADD COLUMN links_extracted_at TIMESTAMPTZ; - --- Index for finding media items that need link extraction -CREATE INDEX idx_media_links_extracted ON media_items (links_extracted_at); diff --git a/crates/pinakes-migrations/migrations/postgres/V1__initial_schema.sql b/crates/pinakes-migrations/migrations/postgres/V1__initial_schema.sql deleted file mode 100644 index c1c49af..0000000 --- a/crates/pinakes-migrations/migrations/postgres/V1__initial_schema.sql +++ /dev/null @@ -1,75 +0,0 @@ -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - -CREATE EXTENSION IF NOT EXISTS pg_trgm; - -CREATE TABLE IF NOT EXISTS root_dirs (path TEXT PRIMARY KEY NOT NULL); - -CREATE TABLE IF NOT EXISTS media_items ( - id UUID PRIMARY KEY NOT NULL, - path TEXT NOT NULL UNIQUE, - file_name TEXT NOT NULL, - media_type TEXT NOT NULL, - content_hash TEXT NOT NULL UNIQUE, - file_size BIGINT NOT NULL, - title TEXT, - artist TEXT, - album TEXT, - genre TEXT, - year INTEGER, - duration_secs DOUBLE PRECISION, - description TEXT, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL -); - -CREATE TABLE IF NOT EXISTS tags ( - id UUID PRIMARY KEY NOT NULL, - name TEXT NOT NULL, - parent_id UUID REFERENCES tags (id) ON DELETE SET NULL, - created_at TIMESTAMPTZ NOT NULL -); - -CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_name_parent ON tags ( - name, - COALESCE(parent_id, '00000000-0000-0000-0000-000000000000') -); - -CREATE TABLE IF NOT EXISTS media_tags ( - media_id UUID NOT NULL REFERENCES media_items (id) ON DELETE CASCADE, - tag_id UUID NOT NULL REFERENCES tags (id) ON DELETE CASCADE, - PRIMARY KEY (media_id, tag_id) -); - -CREATE TABLE IF NOT EXISTS collections ( - id UUID PRIMARY KEY NOT NULL, - name TEXT NOT NULL, - description TEXT, - kind TEXT NOT NULL, - filter_query TEXT, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL -); - -CREATE TABLE IF NOT EXISTS collection_members ( - collection_id UUID NOT NULL REFERENCES collections (id) ON DELETE CASCADE, - media_id UUID NOT NULL REFERENCES media_items (id) ON DELETE CASCADE, - position INTEGER NOT NULL DEFAULT 0, - added_at TIMESTAMPTZ NOT NULL, - PRIMARY KEY (collection_id, media_id) -); - -CREATE TABLE IF NOT EXISTS audit_log ( - id UUID PRIMARY KEY NOT NULL, - media_id UUID REFERENCES media_items (id) ON DELETE SET NULL, - action TEXT NOT NULL, - details TEXT, - timestamp TIMESTAMPTZ NOT NULL -); - -CREATE TABLE IF NOT EXISTS custom_fields ( - media_id UUID NOT NULL REFERENCES media_items (id) ON DELETE CASCADE, - field_name TEXT NOT NULL, - field_type TEXT NOT NULL, - field_value TEXT NOT NULL, - PRIMARY KEY (media_id, field_name) -); diff --git a/crates/pinakes-migrations/migrations/postgres/V2__fts_indexes.sql b/crates/pinakes-migrations/migrations/postgres/V2__fts_indexes.sql deleted file mode 100644 index 543fdce..0000000 --- a/crates/pinakes-migrations/migrations/postgres/V2__fts_indexes.sql +++ /dev/null @@ -1,12 +0,0 @@ -ALTER TABLE media_items -ADD COLUMN IF NOT EXISTS search_vector tsvector GENERATED ALWAYS AS ( - setweight(to_tsvector('english', COALESCE(title, '')), 'A') || setweight(to_tsvector('english', COALESCE(artist, '')), 'B') || setweight(to_tsvector('english', COALESCE(album, '')), 'B') || setweight(to_tsvector('english', COALESCE(genre, '')), 'C') || setweight( - to_tsvector('english', COALESCE(description, '')), - 'C' - ) || setweight( - to_tsvector('english', COALESCE(file_name, '')), - 'D' - ) -) STORED; - -CREATE INDEX IF NOT EXISTS idx_media_search ON media_items USING GIN (search_vector); diff --git a/crates/pinakes-migrations/migrations/postgres/V3__audit_indexes.sql b/crates/pinakes-migrations/migrations/postgres/V3__audit_indexes.sql deleted file mode 100644 index 85ffa06..0000000 --- a/crates/pinakes-migrations/migrations/postgres/V3__audit_indexes.sql +++ /dev/null @@ -1,15 +0,0 @@ -CREATE INDEX IF NOT EXISTS idx_audit_media_id ON audit_log (media_id); - -CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log (timestamp); - -CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log (action); - -CREATE INDEX IF NOT EXISTS idx_media_content_hash ON media_items (content_hash); - -CREATE INDEX IF NOT EXISTS idx_media_media_type ON media_items (media_type); - -CREATE INDEX IF NOT EXISTS idx_media_created_at ON media_items (created_at); - -CREATE INDEX IF NOT EXISTS idx_media_title_trgm ON media_items USING GIN (title gin_trgm_ops); - -CREATE INDEX IF NOT EXISTS idx_media_artist_trgm ON media_items USING GIN (artist gin_trgm_ops); diff --git a/crates/pinakes-migrations/migrations/postgres/V4__thumbnail_path.sql b/crates/pinakes-migrations/migrations/postgres/V4__thumbnail_path.sql deleted file mode 100644 index 4c23b5b..0000000 --- a/crates/pinakes-migrations/migrations/postgres/V4__thumbnail_path.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE media_items -ADD COLUMN thumbnail_path TEXT; diff --git a/crates/pinakes-migrations/migrations/postgres/V5__integrity_and_saved_searches.sql b/crates/pinakes-migrations/migrations/postgres/V5__integrity_and_saved_searches.sql deleted file mode 100644 index fd62baf..0000000 --- a/crates/pinakes-migrations/migrations/postgres/V5__integrity_and_saved_searches.sql +++ /dev/null @@ -1,15 +0,0 @@ --- Integrity tracking columns -ALTER TABLE media_items -ADD COLUMN last_verified_at TIMESTAMPTZ; - -ALTER TABLE media_items -ADD COLUMN integrity_status TEXT DEFAULT 'unverified'; - --- Saved searches -CREATE TABLE IF NOT EXISTS saved_searches ( - id UUID PRIMARY KEY, - name TEXT NOT NULL, - query TEXT NOT NULL, - sort_order TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); diff --git a/crates/pinakes-migrations/migrations/postgres/V6__plugin_system.sql b/crates/pinakes-migrations/migrations/postgres/V6__plugin_system.sql deleted file mode 100644 index b40cf41..0000000 --- a/crates/pinakes-migrations/migrations/postgres/V6__plugin_system.sql +++ /dev/null @@ -1,16 +0,0 @@ --- Plugin registry table -CREATE TABLE plugin_registry ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - version TEXT NOT NULL, - enabled BOOLEAN NOT NULL DEFAULT TRUE, - config_json TEXT, - manifest_json TEXT, - installed_at TIMESTAMP WITH TIME ZONE NOT NULL, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL -); - --- Index for quick lookups -CREATE INDEX idx_plugin_registry_enabled ON plugin_registry (enabled); - -CREATE INDEX idx_plugin_registry_name ON plugin_registry (name); diff --git a/crates/pinakes-migrations/migrations/postgres/V7__user_management.sql b/crates/pinakes-migrations/migrations/postgres/V7__user_management.sql deleted file mode 100644 index c405c80..0000000 --- a/crates/pinakes-migrations/migrations/postgres/V7__user_management.sql +++ /dev/null @@ -1,37 +0,0 @@ --- Users table -CREATE TABLE users ( - id UUID PRIMARY KEY, - username TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - role JSONB NOT NULL, - created_at TIMESTAMP WITH TIME ZONE NOT NULL, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL -); - --- User profiles table -CREATE TABLE user_profiles ( - user_id UUID PRIMARY KEY, - avatar_path TEXT, - bio TEXT, - preferences_json JSONB NOT NULL DEFAULT '{}', - created_at TIMESTAMP WITH TIME ZONE NOT NULL, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL, - FOREIGN KEY (user_id) REFERENCES users (id) -); - --- User library access table -CREATE TABLE user_libraries ( - user_id UUID NOT NULL, - root_path TEXT NOT NULL, - permission JSONB NOT NULL, - granted_at TIMESTAMP WITH TIME ZONE NOT NULL, - PRIMARY KEY (user_id, root_path), - FOREIGN KEY (user_id) REFERENCES users (id) -); - --- Indexes for efficient lookups -CREATE INDEX idx_users_username ON users (username); - -CREATE INDEX idx_user_libraries_user_id ON user_libraries (user_id); - -CREATE INDEX idx_user_libraries_root_path ON user_libraries (root_path); diff --git a/crates/pinakes-migrations/migrations/postgres/V8__media_server_features.sql b/crates/pinakes-migrations/migrations/postgres/V8__media_server_features.sql deleted file mode 100644 index 2594bd4..0000000 --- a/crates/pinakes-migrations/migrations/postgres/V8__media_server_features.sql +++ /dev/null @@ -1,136 +0,0 @@ --- Ratings -CREATE TABLE IF NOT EXISTS ratings ( - id UUID PRIMARY KEY, - user_id UUID NOT NULL, - media_id UUID NOT NULL REFERENCES media_items (id) ON DELETE CASCADE, - stars INTEGER NOT NULL CHECK ( - stars >= 1 - AND stars <= 5 - ), - review_text TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE (user_id, media_id) -); - --- Comments -CREATE TABLE IF NOT EXISTS comments ( - id UUID PRIMARY KEY, - user_id UUID NOT NULL, - media_id UUID NOT NULL REFERENCES media_items (id) ON DELETE CASCADE, - parent_comment_id UUID REFERENCES comments (id) ON DELETE CASCADE, - text TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Favorites -CREATE TABLE IF NOT EXISTS favorites ( - user_id UUID NOT NULL, - media_id UUID NOT NULL REFERENCES media_items (id) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - PRIMARY KEY (user_id, media_id) -); - --- Share links -CREATE TABLE IF NOT EXISTS share_links ( - id UUID PRIMARY KEY, - media_id UUID NOT NULL REFERENCES media_items (id) ON DELETE CASCADE, - created_by UUID NOT NULL, - token TEXT NOT NULL UNIQUE, - password_hash TEXT, - expires_at TIMESTAMPTZ, - view_count INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Playlists -CREATE TABLE IF NOT EXISTS playlists ( - id UUID PRIMARY KEY, - owner_id UUID NOT NULL, - name TEXT NOT NULL, - description TEXT, - is_public BOOLEAN NOT NULL DEFAULT FALSE, - is_smart BOOLEAN NOT NULL DEFAULT FALSE, - filter_query TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Playlist items -CREATE TABLE IF NOT EXISTS playlist_items ( - playlist_id UUID NOT NULL REFERENCES playlists (id) ON DELETE CASCADE, - media_id UUID NOT NULL REFERENCES media_items (id) ON DELETE CASCADE, - position INTEGER NOT NULL DEFAULT 0, - added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - PRIMARY KEY (playlist_id, media_id) -); - --- Usage events -CREATE TABLE IF NOT EXISTS usage_events ( - id UUID PRIMARY KEY, - media_id UUID REFERENCES media_items (id) ON DELETE SET NULL, - user_id UUID, - event_type TEXT NOT NULL, - timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), - duration_secs DOUBLE PRECISION, - context_json JSONB -); - -CREATE INDEX IF NOT EXISTS idx_usage_events_media ON usage_events (media_id); - -CREATE INDEX IF NOT EXISTS idx_usage_events_user ON usage_events (user_id); - -CREATE INDEX IF NOT EXISTS idx_usage_events_timestamp ON usage_events (timestamp); - --- Watch history / progress -CREATE TABLE IF NOT EXISTS watch_history ( - id UUID PRIMARY KEY, - user_id UUID NOT NULL, - media_id UUID NOT NULL REFERENCES media_items (id) ON DELETE CASCADE, - progress_secs DOUBLE PRECISION NOT NULL DEFAULT 0, - last_watched TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE (user_id, media_id) -); - --- Subtitles -CREATE TABLE IF NOT EXISTS subtitles ( - id UUID PRIMARY KEY, - media_id UUID NOT NULL REFERENCES media_items (id) ON DELETE CASCADE, - language TEXT, - format TEXT NOT NULL, - file_path TEXT, - is_embedded BOOLEAN NOT NULL DEFAULT FALSE, - track_index INTEGER, - offset_ms INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_subtitles_media ON subtitles (media_id); - --- External metadata (enrichment) -CREATE TABLE IF NOT EXISTS external_metadata ( - id UUID PRIMARY KEY, - media_id UUID NOT NULL REFERENCES media_items (id) ON DELETE CASCADE, - source TEXT NOT NULL, - external_id TEXT, - metadata_json JSONB NOT NULL DEFAULT '{}', - confidence DOUBLE PRECISION NOT NULL DEFAULT 0.0, - last_updated TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_external_metadata_media ON external_metadata (media_id); - --- Transcode sessions -CREATE TABLE IF NOT EXISTS transcode_sessions ( - id UUID PRIMARY KEY, - media_id UUID NOT NULL REFERENCES media_items (id) ON DELETE CASCADE, - user_id UUID, - profile TEXT NOT NULL, - cache_path TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - progress DOUBLE PRECISION NOT NULL DEFAULT 0.0, - error_message TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - expires_at TIMESTAMPTZ -); - -CREATE INDEX IF NOT EXISTS idx_transcode_sessions_media ON transcode_sessions (media_id); diff --git a/crates/pinakes-migrations/migrations/sqlite/V11__session_persistence.sql b/crates/pinakes-migrations/migrations/sqlite/V11__session_persistence.sql deleted file mode 100644 index e5e7a94..0000000 --- a/crates/pinakes-migrations/migrations/sqlite/V11__session_persistence.sql +++ /dev/null @@ -1,17 +0,0 @@ --- Session persistence for database-backed sessions --- Replaces in-memory session storage -CREATE TABLE IF NOT EXISTS sessions ( - session_token TEXT PRIMARY KEY NOT NULL, - user_id TEXT, - username TEXT NOT NULL, - role TEXT NOT NULL, - created_at TEXT NOT NULL, - expires_at TEXT NOT NULL, - last_accessed TEXT NOT NULL -); - --- Index for efficient cleanup of expired sessions -CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions (expires_at); - --- Index for listing sessions by username -CREATE INDEX IF NOT EXISTS idx_sessions_username ON sessions (username); diff --git a/crates/pinakes-migrations/migrations/sqlite/V12__book_management.sql b/crates/pinakes-migrations/migrations/sqlite/V12__book_management.sql deleted file mode 100644 index 9b18100..0000000 --- a/crates/pinakes-migrations/migrations/sqlite/V12__book_management.sql +++ /dev/null @@ -1,62 +0,0 @@ --- V12: Book Management Schema --- Adds comprehensive book metadata tracking, authors, and identifiers --- Book metadata (supplements media_items for EPUB/PDF/MOBI) -CREATE TABLE book_metadata ( - media_id TEXT PRIMARY KEY REFERENCES media_items (id) ON DELETE CASCADE, - isbn TEXT, - isbn13 TEXT, -- Normalized ISBN-13 for lookups - publisher TEXT, - language TEXT, -- ISO 639-1 code - page_count INTEGER, - publication_date TEXT, -- ISO 8601 date string - series_name TEXT, - series_index REAL, -- Supports 1.5, etc. - format TEXT, -- 'epub', 'pdf', 'mobi', 'azw3' - created_at TEXT NOT NULL DEFAULT (datetime ('now')), - updated_at TEXT NOT NULL DEFAULT (datetime ('now')) -) STRICT; - -CREATE INDEX idx_book_isbn13 ON book_metadata (isbn13); - -CREATE INDEX idx_book_series ON book_metadata (series_name, series_index); - -CREATE INDEX idx_book_publisher ON book_metadata (publisher); - -CREATE INDEX idx_book_language ON book_metadata (language); - --- Multiple authors per book (many-to-many) -CREATE TABLE book_authors ( - media_id TEXT NOT NULL REFERENCES media_items (id) ON DELETE CASCADE, - author_name TEXT NOT NULL, - author_sort TEXT, -- "Last, First" for sorting - role TEXT NOT NULL DEFAULT 'author', -- author, translator, editor, illustrator - position INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (media_id, author_name, role) -) STRICT; - -CREATE INDEX idx_book_authors_name ON book_authors (author_name); - -CREATE INDEX idx_book_authors_sort ON book_authors (author_sort); - --- Multiple identifiers (ISBN variants, ASIN, DOI, etc.) -CREATE TABLE book_identifiers ( - media_id TEXT NOT NULL REFERENCES media_items (id) ON DELETE CASCADE, - identifier_type TEXT NOT NULL, -- isbn, isbn13, asin, doi, lccn, oclc - identifier_value TEXT NOT NULL, - PRIMARY KEY (media_id, identifier_type, identifier_value) -) STRICT; - -CREATE INDEX idx_book_identifiers ON book_identifiers (identifier_type, identifier_value); - --- Trigger to update updated_at on book_metadata changes -CREATE TRIGGER update_book_metadata_timestamp -AFTER -UPDATE ON book_metadata FOR EACH ROW -BEGIN -UPDATE book_metadata -SET - updated_at = datetime ('now') -WHERE - media_id = NEW.media_id; - -END; diff --git a/crates/pinakes-migrations/migrations/sqlite/V13__photo_metadata.sql b/crates/pinakes-migrations/migrations/sqlite/V13__photo_metadata.sql deleted file mode 100644 index 640374f..0000000 --- a/crates/pinakes-migrations/migrations/sqlite/V13__photo_metadata.sql +++ /dev/null @@ -1,40 +0,0 @@ --- V13: Enhanced photo metadata support --- Add photo-specific fields to media_items table -ALTER TABLE media_items -ADD COLUMN date_taken TIMESTAMP; - -ALTER TABLE media_items -ADD COLUMN latitude REAL; - -ALTER TABLE media_items -ADD COLUMN longitude REAL; - -ALTER TABLE media_items -ADD COLUMN camera_make TEXT; - -ALTER TABLE media_items -ADD COLUMN camera_model TEXT; - -ALTER TABLE media_items -ADD COLUMN rating INTEGER CHECK ( - rating >= 0 - AND rating <= 5 -); - --- Indexes for photo queries -CREATE INDEX idx_media_date_taken ON media_items (date_taken) -WHERE - date_taken IS NOT NULL; - -CREATE INDEX idx_media_location ON media_items (latitude, longitude) -WHERE - latitude IS NOT NULL - AND longitude IS NOT NULL; - -CREATE INDEX idx_media_camera ON media_items (camera_make) -WHERE - camera_make IS NOT NULL; - -CREATE INDEX idx_media_rating ON media_items (rating) -WHERE - rating IS NOT NULL; diff --git a/crates/pinakes-migrations/migrations/sqlite/V14__perceptual_hash.sql b/crates/pinakes-migrations/migrations/sqlite/V14__perceptual_hash.sql deleted file mode 100644 index 1d3c634..0000000 --- a/crates/pinakes-migrations/migrations/sqlite/V14__perceptual_hash.sql +++ /dev/null @@ -1,9 +0,0 @@ --- V14: Perceptual hash for duplicate detection --- Add perceptual hash column for image similarity detection -ALTER TABLE media_items -ADD COLUMN perceptual_hash TEXT; - --- Index for perceptual hash lookups -CREATE INDEX idx_media_phash ON media_items (perceptual_hash) -WHERE - perceptual_hash IS NOT NULL; diff --git a/crates/pinakes-migrations/migrations/sqlite/V16__sync_system.sql b/crates/pinakes-migrations/migrations/sqlite/V16__sync_system.sql deleted file mode 100644 index 6787034..0000000 --- a/crates/pinakes-migrations/migrations/sqlite/V16__sync_system.sql +++ /dev/null @@ -1,129 +0,0 @@ --- V16: Cross-Device Sync System --- Adds device registration, change tracking, and chunked upload support --- Sync devices table -CREATE TABLE sync_devices ( - id TEXT PRIMARY KEY NOT NULL, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - device_type TEXT NOT NULL, - client_version TEXT NOT NULL, - os_info TEXT, - device_token_hash TEXT NOT NULL UNIQUE, - last_sync_at TEXT, - last_seen_at TEXT NOT NULL, - sync_cursor INTEGER DEFAULT 0, - enabled INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE -); - -CREATE INDEX idx_sync_devices_user ON sync_devices (user_id); - -CREATE INDEX idx_sync_devices_token ON sync_devices (device_token_hash); - --- Sync log table - tracks all changes for sync -CREATE TABLE sync_log ( - id TEXT PRIMARY KEY NOT NULL, - sequence INTEGER NOT NULL UNIQUE, - change_type TEXT NOT NULL, - media_id TEXT, - path TEXT NOT NULL, - content_hash TEXT, - file_size INTEGER, - metadata_json TEXT, - changed_by_device TEXT, - timestamp TEXT NOT NULL, - FOREIGN KEY (media_id) REFERENCES media_items (id) ON DELETE SET NULL, - FOREIGN KEY (changed_by_device) REFERENCES sync_devices (id) ON DELETE SET NULL -); - -CREATE INDEX idx_sync_log_sequence ON sync_log (sequence); - -CREATE INDEX idx_sync_log_path ON sync_log (path); - -CREATE INDEX idx_sync_log_timestamp ON sync_log (timestamp); - --- Sequence counter for sync log -CREATE TABLE sync_sequence ( - id INTEGER PRIMARY KEY CHECK (id = 1), - current_value INTEGER NOT NULL DEFAULT 0 -); - -INSERT INTO - sync_sequence (id, current_value) -VALUES - (1, 0); - --- Device sync state - tracks sync status per device per file -CREATE TABLE device_sync_state ( - device_id TEXT NOT NULL, - path TEXT NOT NULL, - local_hash TEXT, - server_hash TEXT, - local_mtime INTEGER, - server_mtime INTEGER, - sync_status TEXT NOT NULL, - last_synced_at TEXT, - conflict_info_json TEXT, - PRIMARY KEY (device_id, path), - FOREIGN KEY (device_id) REFERENCES sync_devices (id) ON DELETE CASCADE -); - -CREATE INDEX idx_device_sync_status ON device_sync_state (device_id, sync_status); - --- Upload sessions for chunked uploads -CREATE TABLE upload_sessions ( - id TEXT PRIMARY KEY NOT NULL, - device_id TEXT NOT NULL, - target_path TEXT NOT NULL, - expected_hash TEXT NOT NULL, - expected_size INTEGER NOT NULL, - chunk_size INTEGER NOT NULL, - chunk_count INTEGER NOT NULL, - status TEXT NOT NULL, - created_at TEXT NOT NULL, - expires_at TEXT NOT NULL, - last_activity TEXT NOT NULL, - FOREIGN KEY (device_id) REFERENCES sync_devices (id) ON DELETE CASCADE -); - -CREATE INDEX idx_upload_sessions_device ON upload_sessions (device_id); - -CREATE INDEX idx_upload_sessions_status ON upload_sessions (status); - -CREATE INDEX idx_upload_sessions_expires ON upload_sessions (expires_at); - --- Upload chunks - tracks received chunks -CREATE TABLE upload_chunks ( - upload_id TEXT NOT NULL, - chunk_index INTEGER NOT NULL, - offset - INTEGER NOT NULL, - size INTEGER NOT NULL, - hash TEXT NOT NULL, - received_at TEXT NOT NULL, - PRIMARY KEY (upload_id, chunk_index), - FOREIGN KEY (upload_id) REFERENCES upload_sessions (id) ON DELETE CASCADE -); - --- Sync conflicts -CREATE TABLE sync_conflicts ( - id TEXT PRIMARY KEY NOT NULL, - device_id TEXT NOT NULL, - path TEXT NOT NULL, - local_hash TEXT NOT NULL, - local_mtime INTEGER NOT NULL, - server_hash TEXT NOT NULL, - server_mtime INTEGER NOT NULL, - detected_at TEXT NOT NULL, - resolved_at TEXT, - resolution TEXT, - FOREIGN KEY (device_id) REFERENCES sync_devices (id) ON DELETE CASCADE -); - -CREATE INDEX idx_sync_conflicts_device ON sync_conflicts (device_id); - -CREATE INDEX idx_sync_conflicts_unresolved ON sync_conflicts (device_id, resolved_at) -WHERE - resolved_at IS NULL; diff --git a/crates/pinakes-migrations/migrations/sqlite/V17__enhanced_sharing.sql b/crates/pinakes-migrations/migrations/sqlite/V17__enhanced_sharing.sql deleted file mode 100644 index ac7d93e..0000000 --- a/crates/pinakes-migrations/migrations/sqlite/V17__enhanced_sharing.sql +++ /dev/null @@ -1,133 +0,0 @@ --- V17: Enhanced Sharing System --- Replaces simple share_links with comprehensive sharing capabilities --- Enhanced shares table -CREATE TABLE shares ( - id TEXT PRIMARY KEY NOT NULL, - target_type TEXT NOT NULL CHECK ( - target_type IN ('media', 'collection', 'tag', 'saved_search') - ), - target_id TEXT NOT NULL, - owner_id TEXT NOT NULL, - recipient_type TEXT NOT NULL CHECK ( - recipient_type IN ('public_link', 'user', 'group', 'federated') - ), - recipient_user_id TEXT, - recipient_group_id TEXT, - recipient_federated_handle TEXT, - recipient_federated_server TEXT, - public_token TEXT UNIQUE, - public_password_hash TEXT, - perm_view INTEGER NOT NULL DEFAULT 1, - perm_download INTEGER NOT NULL DEFAULT 0, - perm_edit INTEGER NOT NULL DEFAULT 0, - perm_delete INTEGER NOT NULL DEFAULT 0, - perm_reshare INTEGER NOT NULL DEFAULT 0, - perm_add INTEGER NOT NULL DEFAULT 0, - note TEXT, - expires_at TEXT, - access_count INTEGER NOT NULL DEFAULT 0, - last_accessed TEXT, - inherit_to_children INTEGER NOT NULL DEFAULT 1, - parent_share_id TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY (owner_id) REFERENCES users (id) ON DELETE CASCADE, - FOREIGN KEY (recipient_user_id) REFERENCES users (id) ON DELETE CASCADE, - FOREIGN KEY (parent_share_id) REFERENCES shares (id) ON DELETE CASCADE, - UNIQUE ( - owner_id, - target_type, - target_id, - recipient_type, - recipient_user_id - ) -); - -CREATE INDEX idx_shares_owner ON shares (owner_id); - -CREATE INDEX idx_shares_recipient_user ON shares (recipient_user_id); - -CREATE INDEX idx_shares_target ON shares (target_type, target_id); - -CREATE INDEX idx_shares_token ON shares (public_token); - -CREATE INDEX idx_shares_expires ON shares (expires_at); - --- Share activity log -CREATE TABLE share_activity ( - id TEXT PRIMARY KEY NOT NULL, - share_id TEXT NOT NULL, - actor_id TEXT, - actor_ip TEXT, - action TEXT NOT NULL, - details TEXT, - timestamp TEXT NOT NULL, - FOREIGN KEY (share_id) REFERENCES shares (id) ON DELETE CASCADE, - FOREIGN KEY (actor_id) REFERENCES users (id) ON DELETE SET NULL -); - -CREATE INDEX idx_share_activity_share ON share_activity (share_id); - -CREATE INDEX idx_share_activity_timestamp ON share_activity (timestamp); - --- Share notifications -CREATE TABLE share_notifications ( - id TEXT PRIMARY KEY NOT NULL, - user_id TEXT NOT NULL, - share_id TEXT NOT NULL, - notification_type TEXT NOT NULL, - is_read INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, - FOREIGN KEY (share_id) REFERENCES shares (id) ON DELETE CASCADE -); - -CREATE INDEX idx_share_notifications_user ON share_notifications (user_id); - -CREATE INDEX idx_share_notifications_unread ON share_notifications (user_id, is_read) -WHERE - is_read = 0; - --- Migrate existing share_links to new shares table (if share_links exists) -INSERT -OR IGNORE INTO shares ( - id, - target_type, - target_id, - owner_id, - recipient_type, - public_token, - public_password_hash, - perm_view, - perm_download, - access_count, - expires_at, - created_at, - updated_at -) -SELECT - id, - 'media', - media_id, - created_by, - 'public_link', - token, - password_hash, - 1, - 1, - view_count, - expires_at, - created_at, - created_at -FROM - share_links -WHERE - EXISTS ( - SELECT - 1 - FROM - sqlite_master - WHERE - type = 'table' - AND name = 'share_links' - ); diff --git a/crates/pinakes-migrations/migrations/sqlite/V19__markdown_links.sql b/crates/pinakes-migrations/migrations/sqlite/V19__markdown_links.sql deleted file mode 100644 index 214e40a..0000000 --- a/crates/pinakes-migrations/migrations/sqlite/V19__markdown_links.sql +++ /dev/null @@ -1,35 +0,0 @@ --- V19: Markdown Links (Obsidian-style bidirectional links) --- Adds support for wikilinks, markdown links, embeds, and backlink tracking --- Table for storing extracted markdown links -CREATE TABLE IF NOT EXISTS markdown_links ( - id TEXT PRIMARY KEY NOT NULL, - source_media_id TEXT NOT NULL, - target_path TEXT NOT NULL, -- raw link target (wikilink or path) - target_media_id TEXT, -- resolved media_id (nullable if unresolved) - link_type TEXT NOT NULL, -- 'wikilink', 'markdown_link', 'embed' - link_text TEXT, -- display text for the link - line_number INTEGER, -- line number in source file - context TEXT, -- surrounding text for preview - created_at TEXT NOT NULL, - FOREIGN KEY (source_media_id) REFERENCES media_items (id) ON DELETE CASCADE, - FOREIGN KEY (target_media_id) REFERENCES media_items (id) ON DELETE SET NULL -); - --- Index for efficient outgoing link queries (what does this note link to?) -CREATE INDEX idx_links_source ON markdown_links (source_media_id); - --- Index for efficient backlink queries (what links to this note?) -CREATE INDEX idx_links_target ON markdown_links (target_media_id); - --- Index for path-based resolution (finding unresolved links) -CREATE INDEX idx_links_target_path ON markdown_links (target_path); - --- Index for link type filtering -CREATE INDEX idx_links_type ON markdown_links (link_type); - --- Track when links were last extracted from a media item -ALTER TABLE media_items -ADD COLUMN links_extracted_at TEXT; - --- Index for finding media items that need link extraction -CREATE INDEX idx_media_links_extracted ON media_items (links_extracted_at); diff --git a/crates/pinakes-migrations/migrations/sqlite/V1__initial_schema.sql b/crates/pinakes-migrations/migrations/sqlite/V1__initial_schema.sql deleted file mode 100644 index b6ccd0e..0000000 --- a/crates/pinakes-migrations/migrations/sqlite/V1__initial_schema.sql +++ /dev/null @@ -1,75 +0,0 @@ -CREATE TABLE IF NOT EXISTS root_dirs (path TEXT PRIMARY KEY NOT NULL); - -CREATE TABLE IF NOT EXISTS media_items ( - id TEXT PRIMARY KEY NOT NULL, - path TEXT NOT NULL UNIQUE, - file_name TEXT NOT NULL, - media_type TEXT NOT NULL, - content_hash TEXT NOT NULL UNIQUE, - file_size INTEGER NOT NULL, - title TEXT, - artist TEXT, - album TEXT, - genre TEXT, - year INTEGER, - duration_secs REAL, - description TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS tags ( - id TEXT PRIMARY KEY NOT NULL, - name TEXT NOT NULL, - parent_id TEXT, - created_at TEXT NOT NULL, - FOREIGN KEY (parent_id) REFERENCES tags (id) ON DELETE SET NULL -); - -CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_name_parent ON tags (name, parent_id); - -CREATE TABLE IF NOT EXISTS media_tags ( - media_id TEXT NOT NULL, - tag_id TEXT NOT NULL, - PRIMARY KEY (media_id, tag_id), - FOREIGN KEY (media_id) REFERENCES media_items (id) ON DELETE CASCADE, - FOREIGN KEY (tag_id) REFERENCES tags (id) ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS collections ( - id TEXT PRIMARY KEY NOT NULL, - name TEXT NOT NULL, - description TEXT, - kind TEXT NOT NULL, - filter_query TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS collection_members ( - collection_id TEXT NOT NULL, - media_id TEXT NOT NULL, - position INTEGER NOT NULL DEFAULT 0, - added_at TEXT NOT NULL, - PRIMARY KEY (collection_id, media_id), - FOREIGN KEY (collection_id) REFERENCES collections (id) ON DELETE CASCADE, - FOREIGN KEY (media_id) REFERENCES media_items (id) ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS audit_log ( - id TEXT PRIMARY KEY NOT NULL, - media_id TEXT, - action TEXT NOT NULL, - details TEXT, - timestamp TEXT NOT NULL, - FOREIGN KEY (media_id) REFERENCES media_items (id) ON DELETE SET NULL -); - -CREATE TABLE IF NOT EXISTS custom_fields ( - media_id TEXT NOT NULL, - field_name TEXT NOT NULL, - field_type TEXT NOT NULL, - field_value TEXT NOT NULL, - PRIMARY KEY (media_id, field_name), - FOREIGN KEY (media_id) REFERENCES media_items (id) ON DELETE CASCADE -); diff --git a/crates/pinakes-migrations/migrations/sqlite/V2__fts5_indexes.sql b/crates/pinakes-migrations/migrations/sqlite/V2__fts5_indexes.sql deleted file mode 100644 index 01270a0..0000000 --- a/crates/pinakes-migrations/migrations/sqlite/V2__fts5_indexes.sql +++ /dev/null @@ -1,114 +0,0 @@ -CREATE VIRTUAL TABLE IF NOT EXISTS media_fts USING fts5 ( - title, - artist, - album, - genre, - description, - file_name, - content = 'media_items', - content_rowid = 'rowid' -); - -CREATE TRIGGER IF NOT EXISTS media_fts_insert -AFTER INSERT ON media_items -BEGIN -INSERT INTO - media_fts ( - rowid, - title, - artist, - album, - genre, - description, - file_name - ) -VALUES - ( - new.rowid, - new.title, - new.artist, - new.album, - new.genre, - new.description, - new.file_name - ); - -END; - -CREATE TRIGGER IF NOT EXISTS media_fts_update -AFTER -UPDATE ON media_items -BEGIN -INSERT INTO - media_fts ( - media_fts, - rowid, - title, - artist, - album, - genre, - description, - file_name - ) -VALUES - ( - 'delete', - old.rowid, - old.title, - old.artist, - old.album, - old.genre, - old.description, - old.file_name - ); - -INSERT INTO - media_fts ( - rowid, - title, - artist, - album, - genre, - description, - file_name - ) -VALUES - ( - new.rowid, - new.title, - new.artist, - new.album, - new.genre, - new.description, - new.file_name - ); - -END; - -CREATE TRIGGER IF NOT EXISTS media_fts_delete -AFTER DELETE ON media_items -BEGIN -INSERT INTO - media_fts ( - media_fts, - rowid, - title, - artist, - album, - genre, - description, - file_name - ) -VALUES - ( - 'delete', - old.rowid, - old.title, - old.artist, - old.album, - old.genre, - old.description, - old.file_name - ); - -END; diff --git a/crates/pinakes-migrations/migrations/sqlite/V3__audit_indexes.sql b/crates/pinakes-migrations/migrations/sqlite/V3__audit_indexes.sql deleted file mode 100644 index 5372307..0000000 --- a/crates/pinakes-migrations/migrations/sqlite/V3__audit_indexes.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE INDEX IF NOT EXISTS idx_audit_media_id ON audit_log (media_id); - -CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log (timestamp); - -CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log (action); - -CREATE INDEX IF NOT EXISTS idx_media_content_hash ON media_items (content_hash); - -CREATE INDEX IF NOT EXISTS idx_media_media_type ON media_items (media_type); - -CREATE INDEX IF NOT EXISTS idx_media_created_at ON media_items (created_at); diff --git a/crates/pinakes-migrations/migrations/sqlite/V4__thumbnail_path.sql b/crates/pinakes-migrations/migrations/sqlite/V4__thumbnail_path.sql deleted file mode 100644 index 4c23b5b..0000000 --- a/crates/pinakes-migrations/migrations/sqlite/V4__thumbnail_path.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE media_items -ADD COLUMN thumbnail_path TEXT; diff --git a/crates/pinakes-migrations/migrations/sqlite/V5__integrity_and_saved_searches.sql b/crates/pinakes-migrations/migrations/sqlite/V5__integrity_and_saved_searches.sql deleted file mode 100644 index a8b05ea..0000000 --- a/crates/pinakes-migrations/migrations/sqlite/V5__integrity_and_saved_searches.sql +++ /dev/null @@ -1,15 +0,0 @@ --- Integrity tracking columns -ALTER TABLE media_items -ADD COLUMN last_verified_at TEXT; - -ALTER TABLE media_items -ADD COLUMN integrity_status TEXT DEFAULT 'unverified'; - --- Saved searches -CREATE TABLE IF NOT EXISTS saved_searches ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - query TEXT NOT NULL, - sort_order TEXT, - created_at TEXT NOT NULL -); diff --git a/crates/pinakes-migrations/migrations/sqlite/V6__plugin_system.sql b/crates/pinakes-migrations/migrations/sqlite/V6__plugin_system.sql deleted file mode 100644 index c675177..0000000 --- a/crates/pinakes-migrations/migrations/sqlite/V6__plugin_system.sql +++ /dev/null @@ -1,16 +0,0 @@ --- Plugin registry table -CREATE TABLE plugin_registry ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - version TEXT NOT NULL, - enabled BOOLEAN NOT NULL DEFAULT TRUE, - config_json TEXT, - manifest_json TEXT, - installed_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - --- Index for quick lookups -CREATE INDEX idx_plugin_registry_enabled ON plugin_registry (enabled); - -CREATE INDEX idx_plugin_registry_name ON plugin_registry (name); diff --git a/crates/pinakes-migrations/migrations/sqlite/V7__user_management.sql b/crates/pinakes-migrations/migrations/sqlite/V7__user_management.sql deleted file mode 100644 index 8042e10..0000000 --- a/crates/pinakes-migrations/migrations/sqlite/V7__user_management.sql +++ /dev/null @@ -1,37 +0,0 @@ --- Users table -CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - role TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - --- User profiles table -CREATE TABLE user_profiles ( - user_id TEXT PRIMARY KEY, - avatar_path TEXT, - bio TEXT, - preferences_json TEXT NOT NULL DEFAULT '{}', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users (id) -); - --- User library access table -CREATE TABLE user_libraries ( - user_id TEXT NOT NULL, - root_path TEXT NOT NULL, - permission TEXT NOT NULL, - granted_at TEXT NOT NULL, - PRIMARY KEY (user_id, root_path), - FOREIGN KEY (user_id) REFERENCES users (id) -); - --- Indexes for efficient lookups -CREATE INDEX idx_users_username ON users (username); - -CREATE INDEX idx_user_libraries_user_id ON user_libraries (user_id); - -CREATE INDEX idx_user_libraries_root_path ON user_libraries (root_path); diff --git a/crates/pinakes-migrations/migrations/sqlite/V8__media_server_features.sql b/crates/pinakes-migrations/migrations/sqlite/V8__media_server_features.sql deleted file mode 100644 index ee3dc04..0000000 --- a/crates/pinakes-migrations/migrations/sqlite/V8__media_server_features.sql +++ /dev/null @@ -1,148 +0,0 @@ --- Ratings -CREATE TABLE IF NOT EXISTS ratings ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - media_id TEXT NOT NULL, - stars INTEGER NOT NULL CHECK ( - stars >= 1 - AND stars <= 5 - ), - review_text TEXT, - created_at TEXT NOT NULL DEFAULT (datetime ('now')), - UNIQUE (user_id, media_id), - FOREIGN KEY (media_id) REFERENCES media_items (id) ON DELETE CASCADE -); - --- Comments -CREATE TABLE IF NOT EXISTS comments ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - media_id TEXT NOT NULL, - parent_comment_id TEXT, - text TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime ('now')), - FOREIGN KEY (media_id) REFERENCES media_items (id) ON DELETE CASCADE, - FOREIGN KEY (parent_comment_id) REFERENCES comments (id) ON DELETE CASCADE -); - --- Favorites -CREATE TABLE IF NOT EXISTS favorites ( - user_id TEXT NOT NULL, - media_id TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime ('now')), - PRIMARY KEY (user_id, media_id), - FOREIGN KEY (media_id) REFERENCES media_items (id) ON DELETE CASCADE -); - --- Share links -CREATE TABLE IF NOT EXISTS share_links ( - id TEXT PRIMARY KEY, - media_id TEXT NOT NULL, - created_by TEXT NOT NULL, - token TEXT NOT NULL UNIQUE, - password_hash TEXT, - expires_at TEXT, - view_count INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime ('now')), - FOREIGN KEY (media_id) REFERENCES media_items (id) ON DELETE CASCADE -); - --- Playlists -CREATE TABLE IF NOT EXISTS playlists ( - id TEXT PRIMARY KEY, - owner_id TEXT NOT NULL, - name TEXT NOT NULL, - description TEXT, - is_public INTEGER NOT NULL DEFAULT 0, - is_smart INTEGER NOT NULL DEFAULT 0, - filter_query TEXT, - created_at TEXT NOT NULL DEFAULT (datetime ('now')), - updated_at TEXT NOT NULL DEFAULT (datetime ('now')) -); - --- Playlist items -CREATE TABLE IF NOT EXISTS playlist_items ( - playlist_id TEXT NOT NULL, - media_id TEXT NOT NULL, - position INTEGER NOT NULL DEFAULT 0, - added_at TEXT NOT NULL DEFAULT (datetime ('now')), - PRIMARY KEY (playlist_id, media_id), - FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE, - FOREIGN KEY (media_id) REFERENCES media_items (id) ON DELETE CASCADE -); - --- Usage events -CREATE TABLE IF NOT EXISTS usage_events ( - id TEXT PRIMARY KEY, - media_id TEXT, - user_id TEXT, - event_type TEXT NOT NULL, - timestamp TEXT NOT NULL DEFAULT (datetime ('now')), - duration_secs REAL, - context_json TEXT, - FOREIGN KEY (media_id) REFERENCES media_items (id) ON DELETE SET NULL -); - -CREATE INDEX IF NOT EXISTS idx_usage_events_media ON usage_events (media_id); - -CREATE INDEX IF NOT EXISTS idx_usage_events_user ON usage_events (user_id); - -CREATE INDEX IF NOT EXISTS idx_usage_events_timestamp ON usage_events (timestamp); - --- Watch history / progress -CREATE TABLE IF NOT EXISTS watch_history ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - media_id TEXT NOT NULL, - progress_secs REAL NOT NULL DEFAULT 0, - last_watched TEXT NOT NULL DEFAULT (datetime ('now')), - UNIQUE (user_id, media_id), - FOREIGN KEY (media_id) REFERENCES media_items (id) ON DELETE CASCADE -); - --- Subtitles -CREATE TABLE IF NOT EXISTS subtitles ( - id TEXT PRIMARY KEY, - media_id TEXT NOT NULL, - language TEXT, - format TEXT NOT NULL, - file_path TEXT, - is_embedded INTEGER NOT NULL DEFAULT 0, - track_index INTEGER, - offset_ms INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime ('now')), - FOREIGN KEY (media_id) REFERENCES media_items (id) ON DELETE CASCADE -); - -CREATE INDEX IF NOT EXISTS idx_subtitles_media ON subtitles (media_id); - --- External metadata (enrichment) -CREATE TABLE IF NOT EXISTS external_metadata ( - id TEXT PRIMARY KEY, - media_id TEXT NOT NULL, - source TEXT NOT NULL, - external_id TEXT, - metadata_json TEXT NOT NULL DEFAULT '{}', - confidence REAL NOT NULL DEFAULT 0.0, - last_updated TEXT NOT NULL DEFAULT (datetime ('now')), - FOREIGN KEY (media_id) REFERENCES media_items (id) ON DELETE CASCADE -); - -CREATE INDEX IF NOT EXISTS idx_external_metadata_media ON external_metadata (media_id); - --- Transcode sessions -CREATE TABLE IF NOT EXISTS transcode_sessions ( - id TEXT PRIMARY KEY, - media_id TEXT NOT NULL, - user_id TEXT, - profile TEXT NOT NULL, - cache_path TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - progress REAL NOT NULL DEFAULT 0.0, - error_message TEXT, - created_at TEXT NOT NULL DEFAULT (datetime ('now')), - expires_at TEXT, - FOREIGN KEY (media_id) REFERENCES media_items (id) ON DELETE CASCADE -); - -CREATE INDEX IF NOT EXISTS idx_transcode_sessions_media ON transcode_sessions (media_id); diff --git a/crates/pinakes-migrations/src/lib.rs b/crates/pinakes-migrations/src/lib.rs deleted file mode 100644 index 2890155..0000000 --- a/crates/pinakes-migrations/src/lib.rs +++ /dev/null @@ -1,56 +0,0 @@ -use rusqlite_migration::{M, Migrations}; - -mod postgres_migrations { - use refinery::embed_migrations; - embed_migrations!("migrations/postgres"); -} - -#[must_use] -pub fn sqlite_migrations() -> Migrations<'static> { - Migrations::new(vec![ - M::up(include_str!("../migrations/sqlite/V1__initial_schema.sql")), - M::up(include_str!("../migrations/sqlite/V2__fts5_indexes.sql")), - M::up(include_str!("../migrations/sqlite/V3__audit_indexes.sql")), - M::up(include_str!("../migrations/sqlite/V4__thumbnail_path.sql")), - M::up(include_str!( - "../migrations/sqlite/V5__integrity_and_saved_searches.sql" - )), - M::up(include_str!("../migrations/sqlite/V6__plugin_system.sql")), - M::up(include_str!("../migrations/sqlite/V7__user_management.sql")), - M::up(include_str!( - "../migrations/sqlite/V8__media_server_features.sql" - )), - M::up(include_str!( - "../migrations/sqlite/V9__fix_indexes_and_constraints.sql" - )), - M::up(include_str!( - "../migrations/sqlite/V10__incremental_scan.sql" - )), - M::up(include_str!( - "../migrations/sqlite/V11__session_persistence.sql" - )), - M::up(include_str!( - "../migrations/sqlite/V12__book_management.sql" - )), - M::up(include_str!("../migrations/sqlite/V13__photo_metadata.sql")), - M::up(include_str!( - "../migrations/sqlite/V14__perceptual_hash.sql" - )), - M::up(include_str!( - "../migrations/sqlite/V15__managed_storage.sql" - )), - M::up(include_str!("../migrations/sqlite/V16__sync_system.sql")), - M::up(include_str!( - "../migrations/sqlite/V17__enhanced_sharing.sql" - )), - M::up(include_str!( - "../migrations/sqlite/V18__file_management.sql" - )), - M::up(include_str!("../migrations/sqlite/V19__markdown_links.sql")), - ]) -} - -#[must_use] -pub fn postgres_runner() -> refinery::Runner { - postgres_migrations::migrations::runner() -} diff --git a/crates/pinakes-plugin-api/Cargo.toml b/crates/pinakes-plugin-api/Cargo.toml index b8e8f49..51a6686 100644 --- a/crates/pinakes-plugin-api/Cargo.toml +++ b/crates/pinakes-plugin-api/Cargo.toml @@ -4,25 +4,32 @@ version.workspace = true edition.workspace = true license.workspace = true -[features] -default = [] -wasm = ["wit-bindgen"] - [dependencies] +# Core dependencies serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } async-trait = { workspace = true } tracing = { workspace = true } + +# For plugin manifest parsing toml = { workspace = true } + +# For media types and identifiers uuid = { workspace = true } chrono = { workspace = true } mime_guess = { workspace = true } rustc-hash = { workspace = true } -wit-bindgen = { workspace = true, optional = true } -[dev-dependencies] -tokio = { workspace = true, features = ["rt", "rt-multi-thread", "macros"] } +# WASM bridge types +wit-bindgen = { workspace = true, optional = true } [lints] workspace = true + +[features] +default = [] +wasm = ["wit-bindgen"] + +[dev-dependencies] +tokio = { workspace = true, features = ["rt", "rt-multi-thread", "macros"] } diff --git a/crates/pinakes-plugin/Cargo.toml b/crates/pinakes-plugin/Cargo.toml deleted file mode 100644 index d3d6192..0000000 --- a/crates/pinakes-plugin/Cargo.toml +++ /dev/null @@ -1,29 +0,0 @@ -[package] -name = "pinakes-plugin" -edition.workspace = true -version.workspace = true -license.workspace = true - -[dependencies] -pinakes-types = { workspace = true } -pinakes-plugin-api = { workspace = true } -wasmtime = { workspace = true } -ed25519-dalek = { workspace = true } -reqwest = { workspace = true } -tokio = { workspace = true } -tracing = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -anyhow = { workspace = true } -rustc-hash = { workspace = true } -walkdir = { workspace = true } -uuid = { workspace = true } -url = { workspace = true } -blake3 = { workspace = true } -rand = { workspace = true } - -[dev-dependencies] -tempfile = { workspace = true } - -[lints] -workspace = true diff --git a/crates/pinakes-plugin/src/lib.rs b/crates/pinakes-plugin/src/lib.rs deleted file mode 100644 index 08d063a..0000000 --- a/crates/pinakes-plugin/src/lib.rs +++ /dev/null @@ -1,15 +0,0 @@ -pub mod loader; -pub mod registry; -pub mod rpc; -pub mod runtime; -pub mod security; -pub mod signature; - -pub use loader::PluginLoader; -pub use registry::{PluginRegistry, RegisteredPlugin}; -pub use runtime::{WasmPlugin, WasmRuntime}; -pub use security::CapabilityEnforcer; -pub use signature::{SignatureStatus, verify_plugin_signature}; - -mod manager; -pub use manager::{PluginManager, PluginManagerConfig}; diff --git a/crates/pinakes-plugin/src/manager.rs b/crates/pinakes-plugin/src/manager.rs deleted file mode 100644 index cfe33a9..0000000 --- a/crates/pinakes-plugin/src/manager.rs +++ /dev/null @@ -1,919 +0,0 @@ -use std::{path::PathBuf, sync::Arc}; - -use anyhow::Result; -use pinakes_plugin_api::{PluginContext, PluginMetadata}; -use tokio::sync::RwLock; -use tracing::{debug, error, info, warn}; - -use crate::{ - CapabilityEnforcer, - PluginLoader, - PluginRegistry, - RegisteredPlugin, - SignatureStatus, - WasmPlugin, - WasmRuntime, - signature, -}; - -/// Plugin manager coordinates plugin lifecycle and operations -pub struct PluginManager { - /// Plugin registry - registry: Arc>, - - /// WASM runtime for executing plugins - runtime: Arc, - - /// Plugin loader for discovery and loading - loader: PluginLoader, - - /// Capability enforcer for security - enforcer: CapabilityEnforcer, - - /// Plugin data directory - data_dir: PathBuf, - - /// Plugin cache directory - cache_dir: PathBuf, - - /// Configuration - config: PluginManagerConfig, -} - -/// Configuration for the plugin manager -#[derive(Debug, Clone)] -pub struct PluginManagerConfig { - /// Directories to search for plugins - pub plugin_dirs: Vec, - - /// Whether to enable hot-reload (for development) - pub enable_hot_reload: bool, - - /// Whether to allow unsigned plugins - pub allow_unsigned: bool, - - /// Maximum number of concurrent plugin operations - pub max_concurrent_ops: usize, - - /// Plugin timeout in seconds - pub plugin_timeout_secs: u64, - - /// Timeout configuration for different call types - pub timeouts: pinakes_types::config::PluginTimeoutConfig, - - /// Max consecutive failures before circuit breaker disables plugin - pub max_consecutive_failures: u32, - - /// Trusted Ed25519 public keys for signature verification (hex-encoded) - pub trusted_keys: Vec, -} - -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, - timeouts: - pinakes_types::config::PluginTimeoutConfig::default(), - max_consecutive_failures: 5, - trusted_keys: vec![], - } - } -} - -impl From for PluginManagerConfig { - fn from(cfg: pinakes_types::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, - timeouts: cfg.timeouts, - max_consecutive_failures: cfg.max_consecutive_failures, - trusted_keys: cfg.trusted_keys, - } - } -} - -impl PluginManager { - /// Create a new plugin manager - /// - /// # Errors - /// - /// Returns an error if the data or cache directories cannot be created, or - /// if the WASM runtime cannot be initialized. - 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(); - - Ok(Self { - registry, - runtime, - loader, - enforcer, - data_dir, - cache_dir, - config, - }) - } - - /// Discover and load all plugins from configured directories. - /// - /// Plugins are loaded in dependency order: if plugin A declares a - /// dependency on plugin B, B is loaded first. Cycles and missing - /// dependencies are detected and reported as warnings; affected plugins - /// are skipped rather than causing a hard failure. - /// - /// # Errors - /// - /// Returns an error if plugin discovery fails. - pub async fn discover_and_load_all(&self) -> Result> { - info!("Discovering plugins from {:?}", self.config.plugin_dirs); - - let manifests = self.loader.discover_plugins(); - let ordered = Self::resolve_load_order(&manifests); - let mut loaded_plugins = Vec::new(); - - for manifest in ordered { - 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) - } - - /// Topological sort of manifests by their declared `dependencies`. - /// - /// Uses Kahn's algorithm. Plugins whose dependencies are missing or form - /// a cycle are logged as warnings and excluded from the result. - fn resolve_load_order( - manifests: &[pinakes_plugin_api::PluginManifest], - ) -> Vec { - use std::collections::VecDeque; - - use rustc_hash::{FxHashMap, FxHashSet}; - - // Index manifests by name for O(1) lookup - let by_name: FxHashMap<&str, usize> = manifests - .iter() - .enumerate() - .map(|(i, m)| (m.plugin.name.as_str(), i)) - .collect(); - - // Check for missing dependencies and warn early - let known: FxHashSet<&str> = by_name.keys().copied().collect(); - for manifest in manifests { - for dep in &manifest.plugin.dependencies { - if !known.contains(dep.as_str()) { - warn!( - "Plugin '{}' depends on '{}' which was not discovered; it will be \ - skipped", - manifest.plugin.name, dep - ); - } - } - } - - // Build adjacency: in_degree[i] = number of deps that must load before i - let mut in_degree = vec![0usize; manifests.len()]; - // dependents[i] = indices that depend on i (i must load before them) - let mut dependents: Vec> = vec![vec![]; manifests.len()]; - - for (i, manifest) in manifests.iter().enumerate() { - for dep in &manifest.plugin.dependencies { - if let Some(&dep_idx) = by_name.get(dep.as_str()) { - in_degree[i] += 1; - dependents[dep_idx].push(i); - } else { - // Missing dep: set in_degree impossibly high so it never resolves - in_degree[i] = usize::MAX; - } - } - } - - // Kahn's algorithm - let mut queue: VecDeque = VecDeque::new(); - for (i, °) in in_degree.iter().enumerate() { - if deg == 0 { - queue.push_back(i); - } - } - - let mut result = Vec::with_capacity(manifests.len()); - while let Some(idx) = queue.pop_front() { - result.push(manifests[idx].clone()); - for &dependent in &dependents[idx] { - if in_degree[dependent] == usize::MAX { - continue; // already poisoned by missing dep - } - in_degree[dependent] -= 1; - if in_degree[dependent] == 0 { - queue.push_back(dependent); - } - } - } - - // Anything not in `result` is part of a cycle or has a missing dep - if result.len() < manifests.len() { - let loaded: FxHashSet<&str> = - result.iter().map(|m| m.plugin.name.as_str()).collect(); - for manifest in manifests { - if !loaded.contains(manifest.plugin.name.as_str()) { - warn!( - "Plugin '{}' was skipped due to unresolved dependencies or a \ - dependency cycle", - manifest.plugin.name - ); - } - } - } - - result - } - - /// Load a plugin from a manifest file - /// - /// # Errors - /// - /// Returns an error if the plugin ID is invalid, capability validation - /// fails, the WASM binary cannot be loaded, or the plugin cannot be - /// registered. - 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)?; - - // Verify plugin signature unless unsigned plugins are allowed - if !self.config.allow_unsigned { - let plugin_dir = wasm_path - .parent() - .ok_or_else(|| anyhow::anyhow!("WASM path has no parent directory"))?; - - let trusted_keys: Vec = self - .config - .trusted_keys - .iter() - .filter_map(|hex| { - signature::parse_public_key(hex) - .map_err(|e| warn!("Ignoring malformed trusted key: {e}")) - .ok() - }) - .collect(); - - match signature::verify_plugin_signature( - plugin_dir, - &wasm_path, - &trusted_keys, - )? { - SignatureStatus::Valid => { - debug!("Plugin '{plugin_id}' signature verified"); - }, - SignatureStatus::Unsigned => { - return Err(anyhow::anyhow!( - "Plugin '{plugin_id}' is unsigned and allow_unsigned is false" - )); - }, - SignatureStatus::Invalid(reason) => { - return Err(anyhow::anyhow!( - "Plugin '{plugin_id}' has an invalid signature: {reason}" - )); - }, - } - } - - let wasm_plugin = self.runtime.load_plugin(&wasm_path, context)?; - - // 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 - /// - /// # Errors - /// - /// Returns an error if the plugin cannot be downloaded, the manifest cannot - /// be read, or the plugin cannot be loaded. - 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 - /// - /// # Errors - /// - /// Returns an error if the plugin ID is invalid, the plugin cannot be shut - /// down, cannot be unregistered, or its data directories cannot be removed. - 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!("Uninstalling plugin: {}", plugin_id); - - // 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(()) - } - - /// Enable a plugin - /// - /// # Errors - /// - /// Returns an error if the plugin ID is not found in the registry. - pub async fn enable_plugin(&self, plugin_id: &str) -> Result<()> { - let mut registry = self.registry.write().await; - registry.enable(plugin_id) - } - - /// Disable a plugin - /// - /// # Errors - /// - /// Returns an error if the plugin ID is not found in the registry. - 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 - /// - /// # Errors - /// - /// Returns an error if the plugin ID is not found in the registry. - 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) { - let _ = plugin.wasm_plugin.call_function("shutdown", &[]).await; - Ok(()) - } else { - Err(anyhow::anyhow!("Plugin not found: {plugin_id}")) - } - } - - /// Shutdown all plugins - /// - /// # Errors - /// - /// This function always returns `Ok(())`. Individual plugin shutdown errors - /// are logged but do not cause the overall operation to fail. - pub async fn shutdown_all(&self) -> Result<()> { - info!("Shutting down all plugins"); - - let plugin_ids: Vec = { - let registry = self.registry.read().await; - 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()) - } - - /// Get enabled plugins of a specific kind, sorted by priority (ascending). - /// - /// # Returns - /// - /// `(plugin_id, priority, kinds, wasm_plugin)` tuples. - pub async fn get_enabled_by_kind_sorted( - &self, - kind: &str, - ) -> Vec<(String, u16, Vec, WasmPlugin)> { - let registry = self.registry.read().await; - let mut plugins: Vec<_> = registry - .get_by_kind(kind) - .into_iter() - .filter(|p| p.enabled) - .map(|p| { - ( - p.id.clone(), - p.manifest.plugin.priority, - p.manifest.plugin.kind.clone(), - p.wasm_plugin.clone(), - ) - }) - .collect(); - drop(registry); - plugins.sort_by_key(|(_, priority, ..)| *priority); - plugins - } - - /// Get a reference to the capability enforcer. - #[must_use] - pub const fn enforcer(&self) -> &CapabilityEnforcer { - &self.enforcer - } - - /// List all UI pages provided by loaded plugins. - /// - /// Returns a vector of `(plugin_id, page)` tuples for all enabled plugins - /// that provide pages in their manifests. Both inline and file-referenced - /// page entries are resolved. - pub async fn list_ui_pages( - &self, - ) -> Vec<(String, pinakes_plugin_api::UiPage)> { - self - .list_ui_pages_with_endpoints() - .await - .into_iter() - .map(|(id, page, _)| (id, page)) - .collect() - } - - /// List all UI pages provided by loaded plugins, including each plugin's - /// declared endpoint allowlist. - /// - /// Returns a vector of `(plugin_id, page, allowed_endpoints)` tuples. The - /// `allowed_endpoints` list mirrors the `required_endpoints` field from the - /// plugin manifest's `[ui]` section. - pub async fn list_ui_pages_with_endpoints( - &self, - ) -> Vec<(String, pinakes_plugin_api::UiPage, Vec)> { - let registry = self.registry.read().await; - let mut pages = Vec::new(); - for plugin in registry.list_all() { - if !plugin.enabled { - continue; - } - let allowed = plugin.manifest.ui.required_endpoints.clone(); - let plugin_dir = plugin - .manifest_path - .as_ref() - .and_then(|p| p.parent()) - .map(std::path::Path::to_path_buf); - let Some(plugin_dir) = plugin_dir else { - for entry in &plugin.manifest.ui.pages { - if let pinakes_plugin_api::manifest::UiPageEntry::Inline(page) = entry - { - pages.push((plugin.id.clone(), (**page).clone(), allowed.clone())); - } - } - continue; - }; - match plugin.manifest.load_ui_pages(&plugin_dir) { - Ok(loaded) => { - for page in loaded { - pages.push((plugin.id.clone(), page, allowed.clone())); - } - }, - Err(e) => { - tracing::warn!( - "Failed to load UI pages for plugin '{}': {e}", - plugin.id - ); - }, - } - } - drop(registry); - pages - } - - /// Collect CSS custom property overrides declared by all enabled plugins. - /// - /// When multiple plugins declare the same property name, later-loaded plugins - /// overwrite earlier ones. Returns an empty map if no plugins are loaded or - /// none declare theme extensions. - pub async fn list_ui_theme_extensions( - &self, - ) -> rustc_hash::FxHashMap { - let registry = self.registry.read().await; - let mut merged = rustc_hash::FxHashMap::default(); - for plugin in registry.list_all() { - if !plugin.enabled { - continue; - } - for (k, v) in &plugin.manifest.ui.theme_extensions { - merged.insert(k.clone(), v.clone()); - } - } - drop(registry); - merged - } - - /// List all UI widgets provided by loaded plugins. - /// - /// Returns a vector of `(plugin_id, widget)` tuples for all enabled plugins - /// that provide widgets in their manifests. - pub async fn list_ui_widgets( - &self, - ) -> Vec<(String, pinakes_plugin_api::UiWidget)> { - let registry = self.registry.read().await; - let mut widgets = Vec::new(); - for plugin in registry.list_all() { - if !plugin.enabled { - continue; - } - for widget in &plugin.manifest.ui.widgets { - widgets.push((plugin.id.clone(), widget.clone())); - } - } - drop(registry); - widgets - } - - /// 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) - /// - /// # Errors - /// - /// Returns an error if hot-reload is disabled, the plugin is not found, it - /// cannot be shut down, or the reloaded plugin cannot be registered. - 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"))?; - let manifest = plugin.manifest_path.as_ref().map_or_else( - || plugin.manifest.clone(), - |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() - }) - }, - ); - drop(registry); - manifest - }; - - // 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(()) - } -} - -#[cfg(test)] -mod tests { - use tempfile::TempDir; - - use super::*; - - #[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"); - - let config = PluginManagerConfig::default(); - let manager = - PluginManager::new(data_dir.clone(), cache_dir.clone(), config); - - assert!(manager.is_ok()); - assert!(data_dir.exists()); - assert!(cache_dir.exists()); - } - - #[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 config = PluginManagerConfig::default(); - let manager = PluginManager::new(data_dir, cache_dir, config).unwrap(); - - let plugins = manager.list_plugins().await; - assert_eq!(plugins.len(), 0); - } - - /// Build a minimal manifest for dependency resolution tests - fn test_manifest( - name: &str, - deps: Vec, - ) -> pinakes_plugin_api::PluginManifest { - use pinakes_plugin_api::manifest::{PluginBinary, PluginInfo}; - - pinakes_plugin_api::PluginManifest { - plugin: PluginInfo { - name: name.to_string(), - version: "1.0.0".to_string(), - api_version: "1.0".to_string(), - author: None, - description: None, - homepage: None, - license: None, - priority: 500, - kind: vec!["media_type".to_string()], - binary: PluginBinary { - wasm: "plugin.wasm".to_string(), - entrypoint: None, - }, - dependencies: deps, - }, - capabilities: Default::default(), - config: Default::default(), - ui: Default::default(), - } - } - - #[test] - fn test_resolve_load_order_no_deps() { - let manifests = vec![ - test_manifest("alpha", vec![]), - test_manifest("beta", vec![]), - test_manifest("gamma", vec![]), - ]; - - let ordered = PluginManager::resolve_load_order(&manifests); - assert_eq!(ordered.len(), 3); - } - - #[test] - fn test_resolve_load_order_linear_chain() { - // gamma depends on beta, beta depends on alpha - let manifests = vec![ - test_manifest("gamma", vec!["beta".to_string()]), - test_manifest("alpha", vec![]), - test_manifest("beta", vec!["alpha".to_string()]), - ]; - - let ordered = PluginManager::resolve_load_order(&manifests); - assert_eq!(ordered.len(), 3); - - let names: Vec<&str> = - ordered.iter().map(|m| m.plugin.name.as_str()).collect(); - let alpha_pos = names.iter().position(|&n| n == "alpha").unwrap(); - let beta_pos = names.iter().position(|&n| n == "beta").unwrap(); - let gamma_pos = names.iter().position(|&n| n == "gamma").unwrap(); - assert!(alpha_pos < beta_pos, "alpha must load before beta"); - assert!(beta_pos < gamma_pos, "beta must load before gamma"); - } - - #[test] - fn test_resolve_load_order_cycle_detected() { - // A -> B -> C -> A (cycle) - let manifests = vec![ - test_manifest("a", vec!["c".to_string()]), - test_manifest("b", vec!["a".to_string()]), - test_manifest("c", vec!["b".to_string()]), - ]; - - let ordered = PluginManager::resolve_load_order(&manifests); - // All three should be excluded due to cycle - assert_eq!(ordered.len(), 0); - } - - #[test] - fn test_resolve_load_order_missing_dependency() { - let manifests = vec![ - test_manifest("good", vec![]), - test_manifest("bad", vec!["nonexistent".to_string()]), - ]; - - let ordered = PluginManager::resolve_load_order(&manifests); - // Only "good" should be loaded; "bad" depends on something missing - assert_eq!(ordered.len(), 1); - assert_eq!(ordered[0].plugin.name, "good"); - } - - #[test] - fn test_resolve_load_order_partial_cycle() { - // "ok" has no deps, "cycle_a" and "cycle_b" form a cycle - let manifests = vec![ - test_manifest("ok", vec![]), - test_manifest("cycle_a", vec!["cycle_b".to_string()]), - test_manifest("cycle_b", vec!["cycle_a".to_string()]), - ]; - - let ordered = PluginManager::resolve_load_order(&manifests); - assert_eq!(ordered.len(), 1); - assert_eq!(ordered[0].plugin.name, "ok"); - } - - #[test] - fn test_resolve_load_order_diamond() { - // Man look at how beautiful my diamond is... - // A - // / \ - // B C - // \ / - // D - let manifests = vec![ - test_manifest("d", vec!["b".to_string(), "c".to_string()]), - test_manifest("b", vec!["a".to_string()]), - test_manifest("c", vec!["a".to_string()]), - test_manifest("a", vec![]), - ]; - - let ordered = PluginManager::resolve_load_order(&manifests); - assert_eq!(ordered.len(), 4); - - let names: Vec<&str> = - ordered.iter().map(|m| m.plugin.name.as_str()).collect(); - let a_pos = names.iter().position(|&n| n == "a").unwrap(); - let b_pos = names.iter().position(|&n| n == "b").unwrap(); - let c_pos = names.iter().position(|&n| n == "c").unwrap(); - let d_pos = names.iter().position(|&n| n == "d").unwrap(); - assert!(a_pos < b_pos); - assert!(a_pos < c_pos); - assert!(b_pos < d_pos); - assert!(c_pos < d_pos); - } -} diff --git a/packages/pinakes-server/Cargo.toml b/crates/pinakes-server/Cargo.toml similarity index 84% rename from packages/pinakes-server/Cargo.toml rename to crates/pinakes-server/Cargo.toml index 6ead01c..14e329e 100644 --- a/packages/pinakes-server/Cargo.toml +++ b/crates/pinakes-server/Cargo.toml @@ -7,11 +7,6 @@ license.workspace = true [dependencies] pinakes-core = { workspace = true } pinakes-plugin-api = { workspace = true } -pinakes-types = { workspace = true } -pinakes-enrichment = { workspace = true } -pinakes-metadata = { workspace = true } -pinakes-plugin = { workspace = true } -pinakes-sync = { workspace = true } tokio = { workspace = true } serde = { workspace = true } @@ -41,10 +36,10 @@ utoipa = { workspace = true } utoipa-axum = { workspace = true } utoipa-swagger-ui = { workspace = true } -[dev-dependencies] -http-body-util = { workspace = true } -reqwest = { workspace = true } -tempfile = { workspace = true } - [lints] workspace = true + +[dev-dependencies] +http-body-util = "0.1.3" +reqwest = { workspace = true } +tempfile = { workspace = true } diff --git a/packages/pinakes-server/src/api_doc.rs b/crates/pinakes-server/src/api_doc.rs similarity index 100% rename from packages/pinakes-server/src/api_doc.rs rename to crates/pinakes-server/src/api_doc.rs diff --git a/packages/pinakes-server/src/app.rs b/crates/pinakes-server/src/app.rs similarity index 96% rename from packages/pinakes-server/src/app.rs rename to crates/pinakes-server/src/app.rs index 88f0080..3d2f531 100644 --- a/packages/pinakes-server/src/app.rs +++ b/crates/pinakes-server/src/app.rs @@ -27,6 +27,8 @@ pub fn create_router( create_router_with_tls(state, rate_limits, None) } +/// Build a governor rate limiter from per-second and burst-size values. +/// Panics if the config is invalid (callers must validate before use). fn build_governor( per_second: u64, burst_size: u32, @@ -36,18 +38,13 @@ fn build_governor( governor::middleware::NoOpMiddleware, >, > { - // finish() returns None only when per_second=0; clamp to ensure it always - // returns Some - let per_second = per_second.max(1); - let burst_size = burst_size.max(1); - let Some(config) = GovernorConfigBuilder::default() - .per_second(per_second) - .burst_size(burst_size) - .finish() - else { - return build_governor(1, 1); - }; - Arc::new(config) + Arc::new( + GovernorConfigBuilder::default() + .per_second(per_second) + .burst_size(burst_size) + .finish() + .expect("rate limit config was validated at startup"), + ) } /// Create the router with TLS configuration for security headers @@ -524,16 +521,8 @@ pub fn create_router_with_tls( // CORS configuration: use config-driven origins if specified, // otherwise fall back to default localhost origins let cors = { - let default_origins = || { - vec![ - HeaderValue::from_static("http://localhost:3000"), - HeaderValue::from_static("http://127.0.0.1:3000"), - HeaderValue::from_static("tauri://localhost"), - ] - }; - let origins: Vec = state.config.try_read().map_or_else( - |_| default_origins(), - |config_read| { + let origins: Vec = + if let Ok(config_read) = state.config.try_read() { if config_read.server.cors_enabled && !config_read.server.cors_origins.is_empty() { @@ -544,10 +533,19 @@ pub fn create_router_with_tls( .filter_map(|o| HeaderValue::from_str(o).ok()) .collect() } else { - default_origins() + vec![ + HeaderValue::from_static("http://localhost:3000"), + HeaderValue::from_static("http://127.0.0.1:3000"), + HeaderValue::from_static("tauri://localhost"), + ] } - }, - ); + } else { + vec![ + HeaderValue::from_static("http://localhost:3000"), + HeaderValue::from_static("http://127.0.0.1:3000"), + HeaderValue::from_static("tauri://localhost"), + ] + }; CorsLayer::new() .allow_origin(origins) diff --git a/packages/pinakes-server/src/auth.rs b/crates/pinakes-server/src/auth.rs similarity index 94% rename from packages/pinakes-server/src/auth.rs rename to crates/pinakes-server/src/auth.rs index ca81026..6405612 100644 --- a/packages/pinakes-server/src/auth.rs +++ b/crates/pinakes-server/src/auth.rs @@ -1,5 +1,3 @@ -use std::sync::Arc; - use axum::{ extract::{Request, State}, http::StatusCode, @@ -92,10 +90,8 @@ pub async fn require_auth( if session.expires_at < now { let username = session.username; // Delete expired session in a bounded background task - if let Ok(permit) = - Arc::clone(&state.session_semaphore).try_acquire_owned() - { - let storage = Arc::clone(&state.storage); + if let Ok(permit) = state.session_semaphore.clone().try_acquire_owned() { + let storage = state.storage.clone(); let token_owned = token.clone(); tokio::spawn(async move { if let Err(e) = storage.delete_session(&token_owned).await { @@ -109,9 +105,8 @@ pub async fn require_auth( } // Update last_accessed timestamp in a bounded background task - if let Ok(permit) = Arc::clone(&state.session_semaphore).try_acquire_owned() - { - let storage = Arc::clone(&state.storage); + if let Ok(permit) = state.session_semaphore.clone().try_acquire_owned() { + let storage = state.storage.clone(); let token_owned = token.clone(); tokio::spawn(async move { if let Err(e) = storage.touch_session(&token_owned).await { @@ -214,9 +209,7 @@ pub async fn require_admin(request: Request, next: Next) -> Response { /// Resolve the authenticated username (from request extensions) to a `UserId`. /// -/// # Errors -/// -/// Returns an error if the user cannot be found in the database. +/// Returns an error if the user cannot be found. pub async fn resolve_user_id( storage: &pinakes_core::storage::DynStorageBackend, username: &str, diff --git a/packages/pinakes-server/src/dto/analytics.rs b/crates/pinakes-server/src/dto/analytics.rs similarity index 100% rename from packages/pinakes-server/src/dto/analytics.rs rename to crates/pinakes-server/src/dto/analytics.rs diff --git a/packages/pinakes-server/src/dto/audit.rs b/crates/pinakes-server/src/dto/audit.rs similarity index 100% rename from packages/pinakes-server/src/dto/audit.rs rename to crates/pinakes-server/src/dto/audit.rs diff --git a/packages/pinakes-server/src/dto/batch.rs b/crates/pinakes-server/src/dto/batch.rs similarity index 100% rename from packages/pinakes-server/src/dto/batch.rs rename to crates/pinakes-server/src/dto/batch.rs diff --git a/packages/pinakes-server/src/dto/collections.rs b/crates/pinakes-server/src/dto/collections.rs similarity index 100% rename from packages/pinakes-server/src/dto/collections.rs rename to crates/pinakes-server/src/dto/collections.rs diff --git a/packages/pinakes-server/src/dto/config.rs b/crates/pinakes-server/src/dto/config.rs similarity index 100% rename from packages/pinakes-server/src/dto/config.rs rename to crates/pinakes-server/src/dto/config.rs diff --git a/packages/pinakes-server/src/dto/enrichment.rs b/crates/pinakes-server/src/dto/enrichment.rs similarity index 85% rename from packages/pinakes-server/src/dto/enrichment.rs rename to crates/pinakes-server/src/dto/enrichment.rs index a1f19bd..4e144de 100644 --- a/packages/pinakes-server/src/dto/enrichment.rs +++ b/crates/pinakes-server/src/dto/enrichment.rs @@ -13,8 +13,10 @@ pub struct ExternalMetadataResponse { pub last_updated: DateTime, } -impl From for ExternalMetadataResponse { - fn from(m: pinakes_enrichment::ExternalMetadata) -> Self { +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 {}: {}", diff --git a/packages/pinakes-server/src/dto/media.rs b/crates/pinakes-server/src/dto/media.rs similarity index 100% rename from packages/pinakes-server/src/dto/media.rs rename to crates/pinakes-server/src/dto/media.rs diff --git a/packages/pinakes-server/src/dto/mod.rs b/crates/pinakes-server/src/dto/mod.rs similarity index 100% rename from packages/pinakes-server/src/dto/mod.rs rename to crates/pinakes-server/src/dto/mod.rs diff --git a/packages/pinakes-server/src/dto/playlists.rs b/crates/pinakes-server/src/dto/playlists.rs similarity index 100% rename from packages/pinakes-server/src/dto/playlists.rs rename to crates/pinakes-server/src/dto/playlists.rs diff --git a/packages/pinakes-server/src/dto/plugins.rs b/crates/pinakes-server/src/dto/plugins.rs similarity index 100% rename from packages/pinakes-server/src/dto/plugins.rs rename to crates/pinakes-server/src/dto/plugins.rs diff --git a/packages/pinakes-server/src/dto/scan.rs b/crates/pinakes-server/src/dto/scan.rs similarity index 100% rename from packages/pinakes-server/src/dto/scan.rs rename to crates/pinakes-server/src/dto/scan.rs diff --git a/packages/pinakes-server/src/dto/search.rs b/crates/pinakes-server/src/dto/search.rs similarity index 100% rename from packages/pinakes-server/src/dto/search.rs rename to crates/pinakes-server/src/dto/search.rs diff --git a/packages/pinakes-server/src/dto/sharing.rs b/crates/pinakes-server/src/dto/sharing.rs similarity index 97% rename from packages/pinakes-server/src/dto/sharing.rs rename to crates/pinakes-server/src/dto/sharing.rs index 65dc4c1..4757e26 100644 --- a/packages/pinakes-server/src/dto/sharing.rs +++ b/crates/pinakes-server/src/dto/sharing.rs @@ -17,7 +17,6 @@ pub struct CreateShareRequest { } #[derive(Debug, Deserialize, utoipa::ToSchema)] -#[allow(clippy::struct_field_names)] pub struct SharePermissionsRequest { pub can_view: Option, pub can_download: Option, @@ -48,7 +47,6 @@ pub struct ShareResponse { } #[derive(Debug, Serialize, utoipa::ToSchema)] -#[allow(clippy::struct_excessive_bools, clippy::struct_field_names)] pub struct SharePermissionsResponse { pub can_view: bool, pub can_download: bool, @@ -199,6 +197,6 @@ pub struct AccessSharedRequest { #[derive(Debug, Serialize, utoipa::ToSchema)] #[serde(untagged)] pub enum SharedContentResponse { - Single(Box), + Single(super::MediaResponse), Multiple { items: Vec }, } diff --git a/packages/pinakes-server/src/dto/social.rs b/crates/pinakes-server/src/dto/social.rs similarity index 100% rename from packages/pinakes-server/src/dto/social.rs rename to crates/pinakes-server/src/dto/social.rs diff --git a/packages/pinakes-server/src/dto/statistics.rs b/crates/pinakes-server/src/dto/statistics.rs similarity index 100% rename from packages/pinakes-server/src/dto/statistics.rs rename to crates/pinakes-server/src/dto/statistics.rs diff --git a/packages/pinakes-server/src/dto/subtitles.rs b/crates/pinakes-server/src/dto/subtitles.rs similarity index 100% rename from packages/pinakes-server/src/dto/subtitles.rs rename to crates/pinakes-server/src/dto/subtitles.rs diff --git a/packages/pinakes-server/src/dto/sync.rs b/crates/pinakes-server/src/dto/sync.rs similarity index 90% rename from packages/pinakes-server/src/dto/sync.rs rename to crates/pinakes-server/src/dto/sync.rs index 0f7b172..34b2056 100644 --- a/packages/pinakes-server/src/dto/sync.rs +++ b/crates/pinakes-server/src/dto/sync.rs @@ -25,8 +25,8 @@ pub struct DeviceResponse { pub created_at: DateTime, } -impl From for DeviceResponse { - fn from(d: pinakes_sync::SyncDevice) -> Self { +impl From for DeviceResponse { + fn from(d: pinakes_core::sync::SyncDevice) -> Self { Self { id: d.id.0.to_string(), name: d.name, @@ -72,8 +72,8 @@ pub struct SyncChangeResponse { pub timestamp: DateTime, } -impl From for SyncChangeResponse { - fn from(e: pinakes_sync::SyncLogEntry) -> Self { +impl From for SyncChangeResponse { + fn from(e: pinakes_core::sync::SyncLogEntry) -> Self { Self { id: e.id.to_string(), sequence: e.sequence, @@ -124,8 +124,8 @@ pub struct ConflictResponse { pub detected_at: DateTime, } -impl From for ConflictResponse { - fn from(c: pinakes_sync::SyncConflict) -> Self { +impl From for ConflictResponse { + fn from(c: pinakes_core::sync::SyncConflict) -> Self { Self { id: c.id.to_string(), path: c.path, @@ -162,8 +162,8 @@ pub struct UploadSessionResponse { pub expires_at: DateTime, } -impl From for UploadSessionResponse { - fn from(s: pinakes_sync::UploadSession) -> Self { +impl From for UploadSessionResponse { + fn from(s: pinakes_core::sync::UploadSession) -> Self { Self { id: s.id.to_string(), target_path: s.target_path, diff --git a/packages/pinakes-server/src/dto/tags.rs b/crates/pinakes-server/src/dto/tags.rs similarity index 100% rename from packages/pinakes-server/src/dto/tags.rs rename to crates/pinakes-server/src/dto/tags.rs diff --git a/packages/pinakes-server/src/dto/transcode.rs b/crates/pinakes-server/src/dto/transcode.rs similarity index 100% rename from packages/pinakes-server/src/dto/transcode.rs rename to crates/pinakes-server/src/dto/transcode.rs diff --git a/packages/pinakes-server/src/dto/users.rs b/crates/pinakes-server/src/dto/users.rs similarity index 100% rename from packages/pinakes-server/src/dto/users.rs rename to crates/pinakes-server/src/dto/users.rs diff --git a/packages/pinakes-server/src/error.rs b/crates/pinakes-server/src/error.rs similarity index 95% rename from packages/pinakes-server/src/error.rs rename to crates/pinakes-server/src/error.rs index b5016ed..c18592d 100644 --- a/packages/pinakes-server/src/error.rs +++ b/crates/pinakes-server/src/error.rs @@ -15,11 +15,7 @@ impl IntoResponse for ApiError { fn into_response(self) -> Response { use pinakes_core::error::PinakesError; let (status, message) = match &self.0 { - PinakesError::NotFound(msg) - | PinakesError::TagNotFound(msg) - | PinakesError::CollectionNotFound(msg) => { - (StatusCode::NOT_FOUND, msg.clone()) - }, + 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_or_else( @@ -29,6 +25,10 @@ impl IntoResponse for ApiError { tracing::debug!(path = %path.display(), "file not found"); (StatusCode::NOT_FOUND, format!("file not found: {name}")) }, + PinakesError::TagNotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), + PinakesError::CollectionNotFound(msg) => { + (StatusCode::NOT_FOUND, msg.clone()) + }, PinakesError::DuplicateHash(msg) => (StatusCode::CONFLICT, msg.clone()), PinakesError::UnsupportedMediaType(path) => { let name = path.file_name().map_or_else( diff --git a/packages/pinakes-server/src/lib.rs b/crates/pinakes-server/src/lib.rs similarity index 100% rename from packages/pinakes-server/src/lib.rs rename to crates/pinakes-server/src/lib.rs diff --git a/packages/pinakes-server/src/main.rs b/crates/pinakes-server/src/main.rs similarity index 93% rename from packages/pinakes-server/src/main.rs rename to crates/pinakes-server/src/main.rs index 3c8c030..28dbeb9 100644 --- a/packages/pinakes-server/src/main.rs +++ b/crates/pinakes-server/src/main.rs @@ -4,7 +4,6 @@ use anyhow::Result; use axum::{Router, response::Redirect, routing::any}; use clap::Parser; use pinakes_core::{config::Config, storage::StorageBackend}; -use pinakes_enrichment::EnrichmentSourceType; use pinakes_server::{app, state::AppState}; use tokio::sync::RwLock; use tracing::info; @@ -190,7 +189,7 @@ async fn main() -> Result<()> { // Start filesystem watcher if configured if config.scanning.watch { - let watch_storage = Arc::clone(&storage); + let watch_storage = storage.clone(); let watch_dirs = config.directories.roots.clone(); let watch_ignore = config.scanning.ignore_patterns.clone(); tokio::spawn(async move { @@ -237,27 +236,10 @@ async fn main() -> Result<()> { // Initialize plugin manager if plugins are enabled (needed before job queue) let plugin_manager = if config.plugins.enabled { - let pm_cfg = { - let p = &config.plugins; - pinakes_plugin::PluginManagerConfig { - plugin_dirs: p.plugin_dirs.clone(), - enable_hot_reload: p.enable_hot_reload, - allow_unsigned: p.allow_unsigned, - max_concurrent_ops: p.max_concurrent_ops, - plugin_timeout_secs: p.plugin_timeout_secs, - timeouts: pinakes_types::config::PluginTimeoutConfig { - capability_query: p.timeouts.capability_query, - processing: p.timeouts.processing, - event_handler: p.timeouts.event_handler, - }, - max_consecutive_failures: p.max_consecutive_failures, - trusted_keys: p.trusted_keys.clone(), - } - }; - match pinakes_plugin::PluginManager::new( + match pinakes_core::plugin::PluginManager::new( config.plugins.data_dir.clone(), config.plugins.cache_dir.clone(), - pm_cfg, + config.plugins.clone().into(), ) { Ok(pm) => { tracing::info!("Plugin manager initialized"); @@ -298,7 +280,7 @@ async fn main() -> Result<()> { }; // Initialize job queue with executor - let job_storage = Arc::clone(&storage); + let job_storage = storage.clone(); let job_config = config.clone(); let job_transcode = transcode_service.clone(); let job_webhooks = webhook_dispatcher.clone(); @@ -307,7 +289,7 @@ async fn main() -> Result<()> { config.jobs.worker_count, config.jobs.job_timeout_secs, move |job_id, kind, cancel, jobs| { - let storage = Arc::clone(&job_storage); + let storage = job_storage.clone(); let config = job_config.clone(); let transcode_svc = job_transcode.clone(); let webhooks = job_webhooks.clone(); @@ -401,16 +383,10 @@ async fn main() -> Result<()> { if cancel.is_cancelled() { break; } - #[expect( - clippy::cast_precision_loss, - reason = "progress ratio; precision loss negligible for \ - display" - )] - let progress = i as f32 / total as f32; JobQueue::update_progress( &jobs, job_id, - progress, + i as f32 / total as f32, format!("{i}/{total}"), ) .await; @@ -562,13 +538,15 @@ async fn main() -> Result<()> { } }, JobKind::Enrich { media_ids } => { - use pinakes_core::media_type::MediaCategory; - use pinakes_enrichment::{ - MetadataEnricher, - books::BookEnricher, - lastfm::LastFmEnricher, - musicbrainz::MusicBrainzEnricher, - tmdb::TmdbEnricher, + use pinakes_core::{ + enrichment::{ + MetadataEnricher, + books::BookEnricher, + lastfm::LastFmEnricher, + musicbrainz::MusicBrainzEnricher, + tmdb::TmdbEnricher, + }, + media_type::MediaCategory, }; let enrich_cfg = &config.enrichment; @@ -582,12 +560,7 @@ async fn main() -> Result<()> { enrich_cfg.sources.tmdb.enabled, enrich_cfg.sources.tmdb.api_key.clone(), ) { - match TmdbEnricher::new(key) { - Ok(e) => enrichers.push(Box::new(e)), - Err(err) => { - tracing::warn!("Failed to build TMDB enricher: {err}"); - }, - } + enrichers.push(Box::new(TmdbEnricher::new(key))); } if let (true, Some(key)) = ( enrich_cfg.sources.lastfm.enabled, @@ -625,6 +598,7 @@ async fn main() -> Result<()> { let category = item.media_type.category(); for enricher in &enrichers { let source = enricher.source(); + use pinakes_core::enrichment::EnrichmentSourceType; let applicable = match source { EnrichmentSourceType::MusicBrainz | EnrichmentSourceType::LastFm => { @@ -685,7 +659,7 @@ async fn main() -> Result<()> { JobKind::CleanupAnalytics => { let retention_days = config.analytics.retention_days; let before = chrono::Utc::now() - - chrono::Duration::days(retention_days.cast_signed()); + - chrono::Duration::days(retention_days as i64); match storage.cleanup_old_events(before).await { Ok(count) => { JobQueue::complete( @@ -701,7 +675,7 @@ async fn main() -> Result<()> { JobKind::TrashPurge => { let retention_days = config.trash.retention_days; let before = chrono::Utc::now() - - chrono::Duration::days(retention_days.cast_signed()); + - chrono::Duration::days(retention_days as i64); match storage.purge_old_trash(before).await { Ok(count) => { @@ -734,9 +708,9 @@ async fn main() -> Result<()> { let shutdown_token = tokio_util::sync::CancellationToken::new(); let config_arc = Arc::new(RwLock::new(config)); let scheduler = pinakes_core::scheduler::TaskScheduler::new( - Arc::clone(&job_queue), + job_queue.clone(), shutdown_token.clone(), - Arc::clone(&config_arc), + config_arc.clone(), Some(config_path.clone()), ); let scheduler = Arc::new(scheduler); @@ -746,7 +720,7 @@ async fn main() -> Result<()> { // Spawn scheduler background loop { - let scheduler = Arc::clone(&scheduler); + let scheduler = scheduler.clone(); tokio::spawn(async move { scheduler.run().await; }); @@ -784,7 +758,7 @@ async fn main() -> Result<()> { let chunked_upload_manager = { let config_read = config_arc.read().await; if config_read.sync.enabled { - let manager = pinakes_sync::ChunkedUploadManager::new( + let manager = pinakes_core::sync::ChunkedUploadManager::new( config_read.sync.temp_upload_dir.clone(), ); match manager.init().await { @@ -807,8 +781,8 @@ async fn main() -> Result<()> { }; let state = AppState { - storage: Arc::clone(&storage), - config: Arc::clone(&config_arc), + storage: storage.clone(), + config: config_arc.clone(), config_path: Some(config_path), scan_progress: pinakes_core::scan::ScanProgress::new(), job_queue, @@ -827,7 +801,7 @@ async fn main() -> Result<()> { // Periodic session cleanup (every 15 minutes) { - let storage_clone = Arc::clone(&storage); + let storage_clone = storage.clone(); let cancel = shutdown_token.clone(); tokio::spawn(async move { let mut interval = @@ -855,7 +829,7 @@ async fn main() -> Result<()> { // Periodic chunked upload cleanup (every hour) if let Some(ref manager) = state.chunked_upload_manager { - let manager_clone = Arc::clone(manager); + let manager_clone = manager.clone(); let cancel = shutdown_token.clone(); tokio::spawn(async move { let mut interval = diff --git a/packages/pinakes-server/src/routes/analytics.rs b/crates/pinakes-server/src/routes/analytics.rs similarity index 92% rename from packages/pinakes-server/src/routes/analytics.rs rename to crates/pinakes-server/src/routes/analytics.rs index 9b61bf8..fda8fd9 100644 --- a/packages/pinakes-server/src/routes/analytics.rs +++ b/crates/pinakes-server/src/routes/analytics.rs @@ -39,9 +39,6 @@ const MAX_LIMIT: u64 = 100; ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_most_viewed( State(state): State, Query(params): Query, @@ -77,9 +74,6 @@ pub async fn get_most_viewed( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_recently_viewed( State(state): State, Extension(username): Extension, @@ -109,9 +103,6 @@ pub async fn get_recently_viewed( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn record_event( State(state): State, Extension(username): Extension, @@ -150,9 +141,6 @@ pub async fn record_event( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_watch_progress( State(state): State, Extension(username): Extension, @@ -186,9 +174,6 @@ pub async fn get_watch_progress( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn update_watch_progress( State(state): State, Extension(username): Extension, diff --git a/packages/pinakes-server/src/routes/audit.rs b/crates/pinakes-server/src/routes/audit.rs similarity index 91% rename from packages/pinakes-server/src/routes/audit.rs rename to crates/pinakes-server/src/routes/audit.rs index 272a37e..80ccd10 100644 --- a/packages/pinakes-server/src/routes/audit.rs +++ b/crates/pinakes-server/src/routes/audit.rs @@ -24,9 +24,6 @@ use crate::{ ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn list_audit( State(state): State, Query(params): Query, diff --git a/packages/pinakes-server/src/routes/auth.rs b/crates/pinakes-server/src/routes/auth.rs similarity index 88% rename from packages/pinakes-server/src/routes/auth.rs rename to crates/pinakes-server/src/routes/auth.rs index d75e9d6..a4561f5 100644 --- a/packages/pinakes-server/src/routes/auth.rs +++ b/crates/pinakes-server/src/routes/auth.rs @@ -1,10 +1,8 @@ -use argon2::password_hash::PasswordVerifier; use axum::{ Json, extract::State, http::{HeaderMap, StatusCode}, }; -use rand::seq::IndexedRandom as _; use crate::{ dto::{LoginRequest, LoginResponse, UserInfoResponse}, @@ -19,16 +17,6 @@ const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,\ p=1$VGltaW5nU2FmZUR1bW15$c2ltdWxhdGVkX2hhc2hfZm9yX3RpbWluZ19zYWZldHk"; -/// Authenticate a user with username and password, creating a session. -/// -/// # Errors -/// -/// Returns an error if the credentials are invalid or the session cannot be -/// created. -/// -/// # Panics -/// -/// Panics if the CHARSET is empty (it is not). #[utoipa::path( post, path = "/api/v1/auth/login", @@ -65,8 +53,12 @@ pub async fn login( // Always perform password verification to prevent timing attacks. // If the user doesn't exist, we verify against a dummy hash to ensure // consistent response times regardless of whether the username exists. - let (hash_to_verify, user_found) = - user.map_or((DUMMY_HASH, false), |u| (&u.password_hash as &str, true)); + 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)?; @@ -105,14 +97,13 @@ pub async fn login( // Generate session token using unbiased uniform distribution #[expect(clippy::expect_used)] let token: String = { + use rand::seq::IndexedRandom; const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; let mut rng = rand::rng(); - std::iter::repeat_with(|| { - *CHARSET.choose(&mut rng).expect("non-empty charset") as char - }) - .take(48) - .collect() + (0..48) + .map(|_| *CHARSET.choose(&mut rng).expect("non-empty charset") as char) + .collect() }; let role = user.role; @@ -127,9 +118,7 @@ pub async fn login( role: role.to_string(), created_at: now, expires_at: now - + chrono::Duration::hours( - config.accounts.session_expiry_hours.cast_signed(), - ), + + chrono::Duration::hours(config.accounts.session_expiry_hours as i64), last_accessed: now, }; @@ -206,12 +195,6 @@ pub async fn logout( StatusCode::OK } -/// Return current user info from the bearer token session. -/// -/// # Errors -/// -/// Returns an error if the token is missing, invalid, or the session lookup -/// fails. #[utoipa::path( get, path = "/api/v1/auth/me", @@ -260,11 +243,6 @@ fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> { /// Refresh the current session, extending its expiry by the configured /// duration. -/// -/// # Errors -/// -/// Returns an error if the token is missing, the session does not exist, or the -/// database update fails. #[utoipa::path( post, path = "/api/v1/auth/refresh", @@ -283,7 +261,7 @@ pub async fn refresh( let token = extract_bearer_token(&headers).ok_or(StatusCode::UNAUTHORIZED)?; let config = state.config.read().await; - let expiry_hours = config.accounts.session_expiry_hours.cast_signed(); + let expiry_hours = config.accounts.session_expiry_hours as i64; drop(config); let new_expires_at = @@ -319,8 +297,9 @@ pub async fn revoke_all_sessions( State(state): State, headers: HeaderMap, ) -> StatusCode { - let Some(token) = extract_bearer_token(&headers) else { - return StatusCode::UNAUTHORIZED; + let token = match extract_bearer_token(&headers) { + Some(t) => t, + None => return StatusCode::UNAUTHORIZED, }; // Get current session to find username @@ -361,11 +340,7 @@ pub async fn revoke_all_sessions( } } -/// List all active sessions (admin only). -/// -/// # Errors -/// -/// Returns an error if the database query fails. +/// List all active sessions (admin only) #[derive(serde::Serialize, utoipa::ToSchema)] pub struct SessionListResponse { pub sessions: Vec, @@ -392,9 +367,6 @@ pub struct SessionInfo { ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn list_active_sessions( State(state): State, ) -> Result, StatusCode> { diff --git a/packages/pinakes-server/src/routes/backup.rs b/crates/pinakes-server/src/routes/backup.rs similarity index 95% rename from packages/pinakes-server/src/routes/backup.rs rename to crates/pinakes-server/src/routes/backup.rs index 69e84e3..d80b31f 100644 --- a/packages/pinakes-server/src/routes/backup.rs +++ b/crates/pinakes-server/src/routes/backup.rs @@ -23,9 +23,6 @@ use crate::{error::ApiError, state::AppState}; ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn create_backup( State(state): State, ) -> Result { diff --git a/packages/pinakes-server/src/routes/books.rs b/crates/pinakes-server/src/routes/books.rs similarity index 92% rename from packages/pinakes-server/src/routes/books.rs rename to crates/pinakes-server/src/routes/books.rs index 5640bba..9993492 100644 --- a/packages/pinakes-server/src/routes/books.rs +++ b/crates/pinakes-server/src/routes/books.rs @@ -168,23 +168,19 @@ pub struct AuthorSummary { ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_book_metadata( State(state): State, Path(media_id): Path, ) -> Result { let media_id = MediaId(media_id); - let metadata = state - .storage - .get_book_metadata(media_id) - .await? - .ok_or_else(|| { - ApiError(PinakesError::NotFound( + 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))) } @@ -210,9 +206,6 @@ pub async fn get_book_metadata( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn list_books( State(state): State, Query(query): Query, @@ -254,9 +247,6 @@ pub async fn list_books( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn list_series( State(state): State, ) -> Result { @@ -286,9 +276,6 @@ pub async fn list_series( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_series_books( State(state): State, Path(series_name): Path, @@ -317,9 +304,6 @@ pub async fn get_series_books( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn list_authors( State(state): State, Query(pagination): Query, @@ -354,9 +338,6 @@ pub async fn list_authors( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_author_books( State(state): State, Path(author_name): Path, @@ -388,9 +369,6 @@ pub async fn get_author_books( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_reading_progress( State(state): State, Extension(username): Extension, @@ -403,11 +381,9 @@ pub async fn get_reading_progress( .storage .get_reading_progress(user_id.0, media_id) .await? - .ok_or_else(|| { - ApiError(PinakesError::NotFound( - "Reading progress not found".to_string(), - )) - })?; + .ok_or(ApiError(PinakesError::NotFound( + "Reading progress not found".to_string(), + )))?; Ok(Json(ReadingProgressResponse::from(progress))) } @@ -426,9 +402,6 @@ pub async fn get_reading_progress( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn update_reading_progress( State(state): State, Extension(username): Extension, @@ -465,9 +438,6 @@ pub async fn update_reading_progress( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_reading_list( State(state): State, Extension(username): Extension, diff --git a/packages/pinakes-server/src/routes/collections.rs b/crates/pinakes-server/src/routes/collections.rs similarity index 90% rename from packages/pinakes-server/src/routes/collections.rs rename to crates/pinakes-server/src/routes/collections.rs index 49b7b0a..a1df04d 100644 --- a/packages/pinakes-server/src/routes/collections.rs +++ b/crates/pinakes-server/src/routes/collections.rs @@ -30,9 +30,6 @@ use crate::{ ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn create_collection( State(state): State, Json(req): Json, @@ -88,9 +85,6 @@ pub async fn create_collection( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn list_collections( State(state): State, ) -> Result>, ApiError> { @@ -113,9 +107,6 @@ pub async fn list_collections( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_collection( State(state): State, Path(id): Path, @@ -138,9 +129,6 @@ pub async fn get_collection( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_collection( State(state): State, Path(id): Path, @@ -170,9 +158,6 @@ pub async fn delete_collection( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn add_member( State(state): State, Path(collection_id): Path, @@ -205,9 +190,6 @@ pub async fn add_member( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn remove_member( State(state): State, Path((collection_id, media_id)): Path<(Uuid, Uuid)>, @@ -234,9 +216,6 @@ pub async fn remove_member( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_members( State(state): State, Path(collection_id): Path, diff --git a/packages/pinakes-server/src/routes/config.rs b/crates/pinakes-server/src/routes/config.rs similarity index 86% rename from packages/pinakes-server/src/routes/config.rs rename to crates/pinakes-server/src/routes/config.rs index 55fdbaa..7a76f83 100644 --- a/packages/pinakes-server/src/routes/config.rs +++ b/crates/pinakes-server/src/routes/config.rs @@ -26,9 +26,6 @@ use crate::{ ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_config( State(state): State, ) -> Result, ApiError> { @@ -39,15 +36,18 @@ pub async fn get_config( .config_path .as_ref() .map(|p| p.to_string_lossy().to_string()); - let config_writable = state.config_path.as_ref().is_some_and(|path| { - if path.exists() { - std::fs::metadata(path).is_ok_and(|m| !m.permissions().readonly()) - } else { - path.parent().is_some_and(|parent| { - std::fs::metadata(parent).is_ok_and(|m| !m.permissions().readonly()) - }) - } - }); + let config_writable = match &state.config_path { + Some(path) => { + if path.exists() { + std::fs::metadata(path).is_ok_and(|m| !m.permissions().readonly()) + } else { + path.parent().is_some_and(|parent| { + std::fs::metadata(parent).is_ok_and(|m| !m.permissions().readonly()) + }) + } + }, + None => false, + }; Ok(Json(ConfigResponse { backend: config.storage.backend.to_string(), @@ -86,9 +86,6 @@ pub async fn get_config( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_ui_config( State(state): State, ) -> Result, ApiError> { @@ -109,9 +106,6 @@ pub async fn get_ui_config( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn update_ui_config( State(state): State, Json(req): Json, @@ -159,9 +153,6 @@ pub async fn update_ui_config( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn update_scanning_config( State(state): State, Json(req): Json, @@ -188,15 +179,18 @@ pub async fn update_scanning_config( .config_path .as_ref() .map(|p| p.to_string_lossy().to_string()); - let config_writable = state.config_path.as_ref().is_some_and(|path| { - if path.exists() { - std::fs::metadata(path).is_ok_and(|m| !m.permissions().readonly()) - } else { - path.parent().is_some_and(|parent| { - std::fs::metadata(parent).is_ok_and(|m| !m.permissions().readonly()) - }) - } - }); + let config_writable = match &state.config_path { + Some(path) => { + if path.exists() { + std::fs::metadata(path).is_ok_and(|m| !m.permissions().readonly()) + } else { + path.parent().is_some_and(|parent| { + std::fs::metadata(parent).is_ok_and(|m| !m.permissions().readonly()) + }) + } + }, + None => false, + }; Ok(Json(ConfigResponse { backend: config.storage.backend.to_string(), @@ -238,9 +232,6 @@ pub async fn update_scanning_config( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn add_root( State(state): State, Json(req): Json, @@ -281,9 +272,6 @@ pub async fn add_root( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn remove_root( State(state): State, Json(req): Json, diff --git a/packages/pinakes-server/src/routes/database.rs b/crates/pinakes-server/src/routes/database.rs similarity index 88% rename from packages/pinakes-server/src/routes/database.rs rename to crates/pinakes-server/src/routes/database.rs index a927dd4..e88fcb8 100644 --- a/packages/pinakes-server/src/routes/database.rs +++ b/crates/pinakes-server/src/routes/database.rs @@ -14,9 +14,6 @@ use crate::{dto::DatabaseStatsResponse, error::ApiError, state::AppState}; ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn database_stats( State(state): State, ) -> Result, ApiError> { @@ -43,9 +40,6 @@ pub async fn database_stats( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn vacuum_database( State(state): State, ) -> Result, ApiError> { @@ -65,9 +59,6 @@ pub async fn vacuum_database( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn clear_database( State(state): State, ) -> Result, ApiError> { diff --git a/packages/pinakes-server/src/routes/duplicates.rs b/crates/pinakes-server/src/routes/duplicates.rs similarity index 92% rename from packages/pinakes-server/src/routes/duplicates.rs rename to crates/pinakes-server/src/routes/duplicates.rs index ddcb5f4..6150979 100644 --- a/packages/pinakes-server/src/routes/duplicates.rs +++ b/crates/pinakes-server/src/routes/duplicates.rs @@ -17,9 +17,6 @@ use crate::{ ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn list_duplicates( State(state): State, ) -> Result>, ApiError> { diff --git a/packages/pinakes-server/src/routes/enrichment.rs b/crates/pinakes-server/src/routes/enrichment.rs similarity index 91% rename from packages/pinakes-server/src/routes/enrichment.rs rename to crates/pinakes-server/src/routes/enrichment.rs index c7b9897..1060cc3 100644 --- a/packages/pinakes-server/src/routes/enrichment.rs +++ b/crates/pinakes-server/src/routes/enrichment.rs @@ -25,9 +25,6 @@ use crate::{ ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn trigger_enrichment( State(state): State, Path(id): Path, @@ -55,9 +52,6 @@ pub async fn trigger_enrichment( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_external_metadata( State(state): State, Path(id): Path, @@ -85,9 +79,6 @@ pub async fn get_external_metadata( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn batch_enrich( State(state): State, Json(req): Json, // Reuse: has media_ids field diff --git a/packages/pinakes-server/src/routes/export.rs b/crates/pinakes-server/src/routes/export.rs similarity index 91% rename from packages/pinakes-server/src/routes/export.rs rename to crates/pinakes-server/src/routes/export.rs index 4172b2c..8251272 100644 --- a/packages/pinakes-server/src/routes/export.rs +++ b/crates/pinakes-server/src/routes/export.rs @@ -24,9 +24,6 @@ pub struct ExportRequest { ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn trigger_export( State(state): State, ) -> Result, ApiError> { @@ -54,9 +51,6 @@ pub async fn trigger_export( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn trigger_export_with_options( State(state): State, Json(req): Json, diff --git a/packages/pinakes-server/src/routes/health.rs b/crates/pinakes-server/src/routes/health.rs similarity index 94% rename from packages/pinakes-server/src/routes/health.rs rename to crates/pinakes-server/src/routes/health.rs index 14d9a0a..7d30c27 100644 --- a/packages/pinakes-server/src/routes/health.rs +++ b/crates/pinakes-server/src/routes/health.rs @@ -66,8 +66,7 @@ pub async fn health(State(state): State) -> Json { Ok(count) => { DatabaseHealth { status: "ok".to_string(), - latency_ms: u64::try_from(db_start.elapsed().as_millis()) - .unwrap_or(u64::MAX), + latency_ms: db_start.elapsed().as_millis() as u64, media_count: Some(count), } }, @@ -75,8 +74,7 @@ pub async fn health(State(state): State) -> Json { response.status = "degraded".to_string(); DatabaseHealth { status: format!("error: {e}"), - latency_ms: u64::try_from(db_start.elapsed().as_millis()) - .unwrap_or(u64::MAX), + latency_ms: db_start.elapsed().as_millis() as u64, media_count: None, } }, @@ -149,8 +147,7 @@ pub async fn readiness(State(state): State) -> impl IntoResponse { let db_start = Instant::now(); match state.storage.count_media().await { Ok(_) => { - let latency = - u64::try_from(db_start.elapsed().as_millis()).unwrap_or(u64::MAX); + let latency = db_start.elapsed().as_millis() as u64; ( StatusCode::OK, Json(serde_json::json!({ @@ -206,8 +203,7 @@ pub async fn health_detailed( Ok(count) => ("ok".to_string(), Some(count)), Err(e) => (format!("error: {e}"), None), }; - let db_latency = - u64::try_from(db_start.elapsed().as_millis()).unwrap_or(u64::MAX); + let db_latency = db_start.elapsed().as_millis() as u64; // Check filesystem let roots = state.storage.list_root_dirs().await.unwrap_or_default(); diff --git a/packages/pinakes-server/src/routes/integrity.rs b/crates/pinakes-server/src/routes/integrity.rs similarity index 91% rename from packages/pinakes-server/src/routes/integrity.rs rename to crates/pinakes-server/src/routes/integrity.rs index f3bda1a..f688e79 100644 --- a/packages/pinakes-server/src/routes/integrity.rs +++ b/crates/pinakes-server/src/routes/integrity.rs @@ -21,9 +21,6 @@ pub struct OrphanResolveRequest { ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn trigger_orphan_detection( State(state): State, ) -> Result, ApiError> { @@ -45,9 +42,6 @@ pub async fn trigger_orphan_detection( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn trigger_verify_integrity( State(state): State, Json(req): Json, @@ -79,9 +73,6 @@ pub struct VerifyIntegrityRequest { ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn trigger_cleanup_thumbnails( State(state): State, ) -> Result, ApiError> { @@ -111,9 +102,6 @@ pub struct GenerateThumbnailsRequest { ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn generate_all_thumbnails( State(state): State, body: Option>, @@ -152,9 +140,6 @@ pub async fn generate_all_thumbnails( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn resolve_orphans( State(state): State, Json(req): Json, diff --git a/packages/pinakes-server/src/routes/jobs.rs b/crates/pinakes-server/src/routes/jobs.rs similarity index 91% rename from packages/pinakes-server/src/routes/jobs.rs rename to crates/pinakes-server/src/routes/jobs.rs index ba2863c..c7319cb 100644 --- a/packages/pinakes-server/src/routes/jobs.rs +++ b/crates/pinakes-server/src/routes/jobs.rs @@ -34,9 +34,6 @@ pub async fn list_jobs(State(state): State) -> Json> { ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_job( State(state): State, Path(id): Path, @@ -60,9 +57,6 @@ pub async fn get_job( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn cancel_job( State(state): State, Path(id): Path, diff --git a/packages/pinakes-server/src/routes/media.rs b/crates/pinakes-server/src/routes/media.rs similarity index 89% rename from packages/pinakes-server/src/routes/media.rs rename to crates/pinakes-server/src/routes/media.rs index ff4c0ce..6aa3ec5 100644 --- a/packages/pinakes-server/src/routes/media.rs +++ b/crates/pinakes-server/src/routes/media.rs @@ -2,10 +2,7 @@ use axum::{ Json, extract::{Path, Query, State}, }; -use pinakes_core::{ - model::{CustomField, CustomFieldType, MediaId}, - storage::DynStorageBackend, -}; +use pinakes_core::{model::MediaId, storage::DynStorageBackend}; use rustc_hash::FxHashMap; use uuid::Uuid; @@ -116,9 +113,6 @@ async fn apply_import_post_processing( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn import_media( State(state): State, Json(req): Json, @@ -162,9 +156,6 @@ pub async fn import_media( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn list_media( State(state): State, Query(params): Query, @@ -193,9 +184,6 @@ pub async fn list_media( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_media( State(state): State, Path(id): Path, @@ -211,7 +199,7 @@ const MAX_SHORT_TEXT: usize = 500; const MAX_LONG_TEXT: usize = 10_000; fn validate_optional_text( - field: Option<&str>, + field: &Option, name: &str, max: usize, ) -> Result<(), ApiError> { @@ -243,23 +231,16 @@ fn validate_optional_text( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn update_media( State(state): State, Path(id): Path, Json(req): Json, ) -> Result, ApiError> { - validate_optional_text(req.title.as_deref(), "title", MAX_SHORT_TEXT)?; - validate_optional_text(req.artist.as_deref(), "artist", MAX_SHORT_TEXT)?; - validate_optional_text(req.album.as_deref(), "album", MAX_SHORT_TEXT)?; - validate_optional_text(req.genre.as_deref(), "genre", MAX_SHORT_TEXT)?; - validate_optional_text( - req.description.as_deref(), - "description", - MAX_LONG_TEXT, - )?; + 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?; @@ -321,9 +302,6 @@ pub async fn update_media( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_media( State(state): State, Path(id): Path, @@ -375,9 +353,6 @@ pub async fn delete_media( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn open_media( State(state): State, Path(id): Path, @@ -409,9 +384,6 @@ pub async fn open_media( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn stream_media( State(state): State, Path(id): Path, @@ -537,9 +509,6 @@ fn parse_range(header: &str, total_size: u64) -> Option<(u64, u64)> { ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn import_with_options( State(state): State, Json(req): Json, @@ -588,9 +557,6 @@ pub async fn import_with_options( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn batch_import( State(state): State, Json(req): Json, @@ -679,9 +645,6 @@ pub async fn batch_import( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn import_directory_endpoint( State(state): State, Json(req): Json, @@ -750,50 +713,6 @@ pub async fn import_directory_endpoint( })) } -fn walk_dir_preview( - dir: &std::path::Path, - recursive: bool, - roots: &[std::path::PathBuf], - result: &mut Vec, -) { - let Ok(entries) = std::fs::read_dir(dir) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - // Skip hidden files/dirs - if path - .file_name() - .is_some_and(|n| n.to_string_lossy().starts_with('.')) - { - continue; - } - if path.is_dir() { - if recursive { - walk_dir_preview(&path, recursive, roots, result); - } - } else if path.is_file() - && let Some(mt) = pinakes_core::media_type::MediaType::from_path(&path) - { - let size = entry.metadata().ok().map_or(0, |m| m.len()); - let file_name = path - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_default(); - let media_type = serde_json::to_value(mt) - .ok() - .and_then(|v| v.as_str().map(String::from)) - .unwrap_or_default(); - result.push(DirectoryPreviewFile { - path: crate::dto::relativize_path(&path, roots), - file_name, - media_type, - file_size: size, - }); - } - } -} - #[utoipa::path( post, path = "/api/v1/media/import/preview", @@ -807,9 +726,6 @@ fn walk_dir_preview( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn preview_directory( State(state): State, Json(req): Json, @@ -849,7 +765,51 @@ pub async fn preview_directory( let files: Vec = tokio::task::spawn_blocking(move || { let mut result = Vec::new(); - walk_dir_preview(&dir, recursive, &roots_for_walk, &mut result); + fn walk_dir( + dir: &std::path::Path, + recursive: bool, + roots: &[std::path::PathBuf], + result: &mut Vec, + ) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + // Skip hidden files/dirs + if path + .file_name() + .is_some_and(|n| n.to_string_lossy().starts_with('.')) + { + continue; + } + if path.is_dir() { + if recursive { + walk_dir(&path, recursive, roots, result); + } + } else if path.is_file() + && let Some(mt) = + pinakes_core::media_type::MediaType::from_path(&path) + { + let size = entry.metadata().ok().map_or(0, |m| m.len()); + let file_name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + let media_type = serde_json::to_value(mt) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_default(); + result.push(DirectoryPreviewFile { + path: crate::dto::relativize_path(&path, roots), + file_name, + media_type, + file_size: size, + }); + } + } + } + walk_dir(&dir, recursive, &roots_for_walk, &mut result); result }) .await @@ -883,9 +843,6 @@ pub async fn preview_directory( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn set_custom_field( State(state): State, Path(id): Path, @@ -905,6 +862,7 @@ pub async fn set_custom_field( )), )); } + use pinakes_core::model::{CustomField, CustomFieldType}; let field_type = match req.field_type.as_str() { "number" => CustomFieldType::Number, "date" => CustomFieldType::Date, @@ -939,9 +897,6 @@ pub async fn set_custom_field( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_custom_field( State(state): State, Path((id, name)): Path<(Uuid, String)>, @@ -967,9 +922,6 @@ pub async fn delete_custom_field( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn batch_tag( State(state): State, Json(req): Json, @@ -991,7 +943,7 @@ pub async fn batch_tag( { Ok(count) => { Ok(Json(BatchOperationResponse { - processed: usize::try_from(count).unwrap_or(usize::MAX), + processed: count as usize, errors: Vec::new(), })) }, @@ -1016,9 +968,6 @@ pub async fn batch_tag( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_all_media( State(state): State, ) -> Result, ApiError> { @@ -1037,7 +986,7 @@ pub async fn delete_all_media( match state.storage.delete_all_media().await { Ok(count) => { Ok(Json(BatchOperationResponse { - processed: usize::try_from(count).unwrap_or(usize::MAX), + processed: count as usize, errors: Vec::new(), })) }, @@ -1064,9 +1013,6 @@ pub async fn delete_all_media( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn batch_delete( State(state): State, Json(req): Json, @@ -1098,7 +1044,7 @@ pub async fn batch_delete( match state.storage.batch_delete_media(&media_ids).await { Ok(count) => { Ok(Json(BatchOperationResponse { - processed: usize::try_from(count).unwrap_or(usize::MAX), + processed: count as usize, errors: Vec::new(), })) }, @@ -1125,9 +1071,6 @@ pub async fn batch_delete( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn batch_add_to_collection( State(state): State, Json(req): Json, @@ -1147,7 +1090,7 @@ pub async fn batch_add_to_collection( &state.storage, req.collection_id, MediaId(*media_id), - i32::try_from(i).unwrap_or(i32::MAX), + i as i32, ) .await { @@ -1172,9 +1115,6 @@ pub async fn batch_add_to_collection( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn batch_update( State(state): State, Json(req): Json, @@ -1204,7 +1144,7 @@ pub async fn batch_update( { Ok(count) => { Ok(Json(BatchOperationResponse { - processed: usize::try_from(count).unwrap_or(usize::MAX), + processed: count as usize, errors: Vec::new(), })) }, @@ -1230,9 +1170,6 @@ pub async fn batch_update( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_thumbnail( State(state): State, Path(id): Path, @@ -1277,9 +1214,6 @@ pub async fn get_thumbnail( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_media_count( State(state): State, ) -> Result, ApiError> { @@ -1303,9 +1237,6 @@ pub async fn get_media_count( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn rename_media( State(state): State, Path(id): Path, @@ -1327,10 +1258,10 @@ pub async fn rename_media( // Record in sync log let item = state.storage.get_media(media_id).await?; - let change = pinakes_sync::SyncLogEntry { + let change = pinakes_core::sync::SyncLogEntry { id: uuid::Uuid::now_v7(), sequence: 0, - change_type: pinakes_sync::SyncChangeType::Moved, + 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()), @@ -1374,9 +1305,6 @@ pub async fn rename_media( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn move_media_endpoint( State(state): State, Path(id): Path, @@ -1391,10 +1319,10 @@ pub async fn move_media_endpoint( // Record in sync log let item = state.storage.get_media(media_id).await?; - let change = pinakes_sync::SyncLogEntry { + let change = pinakes_core::sync::SyncLogEntry { id: uuid::Uuid::now_v7(), sequence: 0, - change_type: pinakes_sync::SyncChangeType::Moved, + 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()), @@ -1440,9 +1368,6 @@ pub async fn move_media_endpoint( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn batch_move_media( State(state): State, Json(req): Json, @@ -1479,10 +1404,10 @@ pub async fn batch_move_media( continue; }; let new_path = req.destination.join(file_name); - let change = pinakes_sync::SyncLogEntry { + let change = pinakes_core::sync::SyncLogEntry { id: uuid::Uuid::now_v7(), sequence: 0, - change_type: pinakes_sync::SyncChangeType::Moved, + change_type: pinakes_core::sync::SyncChangeType::Moved, media_id: Some(*media_id), path: new_path.to_string_lossy().to_string(), content_hash: None, @@ -1526,9 +1451,6 @@ pub async fn batch_move_media( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn soft_delete_media( State(state): State, Path(id): Path, @@ -1542,10 +1464,10 @@ pub async fn soft_delete_media( state.storage.soft_delete_media(media_id).await?; // Record in sync log - let change = pinakes_sync::SyncLogEntry { + let change = pinakes_core::sync::SyncLogEntry { id: uuid::Uuid::now_v7(), sequence: 0, - change_type: pinakes_sync::SyncChangeType::Deleted, + 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()), @@ -1589,9 +1511,6 @@ pub async fn soft_delete_media( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn restore_media( State(state): State, Path(id): Path, @@ -1605,10 +1524,10 @@ pub async fn restore_media( let item = state.storage.get_media(media_id).await?; // Record in sync log - let change = pinakes_sync::SyncLogEntry { + let change = pinakes_core::sync::SyncLogEntry { id: uuid::Uuid::now_v7(), sequence: 0, - change_type: pinakes_sync::SyncChangeType::Created, + 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()), @@ -1654,9 +1573,6 @@ pub async fn restore_media( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn list_trash( State(state): State, Query(params): Query, @@ -1687,9 +1603,6 @@ pub async fn list_trash( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn trash_info( State(state): State, ) -> Result, ApiError> { @@ -1709,9 +1622,6 @@ pub async fn trash_info( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn empty_trash( State(state): State, ) -> Result, ApiError> { @@ -1746,14 +1656,6 @@ pub async fn empty_trash( ), security(("bearer_auth" = [])) )] -// axum handlers cannot be generic over hasher types without breaking routing -#[expect( - clippy::implicit_hasher, - reason = "axum handler; generic over hasher breaks routing" -)] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn permanent_delete_media( State(state): State, Path(id): Path, @@ -1779,10 +1681,10 @@ pub async fn permanent_delete_media( state.storage.delete_media(media_id).await?; // Record in sync log - let change = pinakes_sync::SyncLogEntry { + let change = pinakes_core::sync::SyncLogEntry { id: uuid::Uuid::now_v7(), sequence: 0, - change_type: pinakes_sync::SyncChangeType::Deleted, + 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()), diff --git a/packages/pinakes-server/src/routes/mod.rs b/crates/pinakes-server/src/routes/mod.rs similarity index 100% rename from packages/pinakes-server/src/routes/mod.rs rename to crates/pinakes-server/src/routes/mod.rs diff --git a/packages/pinakes-server/src/routes/notes.rs b/crates/pinakes-server/src/routes/notes.rs similarity index 93% rename from packages/pinakes-server/src/routes/notes.rs rename to crates/pinakes-server/src/routes/notes.rs index 30a7e5f..6fe3a37 100644 --- a/packages/pinakes-server/src/routes/notes.rs +++ b/crates/pinakes-server/src/routes/notes.rs @@ -12,16 +12,13 @@ use axum::{ extract::{Path, Query, State}, routing::{get, post}, }; -use pinakes_core::{ - media_type::{BuiltinMediaType, MediaType}, - model::{ - BacklinkInfo, - GraphData, - GraphEdge, - GraphNode, - MarkdownLink, - MediaId, - }, +use pinakes_core::model::{ + BacklinkInfo, + GraphData, + GraphEdge, + GraphNode, + MarkdownLink, + MediaId, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -217,9 +214,6 @@ pub struct UnresolvedLinksResponse { ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_backlinks( State(state): State, Path(id): Path, @@ -253,9 +247,6 @@ pub async fn get_backlinks( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_outgoing_links( State(state): State, Path(id): Path, @@ -291,9 +282,6 @@ pub async fn get_outgoing_links( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_graph( State(state): State, Query(params): Query, @@ -322,9 +310,6 @@ pub async fn get_graph( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn reindex_links( State(state): State, Path(id): Path, @@ -335,6 +320,7 @@ pub async fn reindex_links( let media = state.storage.get_media(media_id).await?; // Only process markdown files + use pinakes_core::media_type::{BuiltinMediaType, MediaType}; match &media.media_type { MediaType::Builtin(BuiltinMediaType::Markdown) => {}, _ => { @@ -383,9 +369,6 @@ pub async fn reindex_links( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn resolve_links( State(state): State, ) -> Result, ApiError> { @@ -408,9 +391,6 @@ pub async fn resolve_links( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_unresolved_count( State(state): State, ) -> Result, ApiError> { diff --git a/packages/pinakes-server/src/routes/photos.rs b/crates/pinakes-server/src/routes/photos.rs similarity index 97% rename from packages/pinakes-server/src/routes/photos.rs rename to crates/pinakes-server/src/routes/photos.rs index 9da3687..7320427 100644 --- a/packages/pinakes-server/src/routes/photos.rs +++ b/crates/pinakes-server/src/routes/photos.rs @@ -81,10 +81,6 @@ pub struct MapMarker { security(("bearer_auth" = [])) )] /// Get timeline of photos grouped by date -/// -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_timeline( State(state): State, Query(query): Query, @@ -187,10 +183,6 @@ pub async fn get_timeline( security(("bearer_auth" = [])) )] /// Get photos in a bounding box for map view -/// -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_map_photos( State(state): State, Query(query): Query, diff --git a/packages/pinakes-server/src/routes/playlists.rs b/crates/pinakes-server/src/routes/playlists.rs similarity index 91% rename from packages/pinakes-server/src/routes/playlists.rs rename to crates/pinakes-server/src/routes/playlists.rs index 7a2cefc..420897e 100644 --- a/packages/pinakes-server/src/routes/playlists.rs +++ b/crates/pinakes-server/src/routes/playlists.rs @@ -3,7 +3,6 @@ use axum::{ extract::{Extension, Path, State}, }; use pinakes_core::{model::MediaId, playlists::Playlist, users::UserId}; -use rand::seq::SliceRandom as _; use uuid::Uuid; use crate::{ @@ -65,9 +64,6 @@ async fn check_playlist_access( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn create_playlist( State(state): State, Extension(username): Extension, @@ -106,9 +102,6 @@ pub async fn create_playlist( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn list_playlists( State(state): State, Extension(username): Extension, @@ -137,9 +130,6 @@ pub async fn list_playlists( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_playlist( State(state): State, Extension(username): Extension, @@ -166,9 +156,6 @@ pub async fn get_playlist( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn update_playlist( State(state): State, Extension(username): Extension, @@ -211,9 +198,6 @@ pub async fn update_playlist( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_playlist( State(state): State, Extension(username): Extension, @@ -239,9 +223,6 @@ pub async fn delete_playlist( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn add_item( State(state): State, Extension(username): Extension, @@ -254,7 +235,7 @@ pub async fn add_item( p } else { let items = state.storage.get_playlist_items(id).await?; - i32::try_from(items.len()).unwrap_or(i32::MAX) + items.len() as i32 }; state .storage @@ -279,9 +260,6 @@ pub async fn add_item( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn remove_item( State(state): State, Extension(username): Extension, @@ -309,9 +287,6 @@ pub async fn remove_item( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn list_items( State(state): State, Extension(username): Extension, @@ -343,9 +318,6 @@ pub async fn list_items( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn reorder_item( State(state): State, Extension(username): Extension, @@ -374,9 +346,6 @@ pub async fn reorder_item( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn shuffle_playlist( State(state): State, Extension(username): Extension, @@ -384,6 +353,7 @@ pub async fn shuffle_playlist( ) -> Result>, ApiError> { let user_id = resolve_user_id(&state.storage, &username).await?; check_playlist_access(&state.storage, id, user_id, false).await?; + use rand::seq::SliceRandom; let mut items = state.storage.get_playlist_items(id).await?; items.shuffle(&mut rand::rng()); let roots = state.config.read().await.directories.roots.clone(); diff --git a/packages/pinakes-server/src/routes/plugins.rs b/crates/pinakes-server/src/routes/plugins.rs similarity index 90% rename from packages/pinakes-server/src/routes/plugins.rs rename to crates/pinakes-server/src/routes/plugins.rs index ae6a63d..e2b45b0 100644 --- a/packages/pinakes-server/src/routes/plugins.rs +++ b/crates/pinakes-server/src/routes/plugins.rs @@ -4,7 +4,7 @@ use axum::{ Json, extract::{Path, State}, }; -use pinakes_plugin::PluginManager; +use pinakes_core::plugin::PluginManager; use rustc_hash::FxHashMap; use crate::{ @@ -42,9 +42,6 @@ fn require_plugin_manager( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn list_plugins( State(state): State, ) -> Result>, ApiError> { @@ -72,9 +69,6 @@ pub async fn list_plugins( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_plugin( State(state): State, Path(id): Path, @@ -105,9 +99,6 @@ pub async fn get_plugin( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn install_plugin( State(state): State, Json(req): Json, @@ -149,9 +140,6 @@ pub async fn install_plugin( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn uninstall_plugin( State(state): State, Path(id): Path, @@ -182,9 +170,6 @@ pub async fn uninstall_plugin( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn toggle_plugin( State(state): State, Path(id): Path, @@ -234,9 +219,6 @@ pub async fn toggle_plugin( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn list_plugin_ui_pages( State(state): State, ) -> Result>, ApiError> { @@ -267,9 +249,6 @@ pub async fn list_plugin_ui_pages( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn list_plugin_ui_widgets( State(state): State, ) -> Result>, ApiError> { @@ -296,9 +275,6 @@ pub async fn list_plugin_ui_widgets( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn emit_plugin_event( State(state): State, Json(req): Json, @@ -321,9 +297,6 @@ pub async fn emit_plugin_event( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn list_plugin_ui_theme_extensions( State(state): State, ) -> Result>, ApiError> { @@ -345,9 +318,6 @@ pub async fn list_plugin_ui_theme_extensions( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn reload_plugin( State(state): State, Path(id): Path, diff --git a/packages/pinakes-server/src/routes/saved_searches.rs b/crates/pinakes-server/src/routes/saved_searches.rs similarity index 93% rename from packages/pinakes-server/src/routes/saved_searches.rs rename to crates/pinakes-server/src/routes/saved_searches.rs index 9ccc981..11bb4f0 100644 --- a/packages/pinakes-server/src/routes/saved_searches.rs +++ b/crates/pinakes-server/src/routes/saved_searches.rs @@ -44,9 +44,6 @@ const VALID_SORT_ORDERS: &[&str] = &[ ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn create_saved_search( State(state): State, Json(req): Json, @@ -103,9 +100,6 @@ pub async fn create_saved_search( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn list_saved_searches( State(state): State, ) -> Result>, ApiError> { @@ -143,9 +137,6 @@ pub async fn list_saved_searches( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_saved_search( State(state): State, Path(id): Path, diff --git a/packages/pinakes-server/src/routes/scan.rs b/crates/pinakes-server/src/routes/scan.rs similarity index 94% rename from packages/pinakes-server/src/routes/scan.rs rename to crates/pinakes-server/src/routes/scan.rs index e0e89e7..f78b089 100644 --- a/packages/pinakes-server/src/routes/scan.rs +++ b/crates/pinakes-server/src/routes/scan.rs @@ -20,9 +20,6 @@ use crate::{ ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn trigger_scan( State(state): State, Json(req): Json, diff --git a/packages/pinakes-server/src/routes/scheduled_tasks.rs b/crates/pinakes-server/src/routes/scheduled_tasks.rs similarity index 85% rename from packages/pinakes-server/src/routes/scheduled_tasks.rs rename to crates/pinakes-server/src/routes/scheduled_tasks.rs index 4b7c3fb..270c4ab 100644 --- a/packages/pinakes-server/src/routes/scheduled_tasks.rs +++ b/crates/pinakes-server/src/routes/scheduled_tasks.rs @@ -16,9 +16,6 @@ use crate::{dto::ScheduledTaskResponse, error::ApiError, state::AppState}; ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn list_scheduled_tasks( State(state): State, ) -> Result>, ApiError> { @@ -53,26 +50,23 @@ pub async fn list_scheduled_tasks( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn toggle_scheduled_task( State(state): State, Path(id): Path, ) -> Result, ApiError> { - state.scheduler.toggle_task(&id).await.map_or_else( - || { - Err(ApiError(pinakes_core::error::PinakesError::NotFound( - format!("scheduled task not found: {id}"), - ))) - }, - |enabled| { + 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}"), + ))) + }, + } } #[utoipa::path( @@ -88,24 +82,21 @@ pub async fn toggle_scheduled_task( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn run_scheduled_task_now( State(state): State, Path(id): Path, ) -> Result, ApiError> { - state.scheduler.run_now(&id).await.map_or_else( - || { - Err(ApiError(pinakes_core::error::PinakesError::NotFound( - format!("scheduled task not found: {id}"), - ))) - }, - |job_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/packages/pinakes-server/src/routes/search.rs b/crates/pinakes-server/src/routes/search.rs similarity index 94% rename from packages/pinakes-server/src/routes/search.rs rename to crates/pinakes-server/src/routes/search.rs index a062f1f..bebb04b 100644 --- a/packages/pinakes-server/src/routes/search.rs +++ b/crates/pinakes-server/src/routes/search.rs @@ -40,9 +40,6 @@ fn resolve_sort(sort: Option<&str>) -> SortOrder { ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn search( State(state): State, Query(params): Query, @@ -90,9 +87,6 @@ pub async fn search( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn search_post( State(state): State, Json(body): Json, diff --git a/packages/pinakes-server/src/routes/shares.rs b/crates/pinakes-server/src/routes/shares.rs similarity index 92% rename from packages/pinakes-server/src/routes/shares.rs rename to crates/pinakes-server/src/routes/shares.rs index e4f48bb..965b79e 100644 --- a/packages/pinakes-server/src/routes/shares.rs +++ b/crates/pinakes-server/src/routes/shares.rs @@ -61,9 +61,6 @@ use crate::{ ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn create_share( State(state): State, Extension(username): Extension, @@ -152,28 +149,27 @@ pub async fn create_share( }; // Parse permissions - let permissions = - req - .permissions - .map_or_else(SharePermissions::view_only, |perms| { - SharePermissions { - view: ShareViewPermissions { - can_view: perms.can_view.unwrap_or(true), - can_download: perms.can_download.unwrap_or(false), - can_reshare: perms.can_reshare.unwrap_or(false), - }, - mutate: ShareMutatePermissions { - can_edit: perms.can_edit.unwrap_or(false), - can_delete: perms.can_delete.unwrap_or(false), - can_add: perms.can_add.unwrap_or(false), - }, - } - }); + let permissions = if let Some(perms) = req.permissions { + SharePermissions { + view: ShareViewPermissions { + can_view: perms.can_view.unwrap_or(true), + can_download: perms.can_download.unwrap_or(false), + can_reshare: perms.can_reshare.unwrap_or(false), + }, + mutate: ShareMutatePermissions { + can_edit: perms.can_edit.unwrap_or(false), + can_delete: perms.can_delete.unwrap_or(false), + can_add: perms.can_add.unwrap_or(false), + }, + } + } else { + SharePermissions::view_only() + }; // Calculate expiration - let expires_at = req.expires_in_hours.map(|hours: u64| { - Utc::now() + chrono::Duration::hours(hours.cast_signed()) - }); + 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()), @@ -232,9 +228,6 @@ pub async fn create_share( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn list_outgoing( State(state): State, Extension(username): Extension, @@ -268,9 +261,6 @@ pub async fn list_outgoing( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn list_incoming( State(state): State, Extension(username): Extension, @@ -303,9 +293,6 @@ pub async fn list_incoming( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_share( State(state): State, Extension(username): Extension, @@ -350,9 +337,6 @@ pub async fn get_share( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn update_share( State(state): State, Extension(username): Extension, @@ -446,9 +430,6 @@ pub async fn update_share( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_share( State(state): State, Extension(username): Extension, @@ -506,9 +487,6 @@ pub async fn delete_share( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn batch_delete( State(state): State, Extension(username): Extension, @@ -562,9 +540,6 @@ pub async fn batch_delete( (status = 404, description = "Not found"), ) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn access_shared( State(state): State, Path(token): Path, @@ -643,8 +618,8 @@ pub async fn access_shared( .await .map_err(|e| ApiError::not_found(format!("Media not found: {e}")))?; - Ok(Json(SharedContentResponse::Single(Box::new( - MediaResponse::new(item, &roots), + Ok(Json(SharedContentResponse::Single(MediaResponse::new( + item, &roots, )))) }, ShareTarget::Collection { collection_id } => { @@ -749,9 +724,6 @@ pub async fn access_shared( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_activity( State(state): State, Extension(username): Extension, @@ -795,9 +767,6 @@ pub async fn get_activity( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_notifications( State(state): State, Extension(username): Extension, @@ -827,9 +796,6 @@ pub async fn get_notifications( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn mark_notification_read( State(state): State, Extension(username): Extension, @@ -857,9 +823,6 @@ pub async fn mark_notification_read( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn mark_all_read( State(state): State, Extension(username): Extension, diff --git a/packages/pinakes-server/src/routes/social.rs b/crates/pinakes-server/src/routes/social.rs similarity index 88% rename from packages/pinakes-server/src/routes/social.rs rename to crates/pinakes-server/src/routes/social.rs index 5fd01b9..b378026 100644 --- a/packages/pinakes-server/src/routes/social.rs +++ b/crates/pinakes-server/src/routes/social.rs @@ -22,8 +22,6 @@ use crate::{ state::AppState, }; -const MAX_SHARE_EXPIRY_HOURS: u64 = 8760; // 1 year - #[derive(Deserialize)] pub struct ShareLinkQuery { pub password: Option, @@ -43,9 +41,6 @@ pub struct ShareLinkQuery { ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn rate_media( State(state): State, Extension(username): Extension, @@ -90,9 +85,6 @@ pub async fn rate_media( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_media_ratings( State(state): State, Path(id): Path, @@ -117,9 +109,6 @@ pub async fn get_media_ratings( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn add_comment( State(state): State, Extension(username): Extension, @@ -154,9 +143,6 @@ pub async fn add_comment( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_media_comments( State(state): State, Path(id): Path, @@ -179,9 +165,6 @@ pub async fn get_media_comments( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn add_favorite( State(state): State, Extension(username): Extension, @@ -207,9 +190,6 @@ pub async fn add_favorite( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn remove_favorite( State(state): State, Extension(username): Extension, @@ -234,9 +214,6 @@ pub async fn remove_favorite( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn list_favorites( State(state): State, Extension(username): Extension, @@ -268,9 +245,6 @@ pub async fn list_favorites( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn create_share_link( State(state): State, Extension(username): Extension, @@ -291,18 +265,19 @@ pub async fn create_share_link( }, None => None, }; + const MAX_EXPIRY_HOURS: u64 = 8760; // 1 year if let Some(h) = req.expires_in_hours - && h > MAX_SHARE_EXPIRY_HOURS + && h > MAX_EXPIRY_HOURS { return Err(ApiError( pinakes_core::error::PinakesError::InvalidOperation(format!( - "expires_in_hours cannot exceed {MAX_SHARE_EXPIRY_HOURS}" + "expires_in_hours cannot exceed {MAX_EXPIRY_HOURS}" )), )); } - let expires_at = req.expires_in_hours.map(|h: u64| { - chrono::Utc::now() + chrono::Duration::hours(h.cast_signed()) - }); + 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( @@ -330,9 +305,6 @@ pub async fn create_share_link( (status = 404, description = "Not found"), ) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn access_shared_media( State(state): State, Path(token): Path, @@ -358,10 +330,15 @@ pub async fn access_shared_media( } // Verify password if set if let Some(ref hash) = link.password_hash { - let Some(password) = query.password.as_deref() else { - return Err(ApiError(pinakes_core::error::PinakesError::Authentication( - "password required for this share link".into(), - ))); + 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); diff --git a/packages/pinakes-server/src/routes/statistics.rs b/crates/pinakes-server/src/routes/statistics.rs similarity index 87% rename from packages/pinakes-server/src/routes/statistics.rs rename to crates/pinakes-server/src/routes/statistics.rs index d03d089..47d1a3b 100644 --- a/packages/pinakes-server/src/routes/statistics.rs +++ b/crates/pinakes-server/src/routes/statistics.rs @@ -13,9 +13,6 @@ use crate::{dto::LibraryStatisticsResponse, error::ApiError, state::AppState}; ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn library_statistics( State(state): State, ) -> Result, ApiError> { diff --git a/packages/pinakes-server/src/routes/streaming.rs b/crates/pinakes-server/src/routes/streaming.rs similarity index 86% rename from packages/pinakes-server/src/routes/streaming.rs rename to crates/pinakes-server/src/routes/streaming.rs index c1748fe..622b5aa 100644 --- a/packages/pinakes-server/src/routes/streaming.rs +++ b/crates/pinakes-server/src/routes/streaming.rs @@ -1,5 +1,3 @@ -use std::fmt::Write as _; - use axum::{ extract::{Path, State}, http::StatusCode, @@ -63,9 +61,6 @@ fn escape_xml(s: &str) -> String { ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn hls_master_playlist( State(state): State, Path(id): Path, @@ -83,11 +78,10 @@ pub async fn hls_master_playlist( let bandwidth = estimate_bandwidth(profile); let encoded_name = utf8_percent_encode(&profile.name, NON_ALPHANUMERIC).to_string(); - let _ = write!( - playlist, + 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", - ); + )); } build_response("application/vnd.apple.mpegurl", playlist) @@ -109,9 +103,6 @@ pub async fn hls_master_playlist( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn hls_variant_playlist( State(state): State, Path((id, profile)): Path<(Uuid, String)>, @@ -127,12 +118,6 @@ pub async fn hls_variant_playlist( )); } let segment_duration = 10.0; - #[expect( - clippy::cast_sign_loss, - clippy::cast_possible_truncation, - reason = "duration/segment_duration is always non-negative and bounded by \ - media length" - )] let num_segments = (duration / segment_duration).ceil() as usize; let mut playlist = String::from( @@ -141,20 +126,14 @@ pub async fn hls_variant_playlist( ); for i in 0..num_segments.max(1) { let seg_dur = if i == num_segments.saturating_sub(1) && duration > 0.0 { - #[expect( - clippy::cast_precision_loss, - reason = "segment index is small, precision loss is negligible" - )] - let i_f64 = i as f64; - i_f64.mul_add(-segment_duration, duration) + (i as f64).mul_add(-segment_duration, duration) } else { segment_duration }; - let _ = writeln!(playlist, "#EXTINF:{seg_dur:.3},"); - let _ = writeln!( - playlist, - "/api/v1/media/{id}/stream/hls/{profile}/segment{i}.ts" - ); + 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"); @@ -178,9 +157,6 @@ pub async fn hls_variant_playlist( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn hls_segment( State(state): State, Path((id, profile, segment)): Path<(Uuid, String, String)>, @@ -230,7 +206,7 @@ pub async fn hls_segment( Err(ApiError( pinakes_core::error::PinakesError::InvalidOperation( "no transcode session found; start a transcode first via POST \ - /media/:id/transcode" + /media/{id}/transcode" .into(), ), )) @@ -249,9 +225,6 @@ pub async fn hls_segment( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn dash_manifest( State(state): State, Path(id): Path, @@ -266,19 +239,7 @@ pub async fn dash_manifest( ), )); } - #[expect( - clippy::cast_sign_loss, - clippy::cast_possible_truncation, - reason = "duration is always non-negative and bounded; hours/minutes fit \ - in u32" - )] let hours = (duration / 3600.0) as u32; - #[expect( - clippy::cast_sign_loss, - clippy::cast_possible_truncation, - reason = "duration is always non-negative and bounded; hours/minutes fit \ - in u32" - )] let minutes = ((duration % 3600.0) / 60.0) as u32; let seconds = duration % 60.0; @@ -292,13 +253,12 @@ pub async fn dash_manifest( let xml_name = escape_xml(&profile.name); let url_name = utf8_percent_encode(&profile.name, NON_ALPHANUMERIC).to_string(); - let _ = write!( - representations, - r#" + representations.push_str(&format!( + r#" "#, - ); + )); } let mpd = format!( @@ -331,9 +291,6 @@ pub async fn dash_manifest( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn dash_segment( State(state): State, Path((id, profile, segment)): Path<(Uuid, String, String)>, @@ -381,7 +338,7 @@ pub async fn dash_segment( Err(ApiError( pinakes_core::error::PinakesError::InvalidOperation( "no transcode session found; start a transcode first via POST \ - /media/:id/transcode" + /media/{id}/transcode" .into(), ), )) diff --git a/packages/pinakes-server/src/routes/subtitles.rs b/crates/pinakes-server/src/routes/subtitles.rs similarity index 94% rename from packages/pinakes-server/src/routes/subtitles.rs rename to crates/pinakes-server/src/routes/subtitles.rs index 3f80bce..3e55af3 100644 --- a/packages/pinakes-server/src/routes/subtitles.rs +++ b/crates/pinakes-server/src/routes/subtitles.rs @@ -1,5 +1,3 @@ -use std::path::Component; - use axum::{ Json, extract::{Path, State}, @@ -40,9 +38,6 @@ use crate::{ ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn list_subtitles( State(state): State, Path(id): Path, @@ -79,9 +74,6 @@ pub async fn list_subtitles( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn add_subtitle( State(state): State, Path(id): Path, @@ -147,6 +139,7 @@ pub async fn add_subtitle( let path = std::path::PathBuf::from(&path_str); + use std::path::Component; if !path.is_absolute() || path.components().any(|c| c == Component::ParentDir) { @@ -211,9 +204,6 @@ pub async fn add_subtitle( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_subtitle( State(state): State, Path(id): Path, @@ -237,9 +227,6 @@ pub async fn delete_subtitle( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_subtitle_content( State(state): State, Path((media_id, subtitle_id)): Path<(Uuid, Uuid)>, @@ -313,9 +300,6 @@ pub async fn get_subtitle_content( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn update_offset( State(state): State, Path(id): Path, diff --git a/packages/pinakes-server/src/routes/sync.rs b/crates/pinakes-server/src/routes/sync.rs similarity index 93% rename from packages/pinakes-server/src/routes/sync.rs rename to crates/pinakes-server/src/routes/sync.rs index 7b2fd34..debc4cd 100644 --- a/packages/pinakes-server/src/routes/sync.rs +++ b/crates/pinakes-server/src/routes/sync.rs @@ -11,17 +11,19 @@ use chrono::Utc; use pinakes_core::{ config::ConflictResolution, model::ContentHash, - sync::{generate_device_token, hash_device_token, update_device_cursor}, -}; -use pinakes_sync::{ - DeviceId, - DeviceType, - SyncChangeType, - SyncConflict, - SyncDevice, - SyncLogEntry, - UploadSession, - UploadStatus, + sync::{ + DeviceId, + DeviceType, + SyncChangeType, + SyncConflict, + SyncDevice, + SyncLogEntry, + UploadSession, + UploadStatus, + generate_device_token, + hash_device_token, + update_device_cursor, + }, }; use tokio::io::{AsyncReadExt, AsyncSeekExt}; use tokio_util::io::ReaderStream; @@ -68,9 +70,6 @@ const DEFAULT_CHANGES_LIMIT: u64 = 100; ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn register_device( State(state): State, Extension(username): Extension, @@ -135,9 +134,6 @@ pub async fn register_device( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn list_devices( State(state): State, Extension(username): Extension, @@ -167,9 +163,6 @@ pub async fn list_devices( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_device( State(state): State, Extension(username): Extension, @@ -183,7 +176,7 @@ pub async fn get_device( .map_err(|e| ApiError::not_found(format!("Device not found: {e}")))?; // Verify ownership - if device.user_id.0 != user_id.0 { + if device.user_id != user_id { return Err(ApiError::forbidden("Not authorized to access this device")); } @@ -206,9 +199,6 @@ pub async fn get_device( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn update_device( State(state): State, Extension(username): Extension, @@ -223,7 +213,7 @@ pub async fn update_device( .map_err(|e| ApiError::not_found(format!("Device not found: {e}")))?; // Verify ownership - if device.user_id.0 != user_id.0 { + if device.user_id != user_id { return Err(ApiError::forbidden("Not authorized to update this device")); } @@ -258,9 +248,6 @@ pub async fn update_device( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_device( State(state): State, Extension(username): Extension, @@ -274,7 +261,7 @@ pub async fn delete_device( .map_err(|e| ApiError::not_found(format!("Device not found: {e}")))?; // Verify ownership - if device.user_id.0 != user_id.0 { + if device.user_id != user_id { return Err(ApiError::forbidden("Not authorized to delete this device")); } @@ -302,9 +289,6 @@ pub async fn delete_device( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn regenerate_token( State(state): State, Extension(username): Extension, @@ -318,7 +302,7 @@ pub async fn regenerate_token( .map_err(|e| ApiError::not_found(format!("Device not found: {e}")))?; // Verify ownership - if device.user_id.0 != user_id.0 { + if device.user_id != user_id { return Err(ApiError::forbidden( "Not authorized to regenerate token for this device", )); @@ -360,9 +344,6 @@ pub async fn regenerate_token( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_changes( State(state): State, Query(params): Query, @@ -412,9 +393,6 @@ pub async fn get_changes( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn report_changes( State(state): State, Extension(_username): Extension, @@ -529,9 +507,6 @@ pub async fn report_changes( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn acknowledge_changes( State(state): State, Extension(_username): Extension, @@ -572,9 +547,6 @@ pub async fn acknowledge_changes( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn list_conflicts( State(state): State, Extension(_username): Extension, @@ -617,9 +589,6 @@ pub async fn list_conflicts( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn resolve_conflict( State(state): State, Extension(_username): Extension, @@ -658,9 +627,6 @@ pub async fn resolve_conflict( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn create_upload( State(state): State, Extension(_username): Extension, @@ -701,8 +667,7 @@ pub async fn create_upload( chunk_count, status: UploadStatus::Pending, created_at: now, - expires_at: now - + chrono::Duration::hours(upload_timeout_hours.cast_signed()), + expires_at: now + chrono::Duration::hours(upload_timeout_hours as i64), last_activity: now, }; @@ -743,9 +708,6 @@ pub async fn create_upload( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn upload_chunk( State(state): State, Path((session_id, chunk_index)): Path<(Uuid, u64)>, @@ -807,9 +769,6 @@ pub async fn upload_chunk( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_upload_status( State(state): State, Path(id): Path, @@ -836,9 +795,6 @@ pub async fn get_upload_status( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn complete_upload( State(state): State, Path(id): Path, @@ -855,8 +811,7 @@ pub async fn complete_upload( .await .map_err(|e| ApiError::internal(format!("Failed to get chunks: {e}")))?; - if chunks.len() != usize::try_from(session.chunk_count).unwrap_or(usize::MAX) - { + if chunks.len() != session.chunk_count as usize { return Err(ApiError::bad_request(format!( "Missing chunks: expected {}, got {}", session.chunk_count, @@ -1008,9 +963,6 @@ pub async fn complete_upload( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn cancel_upload( State(state): State, Path(id): Path, @@ -1054,9 +1006,6 @@ pub async fn cancel_upload( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn download_file( State(state): State, Path(path): Path, diff --git a/packages/pinakes-server/src/routes/tags.rs b/crates/pinakes-server/src/routes/tags.rs similarity index 89% rename from packages/pinakes-server/src/routes/tags.rs rename to crates/pinakes-server/src/routes/tags.rs index 90fffa6..506f855 100644 --- a/packages/pinakes-server/src/routes/tags.rs +++ b/crates/pinakes-server/src/routes/tags.rs @@ -25,9 +25,6 @@ use crate::{ ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn create_tag( State(state): State, Json(req): Json, @@ -56,9 +53,6 @@ pub async fn create_tag( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn list_tags( State(state): State, ) -> Result>, ApiError> { @@ -79,9 +73,6 @@ pub async fn list_tags( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_tag( State(state): State, Path(id): Path, @@ -104,9 +95,6 @@ pub async fn get_tag( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_tag( State(state): State, Path(id): Path, @@ -130,9 +118,6 @@ pub async fn delete_tag( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn tag_media( State(state): State, Path(media_id): Path, @@ -169,9 +154,6 @@ pub async fn tag_media( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn untag_media( State(state): State, Path((media_id, tag_id)): Path<(Uuid, Uuid)>, @@ -203,9 +185,6 @@ pub async fn untag_media( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_media_tags( State(state): State, Path(media_id): Path, diff --git a/packages/pinakes-server/src/routes/transcode.rs b/crates/pinakes-server/src/routes/transcode.rs similarity index 90% rename from packages/pinakes-server/src/routes/transcode.rs rename to crates/pinakes-server/src/routes/transcode.rs index 2cc9169..81a8b5c 100644 --- a/packages/pinakes-server/src/routes/transcode.rs +++ b/crates/pinakes-server/src/routes/transcode.rs @@ -25,9 +25,6 @@ use crate::{ ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn start_transcode( State(state): State, Path(id): Path, @@ -58,9 +55,6 @@ pub async fn start_transcode( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_session( State(state): State, Path(id): Path, @@ -79,9 +73,6 @@ pub async fn get_session( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn list_sessions( State(state): State, Query(params): Query, @@ -108,9 +99,6 @@ pub async fn list_sessions( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn cancel_session( State(state): State, Path(id): Path, diff --git a/packages/pinakes-server/src/routes/upload.rs b/crates/pinakes-server/src/routes/upload.rs similarity index 94% rename from packages/pinakes-server/src/routes/upload.rs rename to crates/pinakes-server/src/routes/upload.rs index 693cb62..cba6451 100644 --- a/packages/pinakes-server/src/routes/upload.rs +++ b/crates/pinakes-server/src/routes/upload.rs @@ -44,9 +44,6 @@ fn sanitize_content_disposition(filename: &str) -> String { ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn upload_file( State(state): State, mut multipart: Multipart, @@ -113,9 +110,6 @@ pub async fn upload_file( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn download_file( State(state): State, Path(id): Path, @@ -198,9 +192,6 @@ pub async fn download_file( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn move_to_managed( State(state): State, Path(id): Path, @@ -235,9 +226,6 @@ pub async fn move_to_managed( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn managed_stats( State(state): State, ) -> ApiResult> { diff --git a/packages/pinakes-server/src/routes/users.rs b/crates/pinakes-server/src/routes/users.rs similarity index 92% rename from packages/pinakes-server/src/routes/users.rs rename to crates/pinakes-server/src/routes/users.rs index 29c6ad3..f88e466 100644 --- a/packages/pinakes-server/src/routes/users.rs +++ b/crates/pinakes-server/src/routes/users.rs @@ -27,9 +27,6 @@ use crate::{ ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn list_users( State(state): State, ) -> Result>, ApiError> { @@ -56,9 +53,6 @@ pub async fn list_users( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn create_user( State(state): State, Json(req): Json, @@ -122,9 +116,6 @@ pub async fn create_user( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_user( State(state): State, Path(id): Path, @@ -160,9 +151,6 @@ pub async fn get_user( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn update_user( State(state): State, Path(id): Path, @@ -211,9 +199,6 @@ pub async fn update_user( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn delete_user( State(state): State, Path(id): Path, @@ -242,9 +227,6 @@ pub async fn delete_user( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn get_user_libraries( State(state): State, Path(id): Path, @@ -295,9 +277,6 @@ fn validate_root_path(path: &str) -> Result<(), ApiError> { ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn grant_library_access( State(state): State, Path(id): Path, @@ -337,9 +316,6 @@ pub async fn grant_library_access( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn revoke_library_access( State(state): State, Path(id): Path, diff --git a/packages/pinakes-server/src/routes/webhooks.rs b/crates/pinakes-server/src/routes/webhooks.rs similarity index 69% rename from packages/pinakes-server/src/routes/webhooks.rs rename to crates/pinakes-server/src/routes/webhooks.rs index 875ec37..ca53d70 100644 --- a/packages/pinakes-server/src/routes/webhooks.rs +++ b/crates/pinakes-server/src/routes/webhooks.rs @@ -20,9 +20,6 @@ pub struct WebhookInfo { ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn list_webhooks( State(state): State, ) -> Result>, ApiError> { @@ -51,9 +48,6 @@ pub async fn list_webhooks( ), security(("bearer_auth" = [])) )] -/// # Errors -/// -/// Returns an error if the database operation fails or the request is invalid. pub async fn test_webhook( State(state): State, ) -> Result, ApiError> { @@ -61,20 +55,17 @@ pub async fn test_webhook( let count = config.webhooks.len(); drop(config); - state.webhook_dispatcher.as_ref().map_or_else( - || { - Ok(Json(serde_json::json!({ - "webhooks_configured": 0, - "test_sent": false, - "message": "no webhooks configured" - }))) - }, - |dispatcher| { - dispatcher.dispatch(pinakes_core::webhooks::WebhookEvent::Test); - Ok(Json(serde_json::json!({ - "webhooks_configured": count, - "test_sent": true - }))) - }, - ) + if let Some(ref dispatcher) = state.webhook_dispatcher { + dispatcher.dispatch(pinakes_core::webhooks::WebhookEvent::Test); + Ok(Json(serde_json::json!({ + "webhooks_configured": count, + "test_sent": true + }))) + } else { + Ok(Json(serde_json::json!({ + "webhooks_configured": 0, + "test_sent": false, + "message": "no webhooks configured" + }))) + } } diff --git a/packages/pinakes-server/src/state.rs b/crates/pinakes-server/src/state.rs similarity index 94% rename from packages/pinakes-server/src/state.rs rename to crates/pinakes-server/src/state.rs index eba6994..2917ed5 100644 --- a/packages/pinakes-server/src/state.rs +++ b/crates/pinakes-server/src/state.rs @@ -5,15 +5,14 @@ use pinakes_core::{ config::Config, jobs::JobQueue, managed_storage::ManagedStorageService, - plugin::PluginPipeline, + plugin::{PluginManager, PluginPipeline}, scan::ScanProgress, scheduler::TaskScheduler, storage::DynStorageBackend, + sync::ChunkedUploadManager, transcode::TranscodeService, webhooks::WebhookDispatcher, }; -use pinakes_plugin::PluginManager; -use pinakes_sync::ChunkedUploadManager; use tokio::sync::{RwLock, Semaphore}; // Note: Sessions are now stored in the database via StorageBackend diff --git a/packages/pinakes-server/tests/api.rs b/crates/pinakes-server/tests/api.rs similarity index 100% rename from packages/pinakes-server/tests/api.rs rename to crates/pinakes-server/tests/api.rs diff --git a/packages/pinakes-server/tests/books.rs b/crates/pinakes-server/tests/books.rs similarity index 100% rename from packages/pinakes-server/tests/books.rs rename to crates/pinakes-server/tests/books.rs diff --git a/packages/pinakes-server/tests/common/mod.rs b/crates/pinakes-server/tests/common/mod.rs similarity index 100% rename from packages/pinakes-server/tests/common/mod.rs rename to crates/pinakes-server/tests/common/mod.rs diff --git a/packages/pinakes-server/tests/e2e.rs b/crates/pinakes-server/tests/e2e.rs similarity index 100% rename from packages/pinakes-server/tests/e2e.rs rename to crates/pinakes-server/tests/e2e.rs diff --git a/packages/pinakes-server/tests/enrichment.rs b/crates/pinakes-server/tests/enrichment.rs similarity index 100% rename from packages/pinakes-server/tests/enrichment.rs rename to crates/pinakes-server/tests/enrichment.rs diff --git a/packages/pinakes-server/tests/media_ops.rs b/crates/pinakes-server/tests/media_ops.rs similarity index 100% rename from packages/pinakes-server/tests/media_ops.rs rename to crates/pinakes-server/tests/media_ops.rs diff --git a/packages/pinakes-server/tests/notes.rs b/crates/pinakes-server/tests/notes.rs similarity index 100% rename from packages/pinakes-server/tests/notes.rs rename to crates/pinakes-server/tests/notes.rs diff --git a/packages/pinakes-server/tests/plugin.rs b/crates/pinakes-server/tests/plugin.rs similarity index 98% rename from packages/pinakes-server/tests/plugin.rs rename to crates/pinakes-server/tests/plugin.rs index 36b4be6..287fef7 100644 --- a/packages/pinakes-server/tests/plugin.rs +++ b/crates/pinakes-server/tests/plugin.rs @@ -17,8 +17,7 @@ use common::{ test_addr, }; use http_body_util::BodyExt; -use pinakes_core::config::PluginsConfig; -use pinakes_plugin::PluginManager; +use pinakes_core::{config::PluginsConfig, plugin::PluginManager}; use tower::ServiceExt; async fn setup_app_with_plugins() @@ -51,7 +50,7 @@ async fn setup_app_with_plugins() max_concurrent_ops: 2, plugin_timeout_secs: 10, timeouts: - pinakes_types::config::PluginTimeoutConfig::default(), + pinakes_core::config::PluginTimeoutConfig::default(), max_consecutive_failures: 5, trusted_keys: vec![], }; diff --git a/packages/pinakes-server/tests/shares.rs b/crates/pinakes-server/tests/shares.rs similarity index 100% rename from packages/pinakes-server/tests/shares.rs rename to crates/pinakes-server/tests/shares.rs diff --git a/packages/pinakes-server/tests/sync.rs b/crates/pinakes-server/tests/sync.rs similarity index 100% rename from packages/pinakes-server/tests/sync.rs rename to crates/pinakes-server/tests/sync.rs diff --git a/packages/pinakes-server/tests/users.rs b/crates/pinakes-server/tests/users.rs similarity index 100% rename from packages/pinakes-server/tests/users.rs rename to crates/pinakes-server/tests/users.rs diff --git a/packages/pinakes-server/tests/webhooks.rs b/crates/pinakes-server/tests/webhooks.rs similarity index 100% rename from packages/pinakes-server/tests/webhooks.rs rename to crates/pinakes-server/tests/webhooks.rs diff --git a/crates/pinakes-sync/Cargo.toml b/crates/pinakes-sync/Cargo.toml deleted file mode 100644 index b613325..0000000 --- a/crates/pinakes-sync/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "pinakes-sync" -edition.workspace = true -version.workspace = true -license.workspace = true - -[dependencies] -pinakes-types = { workspace = true } -tokio = { workspace = true } -chrono = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -tracing = { workspace = true } -uuid = { workspace = true } -blake3 = { workspace = true } - -[dev-dependencies] -tempfile = { workspace = true } - -[lints] -workspace = true diff --git a/crates/pinakes-sync/src/lib.rs b/crates/pinakes-sync/src/lib.rs deleted file mode 100644 index e54a4ed..0000000 --- a/crates/pinakes-sync/src/lib.rs +++ /dev/null @@ -1,7 +0,0 @@ -mod chunked; -mod conflict; -mod models; - -pub use chunked::*; -pub use conflict::*; -pub use models::*; diff --git a/packages/pinakes-tui/Cargo.toml b/crates/pinakes-tui/Cargo.toml similarity index 100% rename from packages/pinakes-tui/Cargo.toml rename to crates/pinakes-tui/Cargo.toml diff --git a/packages/pinakes-tui/src/app.rs b/crates/pinakes-tui/src/app.rs similarity index 100% rename from packages/pinakes-tui/src/app.rs rename to crates/pinakes-tui/src/app.rs diff --git a/packages/pinakes-tui/src/client.rs b/crates/pinakes-tui/src/client.rs similarity index 100% rename from packages/pinakes-tui/src/client.rs rename to crates/pinakes-tui/src/client.rs diff --git a/packages/pinakes-tui/src/event.rs b/crates/pinakes-tui/src/event.rs similarity index 100% rename from packages/pinakes-tui/src/event.rs rename to crates/pinakes-tui/src/event.rs diff --git a/packages/pinakes-tui/src/input.rs b/crates/pinakes-tui/src/input.rs similarity index 100% rename from packages/pinakes-tui/src/input.rs rename to crates/pinakes-tui/src/input.rs diff --git a/packages/pinakes-tui/src/main.rs b/crates/pinakes-tui/src/main.rs similarity index 100% rename from packages/pinakes-tui/src/main.rs rename to crates/pinakes-tui/src/main.rs diff --git a/packages/pinakes-tui/src/ui/admin.rs b/crates/pinakes-tui/src/ui/admin.rs similarity index 100% rename from packages/pinakes-tui/src/ui/admin.rs rename to crates/pinakes-tui/src/ui/admin.rs diff --git a/packages/pinakes-tui/src/ui/audit.rs b/crates/pinakes-tui/src/ui/audit.rs similarity index 100% rename from packages/pinakes-tui/src/ui/audit.rs rename to crates/pinakes-tui/src/ui/audit.rs diff --git a/packages/pinakes-tui/src/ui/books.rs b/crates/pinakes-tui/src/ui/books.rs similarity index 100% rename from packages/pinakes-tui/src/ui/books.rs rename to crates/pinakes-tui/src/ui/books.rs diff --git a/packages/pinakes-tui/src/ui/collections.rs b/crates/pinakes-tui/src/ui/collections.rs similarity index 100% rename from packages/pinakes-tui/src/ui/collections.rs rename to crates/pinakes-tui/src/ui/collections.rs diff --git a/packages/pinakes-tui/src/ui/database.rs b/crates/pinakes-tui/src/ui/database.rs similarity index 100% rename from packages/pinakes-tui/src/ui/database.rs rename to crates/pinakes-tui/src/ui/database.rs diff --git a/packages/pinakes-tui/src/ui/detail.rs b/crates/pinakes-tui/src/ui/detail.rs similarity index 100% rename from packages/pinakes-tui/src/ui/detail.rs rename to crates/pinakes-tui/src/ui/detail.rs diff --git a/packages/pinakes-tui/src/ui/duplicates.rs b/crates/pinakes-tui/src/ui/duplicates.rs similarity index 100% rename from packages/pinakes-tui/src/ui/duplicates.rs rename to crates/pinakes-tui/src/ui/duplicates.rs diff --git a/packages/pinakes-tui/src/ui/import.rs b/crates/pinakes-tui/src/ui/import.rs similarity index 100% rename from packages/pinakes-tui/src/ui/import.rs rename to crates/pinakes-tui/src/ui/import.rs diff --git a/packages/pinakes-tui/src/ui/library.rs b/crates/pinakes-tui/src/ui/library.rs similarity index 100% rename from packages/pinakes-tui/src/ui/library.rs rename to crates/pinakes-tui/src/ui/library.rs diff --git a/packages/pinakes-tui/src/ui/metadata_edit.rs b/crates/pinakes-tui/src/ui/metadata_edit.rs similarity index 100% rename from packages/pinakes-tui/src/ui/metadata_edit.rs rename to crates/pinakes-tui/src/ui/metadata_edit.rs diff --git a/packages/pinakes-tui/src/ui/mod.rs b/crates/pinakes-tui/src/ui/mod.rs similarity index 100% rename from packages/pinakes-tui/src/ui/mod.rs rename to crates/pinakes-tui/src/ui/mod.rs diff --git a/packages/pinakes-tui/src/ui/playlists.rs b/crates/pinakes-tui/src/ui/playlists.rs similarity index 100% rename from packages/pinakes-tui/src/ui/playlists.rs rename to crates/pinakes-tui/src/ui/playlists.rs diff --git a/packages/pinakes-tui/src/ui/queue.rs b/crates/pinakes-tui/src/ui/queue.rs similarity index 100% rename from packages/pinakes-tui/src/ui/queue.rs rename to crates/pinakes-tui/src/ui/queue.rs diff --git a/packages/pinakes-tui/src/ui/search.rs b/crates/pinakes-tui/src/ui/search.rs similarity index 100% rename from packages/pinakes-tui/src/ui/search.rs rename to crates/pinakes-tui/src/ui/search.rs diff --git a/packages/pinakes-tui/src/ui/settings.rs b/crates/pinakes-tui/src/ui/settings.rs similarity index 100% rename from packages/pinakes-tui/src/ui/settings.rs rename to crates/pinakes-tui/src/ui/settings.rs diff --git a/packages/pinakes-tui/src/ui/statistics.rs b/crates/pinakes-tui/src/ui/statistics.rs similarity index 100% rename from packages/pinakes-tui/src/ui/statistics.rs rename to crates/pinakes-tui/src/ui/statistics.rs diff --git a/packages/pinakes-tui/src/ui/tags.rs b/crates/pinakes-tui/src/ui/tags.rs similarity index 100% rename from packages/pinakes-tui/src/ui/tags.rs rename to crates/pinakes-tui/src/ui/tags.rs diff --git a/packages/pinakes-tui/src/ui/tasks.rs b/crates/pinakes-tui/src/ui/tasks.rs similarity index 100% rename from packages/pinakes-tui/src/ui/tasks.rs rename to crates/pinakes-tui/src/ui/tasks.rs diff --git a/crates/pinakes-types/Cargo.toml b/crates/pinakes-types/Cargo.toml deleted file mode 100644 index 02fee48..0000000 --- a/crates/pinakes-types/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "pinakes-types" -edition.workspace = true -version.workspace = true -license.workspace = true - -[dependencies] -thiserror = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -chrono = { workspace = true } -uuid = { workspace = true } -rustc-hash = { workspace = true } -toml = { workspace = true } -anyhow = { workspace = true } - -[lints] -workspace = true diff --git a/crates/pinakes-types/src/config.rs b/crates/pinakes-types/src/config.rs deleted file mode 100644 index 29e31f1..0000000 --- a/crates/pinakes-types/src/config.rs +++ /dev/null @@ -1,1767 +0,0 @@ -use std::path::{Path, PathBuf}; - -use chrono::{DateTime, Datelike, Utc}; -use serde::{Deserialize, Serialize}; - -/// A schedule for a recurring task. -#[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, - }, -} - -impl Schedule { - #[must_use] - pub fn next_run(&self, from: DateTime) -> Option> { - match self { - Self::Interval { secs } => { - Some( - from - + chrono::Duration::seconds( - i64::try_from(*secs).unwrap_or(i64::MAX), - ), - ) - }, - Self::Daily { hour, minute } => { - let today = from.date_naive().and_hms_opt(*hour, *minute, 0)?; - let today_utc = today.and_utc(); - if today_utc > from { - Some(today_utc) - } else { - Some(today_utc + chrono::Duration::days(1)) - } - }, - Self::Weekly { day, hour, minute } => { - let current_day = from.weekday().num_days_from_monday(); - let target_day = *day; - let days_ahead = match target_day.cmp(¤t_day) { - std::cmp::Ordering::Greater => target_day - current_day, - std::cmp::Ordering::Less => 7 - (current_day - target_day), - std::cmp::Ordering::Equal => { - let today = - from.date_naive().and_hms_opt(*hour, *minute, 0)?.and_utc(); - if today > from { - return Some(today); - } - 7 - }, - }; - let target_date = - from.date_naive() + chrono::Duration::days(i64::from(days_ahead)); - Some(target_date.and_hms_opt(*hour, *minute, 0)?.and_utc()) - }, - } - } - - #[must_use] - pub fn display_string(&self) -> String { - match self { - Self::Interval { secs } => { - if *secs >= 3600 { - format!("Every {}h", secs / 3600) - } else if *secs >= 60 { - format!("Every {}m", secs / 60) - } else { - format!("Every {secs}s") - } - }, - Self::Daily { hour, minute } => { - format!("Daily {hour:02}:{minute:02}") - }, - Self::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}") - }, - } - } -} - -/// Expand environment variables in a string using `std::env::var` for lookup. -/// 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 { - expand_env_vars(input, |name| { - std::env::var(name).map_err(|_| { - crate::error::PinakesError::Config(format!( - "environment variable not set: {name}" - )) - }) - }) -} - -/// Expand environment variables in a string using the provided lookup function. -/// Supports both `${VAR_NAME}` and `$VAR_NAME` syntax. -fn expand_env_vars( - input: &str, - lookup: impl Fn(&str) -> crate::error::Result, -) -> crate::error::Result { - 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 '{' - } - - // 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(), - )); - } - - result.push_str(&lookup(&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 rate_limits: RateLimitConfig, - #[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, - #[serde(default)] - pub trash: TrashConfig, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ScheduledTaskConfig { - pub id: String, - pub enabled: bool, - pub schedule: Schedule, - pub last_run: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RateLimitConfig { - /// Global rate limit: requests per second (token replenish interval). - /// Default: 1 (combined with `burst_size=100` gives ~100 req/sec) - #[serde(default = "default_global_per_second")] - pub global_per_second: u64, - /// Global rate limit: burst size (max concurrent requests per IP) - #[serde(default = "default_global_burst")] - pub global_burst_size: u32, - /// Login rate limit: seconds between token replenishment. - /// Default: 12 (one token every 12s, combined with burst=5 gives ~5 req/min) - #[serde(default = "default_login_per_second")] - pub login_per_second: u64, - /// Login rate limit: burst size - #[serde(default = "default_login_burst")] - pub login_burst_size: u32, - /// Search rate limit: seconds between token replenishment. - /// Default: 6 (one token every 6s, combined with burst=10 gives ~10 req/min) - #[serde(default = "default_search_per_second")] - pub search_per_second: u64, - /// Search rate limit: burst size - #[serde(default = "default_search_burst")] - pub search_burst_size: u32, - /// Streaming rate limit: seconds between token replenishment. - /// Default: 60 (one per minute) - #[serde(default = "default_stream_per_second")] - pub stream_per_second: u64, - /// Streaming rate limit: burst size (max concurrent streams) - #[serde(default = "default_stream_burst")] - pub stream_burst_size: u32, - /// Share token rate limit: seconds between token replenishment. - /// Default: 2 - #[serde(default = "default_share_per_second")] - pub share_per_second: u64, - /// Share token rate limit: burst size - #[serde(default = "default_share_burst")] - pub share_burst_size: u32, -} - -const fn default_global_per_second() -> u64 { - 1 -} -const fn default_global_burst() -> u32 { - 100 -} -const fn default_login_per_second() -> u64 { - 12 -} -const fn default_login_burst() -> u32 { - 5 -} -const fn default_search_per_second() -> u64 { - 6 -} -const fn default_search_burst() -> u32 { - 10 -} -const fn default_stream_per_second() -> u64 { - 60 -} -const fn default_stream_burst() -> u32 { - 5 -} -const fn default_share_per_second() -> u64 { - 2 -} -const fn default_share_burst() -> u32 { - 20 -} - -impl Default for RateLimitConfig { - fn default() -> Self { - Self { - global_per_second: default_global_per_second(), - global_burst_size: default_global_burst(), - login_per_second: default_login_per_second(), - login_burst_size: default_login_burst(), - search_per_second: default_search_per_second(), - search_burst_size: default_search_burst(), - stream_per_second: default_stream_per_second(), - stream_burst_size: default_stream_burst(), - share_per_second: default_share_per_second(), - share_burst_size: default_share_burst(), - } - } -} - -impl RateLimitConfig { - /// Validate that all rate limit values are positive. - /// - /// # Errors - /// - /// Returns an error string if any rate limit value is zero. - pub fn validate(&self) -> Result<(), String> { - for (name, value) in [ - ("global_per_second", self.global_per_second), - ("global_burst_size", u64::from(self.global_burst_size)), - ("login_per_second", self.login_per_second), - ("login_burst_size", u64::from(self.login_burst_size)), - ("search_per_second", self.search_per_second), - ("search_burst_size", u64::from(self.search_burst_size)), - ("stream_per_second", self.stream_per_second), - ("stream_burst_size", u64::from(self.stream_burst_size)), - ("share_per_second", self.share_per_second), - ("share_burst_size", u64::from(self.share_burst_size)), - ] { - if value == 0 { - return Err(format!("{name} must be > 0")); - } - } - Ok(()) - } -} - -#[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, - /// Maximum time a job is allowed to run before being cancelled (in seconds). - /// Set to 0 to disable timeout. Default: 3600 (1 hour). - #[serde(default = "default_job_timeout")] - pub job_timeout_secs: u64, -} - -const fn default_worker_count() -> usize { - 2 -} -const fn default_cache_ttl() -> u64 { - 60 -} -const fn default_job_timeout() -> u64 { - 3600 -} - -impl Default for JobsConfig { - fn default() -> Self { - Self { - worker_count: default_worker_count(), - cache_ttl_secs: default_cache_ttl(), - job_timeout_secs: default_job_timeout(), - } - } -} - -#[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, -} - -const fn default_thumb_size() -> u32 { - 320 -} -const fn default_thumb_quality() -> u8 { - 80 -} -const fn default_video_seek() -> u32 { - 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(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WebhookConfig { - 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, -} - -fn default_theme() -> String { - "dark".to_string() -} -fn default_view() -> String { - "library".to_string() -} -const fn default_page_size() -> usize { - 50 -} -fn default_view_mode() -> String { - "grid".to_string() -} -const fn default_true() -> bool { - 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, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AccountsConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub users: Vec, - /// Session expiry in hours. Defaults to 24. - #[serde(default = "default_session_expiry_hours")] - pub session_expiry_hours: u64, -} - -const fn default_session_expiry_hours() -> u64 { - 24 -} - -impl Default for AccountsConfig { - fn default() -> Self { - Self { - enabled: false, - users: Vec::new(), - session_expiry_hours: default_session_expiry_hours(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UserAccount { - pub username: String, - pub password_hash: String, - #[serde(default)] - pub role: UserRole, -} - -#[derive( - Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, -)] -#[serde(rename_all = "lowercase")] -pub enum UserRole { - Admin, - Editor, - #[default] - Viewer, -} - -impl UserRole { - #[must_use] - pub const fn can_read() -> bool { - true - } - - #[must_use] - pub const fn can_write(self) -> bool { - matches!(self, Self::Admin | Self::Editor) - } - - #[must_use] - pub const 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"), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PluginTimeoutConfig { - /// Timeout for capability discovery queries (`supported_types`, - /// `interested_events`) - #[serde( - default = "default_capability_query_timeout", - rename = "capability_query_secs" - )] - pub capability_query: u64, - /// Timeout for processing calls (`extract_metadata`, `generate_thumbnail`) - #[serde(default = "default_processing_timeout", rename = "processing_secs")] - pub processing: u64, - /// Timeout for event handler calls - #[serde( - default = "default_event_handler_timeout", - rename = "event_handler_secs" - )] - pub event_handler: u64, -} - -const fn default_capability_query_timeout() -> u64 { - 2 -} - -const fn default_processing_timeout() -> u64 { - 30 -} - -const fn default_event_handler_timeout() -> u64 { - 10 -} - -impl Default for PluginTimeoutConfig { - fn default() -> Self { - Self { - capability_query: default_capability_query_timeout(), - processing: default_processing_timeout(), - event_handler: default_event_handler_timeout(), - } - } -} - -#[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 timeouts: PluginTimeoutConfig, - #[serde(default = "default_max_consecutive_failures")] - pub max_consecutive_failures: u32, - - /// Hex-encoded Ed25519 public keys trusted for plugin signature - /// verification. Each entry is 64 hex characters (32 bytes). - #[serde(default)] - pub trusted_keys: Vec, -} - -fn default_plugin_data_dir() -> PathBuf { - Config::default_data_dir().join("plugins").join("data") -} - -fn default_plugin_cache_dir() -> PathBuf { - Config::default_data_dir().join("plugins").join("cache") -} - -const fn default_max_concurrent_ops() -> usize { - 4 -} - -const fn default_plugin_timeout() -> u64 { - 30 -} - -const fn default_max_consecutive_failures() -> u32 { - 5 -} - -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(), - timeouts: PluginTimeoutConfig::default(), - max_consecutive_failures: default_max_consecutive_failures(), - trusted_keys: vec![], - } - } -} - -#[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, -} - -const fn default_cache_ttl_hours() -> u64 { - 48 -} - -const fn default_max_concurrent_transcodes() -> usize { - 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, -} - -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(), - }, - ], - } - } -} - -#[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, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct EnrichmentSources { - #[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, -} - -#[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, -} - -const fn default_auto_sync_interval() -> u64 { - 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, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CloudSyncRule { - 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, -} - -impl Default for CloudConfig { - fn default() -> Self { - Self { - enabled: false, - auto_sync_interval_mins: default_auto_sync_interval(), - accounts: vec![], - } - } -} - -#[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, -} - -const fn default_retention_days() -> u64 { - 90 -} - -impl Default for AnalyticsConfig { - fn default() -> Self { - Self { - enabled: false, - track_usage: true, - retention_days: default_retention_days(), - } - } -} - -/// Feature toggles for photo processing (image analysis features). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PhotoFeatures { - /// 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, - - /// Generate multi-resolution thumbnails (tiny, grid, preview) - #[serde(default)] - pub multi_resolution_thumbnails: bool, -} - -impl Default for PhotoFeatures { - fn default() -> Self { - Self { - generate_perceptual_hash: true, - auto_tag_from_exif: false, - multi_resolution_thumbnails: false, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PhotoConfig { - /// Feature toggles for photo processing - #[serde(flatten)] - pub features: PhotoFeatures, - - /// 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, - - /// 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, -} - -impl PhotoConfig { - /// Returns true if perceptual hashing is enabled. - #[must_use] - pub const fn generate_perceptual_hash(&self) -> bool { - self.features.generate_perceptual_hash - } - - /// Returns true if auto-tagging from EXIF is enabled. - #[must_use] - pub const fn auto_tag_from_exif(&self) -> bool { - self.features.auto_tag_from_exif - } - - /// Returns true if multi-resolution thumbnails are enabled. - #[must_use] - pub const fn multi_resolution_thumbnails(&self) -> bool { - self.features.multi_resolution_thumbnails - } -} - -const fn default_min_event_photos() -> usize { - 5 -} - -const fn default_event_time_gap() -> i64 { - 2 * 60 * 60 // 2 hours -} - -const fn default_event_distance() -> f64 { - 1.0 // 1 km -} - -impl Default for PhotoConfig { - fn default() -> Self { - Self { - features: PhotoFeatures::default(), - 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(), - } - } -} - -#[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, -} - -fn default_managed_storage_dir() -> PathBuf { - Config::default_data_dir().join("managed") -} - -const fn default_max_upload_size() -> u64 { - 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, - } - } -} - -#[derive( - Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, -)] -#[serde(rename_all = "snake_case")] -pub enum ConflictResolution { - ServerWins, - ClientWins, - #[default] - KeepBoth, - Manual, -} - -#[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, -} - -const fn default_max_sync_file_size() -> u64 { - 4096 // 4GB -} - -const fn default_chunk_size() -> u64 { - 4096 // 4MB -} - -const fn default_upload_timeout() -> u64 { - 24 // 24 hours -} - -const fn default_max_concurrent_uploads() -> usize { - 3 -} - -const fn default_sync_log_retention() -> u64 { - 90 // 90 days -} - -fn default_temp_upload_dir() -> PathBuf { - 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(), - } - } -} - -/// Core permission flags for the sharing subsystem. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SharingPermissions { - /// Enable sharing functionality - #[serde(default = "default_true")] - pub enabled: bool, - /// Allow creating public share links - #[serde(default = "default_true")] - pub allow_public_links: bool, - /// Allow users to reshare content shared with them - #[serde(default = "default_true")] - pub allow_reshare: bool, -} - -impl Default for SharingPermissions { - fn default() -> Self { - Self { - enabled: true, - allow_public_links: true, - allow_reshare: true, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SharingConfig { - /// Core permission flags for sharing - #[serde(flatten)] - pub permissions: SharingPermissions, - /// Require password for public share links - #[serde(default)] - pub require_public_link_password: bool, - /// Enable share notifications - #[serde(default = "default_true")] - pub notifications_enabled: bool, - /// Maximum expiry time for public links in hours (0 = unlimited) - #[serde(default)] - pub max_public_link_expiry_hours: u64, - /// 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, -} - -impl SharingConfig { - /// Returns true if sharing is enabled. - #[must_use] - pub const fn enabled(&self) -> bool { - self.permissions.enabled - } - - /// Returns true if public links are allowed. - #[must_use] - pub const fn allow_public_links(&self) -> bool { - self.permissions.allow_public_links - } - - /// Returns true if resharing is allowed. - #[must_use] - pub const fn allow_reshare(&self) -> bool { - self.permissions.allow_reshare - } -} - -const fn default_notification_retention() -> u64 { - 30 -} - -const fn default_activity_retention() -> u64 { - 90 -} - -impl Default for SharingConfig { - fn default() -> Self { - Self { - permissions: SharingPermissions::default(), - require_public_link_password: false, - notifications_enabled: true, - max_public_link_expiry_hours: 0, - notification_retention_days: default_notification_retention(), - activity_retention_days: default_activity_retention(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TrashConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default = "default_trash_retention_days")] - pub retention_days: u64, - #[serde(default)] - pub auto_empty: bool, -} - -const fn default_trash_retention_days() -> u64 { - 30 -} - -impl Default for TrashConfig { - fn default() -> Self { - Self { - enabled: false, - retention_days: default_trash_retention_days(), - auto_empty: false, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StorageConfig { - 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, -} - -impl StorageBackendType { - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::Sqlite => "sqlite", - Self::Postgres => "postgres", - } - } -} - -impl std::fmt::Display for StorageBackendType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SqliteConfig { - 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, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DirectoryConfig { - 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, -} - -const fn default_import_concurrency() -> usize { - 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, - /// Enable CORS (Cross-Origin Resource Sharing). - /// When false, default localhost origins are used. - #[serde(default)] - pub cors_enabled: bool, - /// Allowed CORS origins when `cors_enabled` is true. - /// If empty and `cors_enabled` is true, defaults to localhost origins. - #[serde(default)] - pub cors_origins: Vec, - /// TLS/HTTPS configuration - #[serde(default)] - pub tls: TlsConfig, - /// Enable the Swagger UI at /api/docs. - /// Defaults to true. Set to false to disable in production if desired. - #[serde(default = "default_true")] - pub swagger_ui: bool, -} - -/// 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, -} - -const fn default_http_port() -> u16 { - 80 -} - -const fn default_hsts_max_age() -> u64 { - 31_536_000 // 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(), - } - } -} - -impl TlsConfig { - /// Validate TLS configuration - /// - /// # Errors - /// - /// Returns an error string if TLS is enabled but required paths are missing - /// or invalid. - 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 { - /// Load configuration from a TOML file, expanding environment variables in - /// secret fields. - /// - /// # Errors - /// - /// Returns [`crate::error::PinakesError`] if the file cannot be read, parsed, - /// or contains invalid environment variable references. - 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)?; - } - - // 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(()) - } - - /// Try loading from file, falling back to defaults if the file doesn't exist. - /// - /// # Errors - /// - /// Returns [`crate::error::PinakesError`] if the file exists but cannot be - /// read or parsed. - 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. - /// - /// # Errors - /// - /// Returns [`crate::error::PinakesError`] if the file cannot be written or - /// the config cannot be serialized. - 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. - /// - /// # Errors - /// - /// Returns [`crate::error::PinakesError`] if a required directory cannot be - /// created or is read-only. - pub fn ensure_dirs(&self) -> crate::error::Result<()> { - if let Some(ref sqlite) = self.storage.sqlite - && 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(()) - } - - /// Returns the default config file path following XDG conventions. - #[must_use] - pub fn default_config_path() -> PathBuf { - std::env::var("XDG_CONFIG_HOME").map_or_else( - |_| { - std::env::var("HOME").map_or_else( - |_| PathBuf::from("pinakes.toml"), - |home| { - PathBuf::from(home) - .join(".config") - .join("pinakes") - .join("pinakes.toml") - }, - ) - }, - |xdg| PathBuf::from(xdg).join("pinakes").join("pinakes.toml"), - ) - } - - /// Validate configuration values for correctness. - /// - /// # Errors - /// - /// Returns an error string if any configuration value is invalid. - 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 rate limits - self.rate_limits.validate()?; - - // Validate TLS configuration - self.server.tls.validate()?; - Ok(()) - } - - /// Returns the default data directory following XDG conventions. - #[must_use] - pub fn default_data_dir() -> PathBuf { - std::env::var("XDG_DATA_HOME").map_or_else( - |_| { - std::env::var("HOME").map_or_else( - |_| PathBuf::from("pinakes-data"), - |home| { - PathBuf::from(home) - .join(".local") - .join("share") - .join("pinakes") - }, - ) - }, - |xdg| PathBuf::from(xdg).join("pinakes"), - ) - } -} - -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, - cors_enabled: false, - cors_origins: vec![], - tls: TlsConfig::default(), - swagger_ui: true, - }, - ui: UiConfig::default(), - accounts: AccountsConfig::default(), - rate_limits: RateLimitConfig::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(), - trash: TrashConfig::default(), - } - } -} - -#[cfg(test)] -mod tests { - use rustc_hash::FxHashMap; - - 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 - } - - #[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_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_high() { - let config = test_config_with_concurrency(256); - assert!(config.validate().is_ok()); - } - - // Environment variable expansion tests using expand_env_vars with a - // HashMap lookup. This avoids unsafe std::env::set_var and is - // thread-safe for parallel test execution. - fn test_lookup<'a>( - vars: &'a FxHashMap<&str, &str>, - ) -> impl Fn(&str) -> crate::error::Result + 'a { - move |name| { - vars - .get(name) - .map(std::string::ToString::to_string) - .ok_or_else(|| { - crate::error::PinakesError::Config(format!( - "environment variable not set: {name}" - )) - }) - } - } - - #[test] - fn test_expand_env_var_simple() { - let vars = [("TEST_VAR_SIMPLE", "test_value")] - .into_iter() - .collect::>(); - let result = expand_env_vars("$TEST_VAR_SIMPLE", test_lookup(&vars)); - assert_eq!(result.unwrap(), "test_value"); - } - - #[test] - fn test_expand_env_var_braces() { - let vars = [("TEST_VAR_BRACES", "test_value")] - .into_iter() - .collect::>(); - let result = expand_env_vars("${TEST_VAR_BRACES}", test_lookup(&vars)); - assert_eq!(result.unwrap(), "test_value"); - } - - #[test] - fn test_expand_env_var_embedded() { - let vars = [("TEST_VAR_EMBEDDED", "value")] - .into_iter() - .collect::>(); - let result = - expand_env_vars("prefix_${TEST_VAR_EMBEDDED}_suffix", test_lookup(&vars)); - assert_eq!(result.unwrap(), "prefix_value_suffix"); - } - - #[test] - fn test_expand_env_var_multiple() { - let vars = [("VAR1", "value1"), ("VAR2", "value2")] - .into_iter() - .collect::>(); - let result = expand_env_vars("${VAR1}_${VAR2}", test_lookup(&vars)); - assert_eq!(result.unwrap(), "value1_value2"); - } - - #[test] - fn test_expand_env_var_missing() { - let vars = FxHashMap::default(); - let result = expand_env_vars("${NONEXISTENT_VAR}", test_lookup(&vars)); - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("environment variable not set") - ); - } - - #[test] - fn test_expand_env_var_empty_name() { - let vars = FxHashMap::default(); - let result = expand_env_vars("${}", test_lookup(&vars)); - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("empty environment variable name") - ); - } - - #[test] - fn test_expand_env_var_escaped() { - let vars = FxHashMap::default(); - let result = expand_env_vars("\\$NOT_A_VAR", test_lookup(&vars)); - assert_eq!(result.unwrap(), "$NOT_A_VAR"); - } - - #[test] - fn test_expand_env_var_no_vars() { - let vars = FxHashMap::default(); - let result = expand_env_vars("plain_text", test_lookup(&vars)); - assert_eq!(result.unwrap(), "plain_text"); - } - - #[test] - fn test_expand_env_var_underscore() { - let vars = [("TEST_VAR_NAME", "value")] - .into_iter() - .collect::>(); - let result = expand_env_vars("$TEST_VAR_NAME", test_lookup(&vars)); - assert_eq!(result.unwrap(), "value"); - } - - #[test] - fn test_expand_env_var_mixed_syntax() { - let vars = [("VAR1_MIXED", "v1"), ("VAR2_MIXED", "v2")] - .into_iter() - .collect::>(); - let result = - expand_env_vars("$VAR1_MIXED and ${VAR2_MIXED}", test_lookup(&vars)); - assert_eq!(result.unwrap(), "v1 and v2"); - } -} diff --git a/crates/pinakes-types/src/error.rs b/crates/pinakes-types/src/error.rs deleted file mode 100644 index 33f0621..0000000 --- a/crates/pinakes-types/src/error.rs +++ /dev/null @@ -1,142 +0,0 @@ -use std::path::PathBuf; - -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum PinakesError { - #[error("IO error: {0}")] - Io(#[from] std::io::Error), - - #[error("database error: {0}")] - Database(String), - - #[error("migration error: {0}")] - Migration(String), - - #[error("configuration error: {0}")] - Config(String), - - #[error("media item not found: {0}")] - NotFound(String), - - #[error("duplicate content hash: {0}")] - DuplicateHash(String), - - #[error("unsupported media type for path: {0}")] - UnsupportedMediaType(PathBuf), - - #[error("metadata extraction failed: {0}")] - MetadataExtraction(String), - - #[error("thumbnail generation failed: {0}")] - ThumbnailGeneration(String), - - #[error("search query parse error: {0}")] - SearchParse(String), - - #[error("file not found at path: {0}")] - FileNotFound(PathBuf), - - #[error("tag not found: {0}")] - TagNotFound(String), - - #[error("collection not found: {0}")] - CollectionNotFound(String), - - #[error("invalid operation: {0}")] - InvalidOperation(String), - - #[error("invalid data: {0}")] - InvalidData(String), - - #[error("authentication error: {0}")] - Authentication(String), - - #[error("authorization error: {0}")] - Authorization(String), - - #[error("path not allowed: {0}")] - PathNotAllowed(String), - - #[error("external API error: {0}")] - External(String), - - // Managed Storage errors - #[error("managed storage not enabled")] - ManagedStorageDisabled, - - #[error("upload too large: {0} bytes exceeds limit")] - UploadTooLarge(u64), - - #[error("blob not found: {0}")] - BlobNotFound(String), - - #[error("storage integrity error: {0}")] - StorageIntegrity(String), - - // Sync errors - #[error("sync not enabled")] - SyncDisabled, - - #[error("device not found: {0}")] - DeviceNotFound(String), - - #[error("sync conflict: {0}")] - SyncConflict(String), - - #[error("upload session expired: {0}")] - UploadSessionExpired(String), - - #[error("upload session not found: {0}")] - UploadSessionNotFound(String), - - #[error("chunk out of order: expected {expected}, got {actual}")] - ChunkOutOfOrder { expected: u64, actual: u64 }, - - // Sharing errors - #[error("share not found: {0}")] - ShareNotFound(String), - - #[error("share expired: {0}")] - ShareExpired(String), - - #[error("share password required")] - SharePasswordRequired, - - #[error("share password invalid")] - SharePasswordInvalid, - - #[error("insufficient share permissions")] - InsufficientSharePermissions, - - #[error("serialization error: {0}")] - Serialization(String), - - #[error("external tool `{tool}` failed: {stderr}")] - ExternalTool { tool: String, stderr: String }, - - #[error("subtitle track {index} not found in media")] - SubtitleTrackNotFound { index: u32 }, - - #[error("invalid language code: {0}")] - InvalidLanguageCode(String), -} - -impl From for PinakesError { - fn from(e: serde_json::Error) -> Self { - Self::Serialization(e.to_string()) - } -} - -/// Build a closure that wraps a database error with operation context. -/// -/// Usage: `stmt.execute(params).map_err(db_ctx("insert_media", media_id))?;` -pub fn db_ctx( - operation: &str, - entity: impl std::fmt::Display, -) -> impl FnOnce(E) -> PinakesError { - let context = format!("{operation} [{entity}]"); - move |e| PinakesError::Database(format!("{context}: {e}")) -} - -pub type Result = std::result::Result; diff --git a/crates/pinakes-types/src/lib.rs b/crates/pinakes-types/src/lib.rs deleted file mode 100644 index 8b482c4..0000000 --- a/crates/pinakes-types/src/lib.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod config; -pub mod error; -pub mod media_type; -pub mod model; diff --git a/crates/pinakes-types/src/media_type/builtin.rs b/crates/pinakes-types/src/media_type/builtin.rs deleted file mode 100644 index 9201b6f..0000000 --- a/crates/pinakes-types/src/media_type/builtin.rs +++ /dev/null @@ -1,292 +0,0 @@ -use std::path::Path; - -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, - - // Video - Mp4, - Mkv, - Avi, - Webm, - - // Documents - Pdf, - Epub, - Djvu, - - // Text - Markdown, - PlainText, - - // Images - Jpeg, - Png, - Gif, - Webp, - Svg, - Avif, - Tiff, - Bmp, - - // RAW Images - Cr2, - Nef, - Arw, - Dng, - Orf, - Rw2, - - // HEIC/HEIF - Heic, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum MediaCategory { - Audio, - Video, - Document, - Text, - Image, -} - -impl BuiltinMediaType { - /// Get the unique, stable ID for this media type. - #[must_use] - pub const fn id(self) -> &'static str { - match self { - Self::Mp3 => "mp3", - Self::Flac => "flac", - Self::Ogg => "ogg", - Self::Wav => "wav", - Self::Aac => "aac", - Self::Opus => "opus", - Self::Mp4 => "mp4", - Self::Mkv => "mkv", - Self::Avi => "avi", - Self::Webm => "webm", - Self::Pdf => "pdf", - Self::Epub => "epub", - Self::Djvu => "djvu", - Self::Markdown => "markdown", - Self::PlainText => "plaintext", - Self::Jpeg => "jpeg", - Self::Png => "png", - Self::Gif => "gif", - Self::Webp => "webp", - Self::Svg => "svg", - Self::Avif => "avif", - Self::Tiff => "tiff", - Self::Bmp => "bmp", - Self::Cr2 => "cr2", - Self::Nef => "nef", - Self::Arw => "arw", - Self::Dng => "dng", - Self::Orf => "orf", - Self::Rw2 => "rw2", - Self::Heic => "heic", - } - } - - /// Get the display name for this media type - #[must_use] - 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(), - } - } - - #[must_use] - 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) - } - - #[must_use] - pub const 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", - } - } - - #[must_use] - pub const 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, - } - } - - #[must_use] - pub const 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. - #[must_use] - pub const fn is_raw(self) -> bool { - matches!( - self, - Self::Cr2 | Self::Nef | Self::Arw | Self::Dng | Self::Orf | Self::Rw2 - ) - } -} diff --git a/crates/pinakes-types/src/media_type/mod.rs b/crates/pinakes-types/src/media_type/mod.rs deleted file mode 100644 index 2c73ef0..0000000 --- a/crates/pinakes-types/src/media_type/mod.rs +++ /dev/null @@ -1,281 +0,0 @@ -//! Media types -//! -//! Supports both -//! built-in media types and plugin-registered custom types. - -use std::path::Path; - -use serde::{Deserialize, Serialize}; - -pub mod builtin; -pub mod registry; - -pub use builtin::{BuiltinMediaType, MediaCategory}; -pub use registry::{MediaTypeDescriptor, MediaTypeRegistry}; - -/// Media type identifier, can be either built-in or custom -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(untagged)] -pub enum MediaType { - /// Built-in media type (backward compatible) - Builtin(BuiltinMediaType), - - /// 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()) - } - - /// Get the type ID as a string - #[must_use] - pub fn id(&self) -> String { - match self { - Self::Builtin(b) => b.id().to_string(), - 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 - #[must_use] - 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 - #[must_use] - pub fn name_with_registry(&self, registry: &MediaTypeRegistry) -> String { - match self { - Self::Builtin(b) => b.name(), - Self::Custom(id) => { - registry - .get(id) - .map_or_else(|| id.clone(), |d| d.name.clone()) - }, - } - } - - /// Get the category for this media type - /// For custom types without a registry, returns [`MediaCategory::Document`] - /// as default - #[must_use] - pub const 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 - #[must_use] - 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" - #[must_use] - 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 - #[must_use] - 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 - #[must_use] - pub fn extensions(&self) -> Vec { - match self { - Self::Builtin(b) => { - b.extensions() - .iter() - .map(std::string::ToString::to_string) - .collect() - }, - Self::Custom(_) => vec![], - } - } - - /// Get file extensions with registry support - #[must_use] - pub fn extensions_with_registry( - &self, - registry: &MediaTypeRegistry, - ) -> Vec { - match self { - Self::Builtin(b) => { - b.extensions() - .iter() - .map(std::string::ToString::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 - #[must_use] - pub const 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) - #[must_use] - 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 - registry - .get_by_extension(ext) - .map(|desc| Self::Custom(desc.id.clone())) - } - - /// 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) - #[must_use] - 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) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[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); - } - - #[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()), - }; - - 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); - } - - #[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)); - } - - #[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()), - }; - - registry.register(descriptor).unwrap(); - - 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-types/src/media_type/registry.rs b/crates/pinakes-types/src/media_type/registry.rs deleted file mode 100644 index 0c116dd..0000000 --- a/crates/pinakes-types/src/media_type/registry.rs +++ /dev/null @@ -1,309 +0,0 @@ -//! Media type registry for managing both built-in and custom media types - -use anyhow::{Result, anyhow}; -use rustc_hash::FxHashMap; -use serde::{Deserialize, Serialize}; - -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, - - /// Display name - pub name: String, - - /// Category - pub category: Option, - - /// File extensions - pub extensions: Vec, - - /// MIME types - pub mime_types: Vec, - - /// 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: FxHashMap, - - /// Map of extension to media type ID - extension_map: FxHashMap, -} - -impl MediaTypeRegistry { - /// Create a new empty registry - #[must_use] - pub fn new() -> Self { - Self { - types: FxHashMap::default(), - extension_map: FxHashMap::default(), - } - } - - /// Register a new media type - /// - /// # Errors - /// - /// Returns an error if a media type with the same ID is already registered. - pub fn register(&mut self, descriptor: MediaTypeDescriptor) -> Result<()> { - // Check if ID is already registered - if self.types.contains_key(&descriptor.id) { - 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(()) - } - - /// Unregister a media type - /// - /// # Errors - /// - /// Returns an error if no media type with the given ID is registered. - pub fn unregister(&mut self, id: &str) -> Result<()> { - let descriptor = self - .types - .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); - } - } - - Ok(()) - } - - /// Get a media type descriptor by ID - #[must_use] - pub fn get(&self, id: &str) -> Option<&MediaTypeDescriptor> { - self.types.get(id) - } - - /// Get a media type by file extension - #[must_use] - 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 - #[must_use] - pub fn list_all(&self) -> Vec<&MediaTypeDescriptor> { - self.types.values().collect() - } - - /// List media types from a specific plugin - #[must_use] - 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) - #[must_use] - pub fn list_builtin(&self) -> Vec<&MediaTypeDescriptor> { - self - .types - .values() - .filter(|d| d.plugin_id.is_none()) - .collect() - } - - /// Get count of registered types - #[must_use] - pub fn count(&self) -> usize { - self.types.len() - } - - /// Check if a media type is registered - #[must_use] - pub fn contains(&self, id: &str) -> bool { - self.types.contains_key(id) - } - - /// Unregister all types from a specific plugin - /// - /// # Errors - /// - /// Returns an error if unregistering any individual type fails. - pub fn unregister_plugin(&mut self, plugin_id: &str) -> Result { - let type_ids: Vec = self - .types - .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) - } -} - -impl Default for MediaTypeRegistry { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn create_test_descriptor(id: &str, ext: &str) -> MediaTypeDescriptor { - MediaTypeDescriptor { - id: id.to_string(), - name: format!("{id} Type"), - 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).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); - } -} diff --git a/packages/pinakes-ui/Cargo.toml b/crates/pinakes-ui/Cargo.toml similarity index 100% rename from packages/pinakes-ui/Cargo.toml rename to crates/pinakes-ui/Cargo.toml index 1142850..6c52e77 100644 --- a/packages/pinakes-ui/Cargo.toml +++ b/crates/pinakes-ui/Cargo.toml @@ -4,12 +4,6 @@ edition.workspace = true version.workspace = true license.workspace = true -[features] -default = ["web"] -web = ["dioxus/web"] -desktop = ["dioxus/desktop"] -mobile = ["dioxus/mobile"] - [dependencies] serde = { workspace = true } serde_json = { workspace = true } @@ -38,3 +32,9 @@ rustc-hash = { workspace = true } [lints] workspace = true + +[features] +default = ["web"] +web = ["dioxus/web"] +desktop = ["dioxus/desktop"] +mobile = ["dioxus/mobile"] diff --git a/packages/pinakes-ui/Dioxus.toml b/crates/pinakes-ui/Dioxus.toml similarity index 100% rename from packages/pinakes-ui/Dioxus.toml rename to crates/pinakes-ui/Dioxus.toml diff --git a/crates/pinakes-ui/assets/css/main.css b/crates/pinakes-ui/assets/css/main.css new file mode 100644 index 0000000..d25d383 --- /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}.mt-16{margin-top:16px}.mt-8{margin-top: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}.card-body{padding-top:8px}.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}.badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:50%;font-size:9px;font-weight:600;letter-spacing:.5px;text-transform:uppercase}.badge-neutral{background:rgba(255,255,255,.04);color:#a0a0b8}.badge-success{background:rgba(62,201,122,.08);color:#3ec97a}.badge-warning{background:rgba(212,160,55,.06);color:#d4a037}.badge-danger{background:rgba(228,88,88,.06);color:#d47070}.tab-bar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px;border-bottom:1px solid rgba(255,255,255,.09);margin-bottom:12px}.tab-btn{background:rgba(0,0,0,0);border:none;padding:6px 8px;font-size:13px;color:#a0a0b8;border-bottom:2px solid rgba(0,0,0,0);margin-bottom:-1px;cursor:pointer;transition:color .1s,border-color .1s}.tab-btn:hover{color:#dcdce4}.tab-btn.active{color:#9698f7;border-bottom-color:#7c7ef5}.input-sm{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;padding:3px 8px;font-size:12px}.input-sm::placeholder{color:#6c6c84}.input-sm:focus{border-color:#7c7ef5}.input-suffix{display:flex;flex-direction:row;justify-content:flex-start;align-items:stretch}.input-suffix input{border-radius:3px 0 0 3px;border-right:none;flex:1}.input-suffix .btn{border-radius:0 3px 3px 0}.field-error{color:#d47070;font-size:11px;margin-top:2px}.comments-list{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px}.comment-item{padding:8px;background:#18181f;border-radius:5px;border:1px solid rgba(255,255,255,.06)}.comment-text{font-size:13px;color:#dcdce4;line-height:1.5;margin-top:4px}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-native-video{width:100%;display:block}.player-native-audio{display:none}.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)}.plugin-page{padding:16px 24px;max-width:100%;overflow-x:hidden}.plugin-page-title{font-size:14px;font-weight:600;color:#dcdce4;margin:0 0 16px}.plugin-container{display:flex;flex-direction:column;gap:var(--plugin-gap, 0px);padding:var(--plugin-padding, 0)}.plugin-grid{display:grid;grid-template-columns:repeat(var(--plugin-columns, 1), 1fr);gap:var(--plugin-gap, 0px)}.plugin-flex{display:flex;gap:var(--plugin-gap, 0px)}.plugin-flex[data-direction=row]{flex-direction:row}.plugin-flex[data-direction=column]{flex-direction:column}.plugin-flex[data-justify=flex-start]{justify-content:flex-start}.plugin-flex[data-justify=flex-end]{justify-content:flex-end}.plugin-flex[data-justify=center]{justify-content:center}.plugin-flex[data-justify=space-between]{justify-content:space-between}.plugin-flex[data-justify=space-around]{justify-content:space-around}.plugin-flex[data-justify=space-evenly]{justify-content:space-evenly}.plugin-flex[data-align=flex-start]{align-items:flex-start}.plugin-flex[data-align=flex-end]{align-items:flex-end}.plugin-flex[data-align=center]{align-items:center}.plugin-flex[data-align=stretch]{align-items:stretch}.plugin-flex[data-align=baseline]{align-items:baseline}.plugin-flex[data-wrap=wrap]{flex-wrap:wrap}.plugin-flex[data-wrap=nowrap]{flex-wrap:nowrap}.plugin-split{display:flex}.plugin-split-sidebar{width:var(--plugin-sidebar-width, 200px);flex-shrink:0}.plugin-split-main{flex:1;min-width:0}.plugin-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;overflow:hidden}.plugin-card-header{padding:12px 16px;font-size:12px;font-weight:600;color:#dcdce4;border-bottom:1px solid rgba(255,255,255,.09);background:#26263a}.plugin-card-content{padding:16px}.plugin-card-footer{padding:12px 16px;border-top:1px solid rgba(255,255,255,.09);background:#18181f}.plugin-heading{color:#dcdce4;margin:0;line-height:1.2}.plugin-heading.level-1{font-size:28px;font-weight:700}.plugin-heading.level-2{font-size:18px;font-weight:600}.plugin-heading.level-3{font-size:16px;font-weight:600}.plugin-heading.level-4{font-size:14px;font-weight:500}.plugin-heading.level-5{font-size:13px;font-weight:500}.plugin-heading.level-6{font-size:12px;font-weight:500}.plugin-text{margin:0;font-size:12px;color:#dcdce4;line-height:1.4}.plugin-text.text-secondary{color:#a0a0b8}.plugin-text.text-error{color:#d47070}.plugin-text.text-success{color:#3ec97a}.plugin-text.text-warning{color:#d4a037}.plugin-text.text-bold{font-weight:600}.plugin-text.text-italic{font-style:italic}.plugin-text.text-small{font-size:10px}.plugin-text.text-large{font-size:15px}.plugin-code{background:#18181f;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px 24px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px;color:#dcdce4;overflow-x:auto;white-space:pre}.plugin-code code{font-family:inherit;font-size:inherit;color:inherit}.plugin-tabs{display:flex;flex-direction:column}.plugin-tab-list{display:flex;gap:2px;border-bottom:1px solid rgba(255,255,255,.09);margin-bottom:16px}.plugin-tab{padding:8px 20px;font-size:12px;font-weight:500;color:#a0a0b8;background:rgba(0,0,0,0);border:none;border-bottom:2px solid rgba(0,0,0,0);cursor:pointer;transition:color .1s,border-color .1s}.plugin-tab:hover{color:#dcdce4}.plugin-tab.active{color:#9698f7;border-bottom-color:#7c7ef5}.plugin-tab .tab-icon{margin-right:4px}.plugin-tab-panel:not(.active){display:none}.plugin-description-list-wrapper{width:100%}.plugin-description-list{display:grid;grid-template-columns:max-content 1fr;gap:4px 16px;margin:0;padding:0}.plugin-description-list dt{font-size:10px;font-weight:500;color:#a0a0b8;text-transform:uppercase;letter-spacing:.5px;padding:6px 0;white-space:nowrap}.plugin-description-list dd{font-size:12px;color:#dcdce4;padding:6px 0;margin:0;word-break:break-word}.plugin-description-list.horizontal{display:flex;flex-wrap:wrap;gap:16px 24px;display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr))}.plugin-description-list.horizontal dt{width:auto;padding:0}.plugin-description-list.horizontal dd{width:auto;padding:0}.plugin-description-list.horizontal dt,.plugin-description-list.horizontal dd{display:inline}.plugin-description-list.horizontal dt{font-size:9px;text-transform:uppercase;letter-spacing:.5px;color:#6c6c84;margin-bottom:2px}.plugin-description-list.horizontal dd{font-size:13px;font-weight:600;color:#dcdce4}.plugin-data-table-wrapper{overflow-x:auto}.plugin-data-table{width:100%;border-collapse:collapse;font-size:12px}.plugin-data-table thead tr{border-bottom:1px solid rgba(255,255,255,.14)}.plugin-data-table thead th{padding:8px 12px;text-align:left;font-size:10px;font-weight:600;color:#a0a0b8;text-transform:uppercase;letter-spacing:.5px;white-space:nowrap}.plugin-data-table tbody tr{border-bottom:1px solid rgba(255,255,255,.06);transition:background .08s}.plugin-data-table tbody tr:hover{background:rgba(255,255,255,.03)}.plugin-data-table tbody tr:last-child{border-bottom:none}.plugin-data-table tbody td{padding:8px 12px;color:#dcdce4;vertical-align:middle}.plugin-col-constrained{width:var(--plugin-col-width)}.table-filter{margin-bottom:12px}.table-filter input{width:240px;padding:6px 12px;background:#18181f;border:1px solid rgba(255,255,255,.09);border-radius:5px;color:#dcdce4;font-size:12px}.table-filter input::placeholder{color:#6c6c84}.table-filter input:focus{outline:none;border-color:#7c7ef5}.table-pagination{display:flex;align-items:center;gap:12px;padding:8px 0;font-size:12px;color:#a0a0b8}.row-actions{white-space:nowrap;width:1%}.row-actions .plugin-button{padding:4px 8px;font-size:10px;margin-right:4px}.plugin-media-grid{display:grid;grid-template-columns:repeat(var(--plugin-columns, 2), 1fr);gap:var(--plugin-gap, 8px)}.media-grid-item{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;overflow:hidden;display:flex;flex-direction:column}.media-grid-img{width:100%;aspect-ratio:16/9;object-fit:cover;display:block}.media-grid-no-img{width:100%;aspect-ratio:16/9;background:#26263a;display:flex;align-items:center;justify-content:center;font-size:10px;color:#6c6c84}.media-grid-caption{padding:8px 12px;font-size:10px;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.plugin-list{list-style:none;margin:0;padding:0}.plugin-list-item{padding:8px 0}.plugin-list-divider{border:none;border-top:1px solid rgba(255,255,255,.06);margin:0}.plugin-list-empty{padding:16px;text-align:center;color:#6c6c84;font-size:12px}.plugin-button{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border:1px solid rgba(255,255,255,.09);border-radius:5px;font-size:12px;font-weight:500;cursor:pointer;transition:background .08s,border-color .08s,color .08s;background:#1f1f28;color:#dcdce4}.plugin-button:disabled{opacity:.45;cursor:not-allowed}.plugin-button.btn-primary{background:#7c7ef5;border-color:#7c7ef5;color:#fff}.plugin-button.btn-primary:hover:not(:disabled){background:#8b8df7}.plugin-button.btn-secondary{background:#26263a;border-color:rgba(255,255,255,.14);color:#dcdce4}.plugin-button.btn-secondary:hover:not(:disabled){background:rgba(255,255,255,.04)}.plugin-button.btn-tertiary{background:rgba(0,0,0,0);border-color:rgba(0,0,0,0);color:#9698f7}.plugin-button.btn-tertiary:hover:not(:disabled){background:rgba(124,126,245,.15)}.plugin-button.btn-danger{background:rgba(0,0,0,0);border-color:rgba(228,88,88,.2);color:#d47070}.plugin-button.btn-danger:hover:not(:disabled){background:rgba(228,88,88,.06)}.plugin-button.btn-success{background:rgba(0,0,0,0);border-color:rgba(62,201,122,.2);color:#3ec97a}.plugin-button.btn-success:hover:not(:disabled){background:rgba(62,201,122,.08)}.plugin-button.btn-ghost{background:rgba(0,0,0,0);border-color:rgba(0,0,0,0);color:#a0a0b8}.plugin-button.btn-ghost:hover:not(:disabled){background:rgba(255,255,255,.04)}.plugin-badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:50%;font-size:9px;font-weight:600;letter-spacing:.5px;text-transform:uppercase}.plugin-badge.badge-default,.plugin-badge.badge-neutral{background:rgba(255,255,255,.04);color:#a0a0b8}.plugin-badge.badge-primary{background:rgba(124,126,245,.15);color:#9698f7}.plugin-badge.badge-secondary{background:rgba(255,255,255,.03);color:#dcdce4}.plugin-badge.badge-success{background:rgba(62,201,122,.08);color:#3ec97a}.plugin-badge.badge-warning{background:rgba(212,160,55,.06);color:#d4a037}.plugin-badge.badge-error{background:rgba(228,88,88,.06);color:#d47070}.plugin-badge.badge-info{background:rgba(99,102,241,.08);color:#9698f7}.plugin-form{display:flex;flex-direction:column;gap:16px}.form-field{display:flex;flex-direction:column;gap:6px}.form-field label{font-size:12px;font-weight:500;color:#dcdce4}.form-field input,.form-field textarea,.form-field select{padding:8px 12px;background:#18181f;border:1px solid rgba(255,255,255,.09);border-radius:5px;color:#dcdce4;font-size:12px;font-family:inherit}.form-field input::placeholder,.form-field textarea::placeholder,.form-field select::placeholder{color:#6c6c84}.form-field input:focus,.form-field textarea:focus,.form-field select:focus{outline:none;border-color:#7c7ef5;box-shadow:0 0 0 2px rgba(124,126,245,.15)}.form-field textarea{min-height:80px;resize:vertical}.form-field select{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23a0a0b8' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center;padding-right:32px}.form-help{margin:0;font-size:10px;color:#6c6c84}.form-actions{display:flex;gap:12px;padding-top:8px}.required{color:#e45858}.plugin-link{color:#9698f7;text-decoration:none}.plugin-link:hover{text-decoration:underline}.plugin-link-blocked{color:#6c6c84;text-decoration:line-through;cursor:not-allowed}.plugin-progress{background:#18181f;border:1px solid rgba(255,255,255,.09);border-radius:5px;height:8px;overflow:hidden;display:flex;align-items:center;gap:8px}.plugin-progress-bar{height:100%;background:#7c7ef5;border-radius:4px;transition:width .3s ease;width:var(--plugin-progress, 0%)}.plugin-progress-label{font-size:10px;color:#a0a0b8;white-space:nowrap;flex-shrink:0}.plugin-chart{overflow:auto;height:var(--plugin-chart-height, 200px)}.plugin-chart .chart-title{font-size:13px;font-weight:600;color:#dcdce4;margin-bottom:8px}.plugin-chart .chart-x-label,.plugin-chart .chart-y-label{font-size:10px;color:#6c6c84;margin-bottom:4px}.plugin-chart .chart-data-table{overflow-x:auto}.plugin-chart .chart-no-data{padding:24px;text-align:center;color:#6c6c84;font-size:12px}.plugin-loading{padding:16px;color:#a0a0b8;font-size:12px;font-style:italic}.plugin-error{padding:12px 16px;background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);border-radius:5px;color:#d47070;font-size:12px}.plugin-feedback{position:sticky;bottom:16px;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:12px 16px;border-radius:7px;font-size:12px;z-index:300;box-shadow:0 4px 20px rgba(0,0,0,.45)}.plugin-feedback.success{background:rgba(62,201,122,.08);border:1px solid rgba(62,201,122,.2);color:#3ec97a}.plugin-feedback.error{background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);color:#d47070}.plugin-feedback-dismiss{background:rgba(0,0,0,0);border:none;color:inherit;font-size:14px;cursor:pointer;line-height:1;padding:0;opacity:.7}.plugin-feedback-dismiss:hover{opacity:1}.plugin-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.65);display:flex;align-items:center;justify-content:center;z-index:100}.plugin-modal{position:relative;background:#1f1f28;border:1px solid rgba(255,255,255,.14);border-radius:12px;padding:32px;min-width:380px;max-width:640px;max-height:80vh;overflow-y:auto;box-shadow:0 4px 20px rgba(0,0,0,.45);z-index:200}.plugin-modal-close{position:absolute;top:16px;right:16px;background:rgba(0,0,0,0);border:none;color:#a0a0b8;font-size:14px;cursor:pointer;line-height:1;padding:4px;border-radius:5px}.plugin-modal-close:hover{background:rgba(255,255,255,.04);color:#dcdce4} \ No newline at end of file diff --git a/packages/pinakes-ui/assets/styles/_audit.scss b/crates/pinakes-ui/assets/styles/_audit.scss similarity index 100% rename from packages/pinakes-ui/assets/styles/_audit.scss rename to crates/pinakes-ui/assets/styles/_audit.scss diff --git a/packages/pinakes-ui/assets/styles/_base.scss b/crates/pinakes-ui/assets/styles/_base.scss similarity index 100% rename from packages/pinakes-ui/assets/styles/_base.scss rename to crates/pinakes-ui/assets/styles/_base.scss diff --git a/packages/pinakes-ui/assets/styles/_components.scss b/crates/pinakes-ui/assets/styles/_components.scss similarity index 100% rename from packages/pinakes-ui/assets/styles/_components.scss rename to crates/pinakes-ui/assets/styles/_components.scss diff --git a/packages/pinakes-ui/assets/styles/_graph.scss b/crates/pinakes-ui/assets/styles/_graph.scss similarity index 100% rename from packages/pinakes-ui/assets/styles/_graph.scss rename to crates/pinakes-ui/assets/styles/_graph.scss diff --git a/packages/pinakes-ui/assets/styles/_layout.scss b/crates/pinakes-ui/assets/styles/_layout.scss similarity index 100% rename from packages/pinakes-ui/assets/styles/_layout.scss rename to crates/pinakes-ui/assets/styles/_layout.scss diff --git a/packages/pinakes-ui/assets/styles/_media.scss b/crates/pinakes-ui/assets/styles/_media.scss similarity index 100% rename from packages/pinakes-ui/assets/styles/_media.scss rename to crates/pinakes-ui/assets/styles/_media.scss diff --git a/packages/pinakes-ui/assets/styles/_mixins.scss b/crates/pinakes-ui/assets/styles/_mixins.scss similarity index 100% rename from packages/pinakes-ui/assets/styles/_mixins.scss rename to crates/pinakes-ui/assets/styles/_mixins.scss diff --git a/packages/pinakes-ui/assets/styles/_plugins.scss b/crates/pinakes-ui/assets/styles/_plugins.scss similarity index 100% rename from packages/pinakes-ui/assets/styles/_plugins.scss rename to crates/pinakes-ui/assets/styles/_plugins.scss diff --git a/packages/pinakes-ui/assets/styles/_sections.scss b/crates/pinakes-ui/assets/styles/_sections.scss similarity index 100% rename from packages/pinakes-ui/assets/styles/_sections.scss rename to crates/pinakes-ui/assets/styles/_sections.scss diff --git a/packages/pinakes-ui/assets/styles/_themes.scss b/crates/pinakes-ui/assets/styles/_themes.scss similarity index 100% rename from packages/pinakes-ui/assets/styles/_themes.scss rename to crates/pinakes-ui/assets/styles/_themes.scss diff --git a/packages/pinakes-ui/assets/styles/_variables.scss b/crates/pinakes-ui/assets/styles/_variables.scss similarity index 100% rename from packages/pinakes-ui/assets/styles/_variables.scss rename to crates/pinakes-ui/assets/styles/_variables.scss diff --git a/packages/pinakes-ui/assets/styles/main.scss b/crates/pinakes-ui/assets/styles/main.scss similarity index 100% rename from packages/pinakes-ui/assets/styles/main.scss rename to crates/pinakes-ui/assets/styles/main.scss diff --git a/packages/pinakes-ui/src/app.rs b/crates/pinakes-ui/src/app.rs similarity index 100% rename from packages/pinakes-ui/src/app.rs rename to crates/pinakes-ui/src/app.rs diff --git a/packages/pinakes-ui/src/client.rs b/crates/pinakes-ui/src/client.rs similarity index 100% rename from packages/pinakes-ui/src/client.rs rename to crates/pinakes-ui/src/client.rs diff --git a/packages/pinakes-ui/src/components/audit.rs b/crates/pinakes-ui/src/components/audit.rs similarity index 100% rename from packages/pinakes-ui/src/components/audit.rs rename to crates/pinakes-ui/src/components/audit.rs diff --git a/packages/pinakes-ui/src/components/backlinks_panel.rs b/crates/pinakes-ui/src/components/backlinks_panel.rs similarity index 100% rename from packages/pinakes-ui/src/components/backlinks_panel.rs rename to crates/pinakes-ui/src/components/backlinks_panel.rs diff --git a/packages/pinakes-ui/src/components/books.rs b/crates/pinakes-ui/src/components/books.rs similarity index 100% rename from packages/pinakes-ui/src/components/books.rs rename to crates/pinakes-ui/src/components/books.rs diff --git a/packages/pinakes-ui/src/components/breadcrumb.rs b/crates/pinakes-ui/src/components/breadcrumb.rs similarity index 100% rename from packages/pinakes-ui/src/components/breadcrumb.rs rename to crates/pinakes-ui/src/components/breadcrumb.rs diff --git a/packages/pinakes-ui/src/components/collections.rs b/crates/pinakes-ui/src/components/collections.rs similarity index 100% rename from packages/pinakes-ui/src/components/collections.rs rename to crates/pinakes-ui/src/components/collections.rs diff --git a/packages/pinakes-ui/src/components/database.rs b/crates/pinakes-ui/src/components/database.rs similarity index 100% rename from packages/pinakes-ui/src/components/database.rs rename to crates/pinakes-ui/src/components/database.rs diff --git a/packages/pinakes-ui/src/components/detail.rs b/crates/pinakes-ui/src/components/detail.rs similarity index 100% rename from packages/pinakes-ui/src/components/detail.rs rename to crates/pinakes-ui/src/components/detail.rs diff --git a/packages/pinakes-ui/src/components/duplicates.rs b/crates/pinakes-ui/src/components/duplicates.rs similarity index 100% rename from packages/pinakes-ui/src/components/duplicates.rs rename to crates/pinakes-ui/src/components/duplicates.rs diff --git a/packages/pinakes-ui/src/components/graph_view.rs b/crates/pinakes-ui/src/components/graph_view.rs similarity index 100% rename from packages/pinakes-ui/src/components/graph_view.rs rename to crates/pinakes-ui/src/components/graph_view.rs diff --git a/packages/pinakes-ui/src/components/image_viewer.rs b/crates/pinakes-ui/src/components/image_viewer.rs similarity index 100% rename from packages/pinakes-ui/src/components/image_viewer.rs rename to crates/pinakes-ui/src/components/image_viewer.rs diff --git a/packages/pinakes-ui/src/components/import.rs b/crates/pinakes-ui/src/components/import.rs similarity index 100% rename from packages/pinakes-ui/src/components/import.rs rename to crates/pinakes-ui/src/components/import.rs diff --git a/packages/pinakes-ui/src/components/library.rs b/crates/pinakes-ui/src/components/library.rs similarity index 100% rename from packages/pinakes-ui/src/components/library.rs rename to crates/pinakes-ui/src/components/library.rs diff --git a/packages/pinakes-ui/src/components/loading.rs b/crates/pinakes-ui/src/components/loading.rs similarity index 100% rename from packages/pinakes-ui/src/components/loading.rs rename to crates/pinakes-ui/src/components/loading.rs diff --git a/packages/pinakes-ui/src/components/login.rs b/crates/pinakes-ui/src/components/login.rs similarity index 100% rename from packages/pinakes-ui/src/components/login.rs rename to crates/pinakes-ui/src/components/login.rs diff --git a/packages/pinakes-ui/src/components/markdown_viewer.rs b/crates/pinakes-ui/src/components/markdown_viewer.rs similarity index 100% rename from packages/pinakes-ui/src/components/markdown_viewer.rs rename to crates/pinakes-ui/src/components/markdown_viewer.rs diff --git a/packages/pinakes-ui/src/components/media_player.rs b/crates/pinakes-ui/src/components/media_player.rs similarity index 100% rename from packages/pinakes-ui/src/components/media_player.rs rename to crates/pinakes-ui/src/components/media_player.rs diff --git a/packages/pinakes-ui/src/components/mod.rs b/crates/pinakes-ui/src/components/mod.rs similarity index 100% rename from packages/pinakes-ui/src/components/mod.rs rename to crates/pinakes-ui/src/components/mod.rs diff --git a/packages/pinakes-ui/src/components/pagination.rs b/crates/pinakes-ui/src/components/pagination.rs similarity index 100% rename from packages/pinakes-ui/src/components/pagination.rs rename to crates/pinakes-ui/src/components/pagination.rs diff --git a/packages/pinakes-ui/src/components/pdf_viewer.rs b/crates/pinakes-ui/src/components/pdf_viewer.rs similarity index 100% rename from packages/pinakes-ui/src/components/pdf_viewer.rs rename to crates/pinakes-ui/src/components/pdf_viewer.rs diff --git a/packages/pinakes-ui/src/components/playlists.rs b/crates/pinakes-ui/src/components/playlists.rs similarity index 100% rename from packages/pinakes-ui/src/components/playlists.rs rename to crates/pinakes-ui/src/components/playlists.rs diff --git a/packages/pinakes-ui/src/components/search.rs b/crates/pinakes-ui/src/components/search.rs similarity index 100% rename from packages/pinakes-ui/src/components/search.rs rename to crates/pinakes-ui/src/components/search.rs diff --git a/packages/pinakes-ui/src/components/settings.rs b/crates/pinakes-ui/src/components/settings.rs similarity index 100% rename from packages/pinakes-ui/src/components/settings.rs rename to crates/pinakes-ui/src/components/settings.rs diff --git a/packages/pinakes-ui/src/components/statistics.rs b/crates/pinakes-ui/src/components/statistics.rs similarity index 100% rename from packages/pinakes-ui/src/components/statistics.rs rename to crates/pinakes-ui/src/components/statistics.rs diff --git a/packages/pinakes-ui/src/components/tags.rs b/crates/pinakes-ui/src/components/tags.rs similarity index 100% rename from packages/pinakes-ui/src/components/tags.rs rename to crates/pinakes-ui/src/components/tags.rs diff --git a/packages/pinakes-ui/src/components/tasks.rs b/crates/pinakes-ui/src/components/tasks.rs similarity index 100% rename from packages/pinakes-ui/src/components/tasks.rs rename to crates/pinakes-ui/src/components/tasks.rs diff --git a/packages/pinakes-ui/src/components/utils.rs b/crates/pinakes-ui/src/components/utils.rs similarity index 100% rename from packages/pinakes-ui/src/components/utils.rs rename to crates/pinakes-ui/src/components/utils.rs diff --git a/packages/pinakes-ui/src/main.rs b/crates/pinakes-ui/src/main.rs similarity index 100% rename from packages/pinakes-ui/src/main.rs rename to crates/pinakes-ui/src/main.rs diff --git a/packages/pinakes-ui/src/plugin_ui/actions.rs b/crates/pinakes-ui/src/plugin_ui/actions.rs similarity index 100% rename from packages/pinakes-ui/src/plugin_ui/actions.rs rename to crates/pinakes-ui/src/plugin_ui/actions.rs diff --git a/packages/pinakes-ui/src/plugin_ui/data.rs b/crates/pinakes-ui/src/plugin_ui/data.rs similarity index 100% rename from packages/pinakes-ui/src/plugin_ui/data.rs rename to crates/pinakes-ui/src/plugin_ui/data.rs diff --git a/packages/pinakes-ui/src/plugin_ui/expr.rs b/crates/pinakes-ui/src/plugin_ui/expr.rs similarity index 100% rename from packages/pinakes-ui/src/plugin_ui/expr.rs rename to crates/pinakes-ui/src/plugin_ui/expr.rs diff --git a/packages/pinakes-ui/src/plugin_ui/mod.rs b/crates/pinakes-ui/src/plugin_ui/mod.rs similarity index 100% rename from packages/pinakes-ui/src/plugin_ui/mod.rs rename to crates/pinakes-ui/src/plugin_ui/mod.rs diff --git a/packages/pinakes-ui/src/plugin_ui/registry.rs b/crates/pinakes-ui/src/plugin_ui/registry.rs similarity index 100% rename from packages/pinakes-ui/src/plugin_ui/registry.rs rename to crates/pinakes-ui/src/plugin_ui/registry.rs diff --git a/packages/pinakes-ui/src/plugin_ui/renderer.rs b/crates/pinakes-ui/src/plugin_ui/renderer.rs similarity index 100% rename from packages/pinakes-ui/src/plugin_ui/renderer.rs rename to crates/pinakes-ui/src/plugin_ui/renderer.rs diff --git a/packages/pinakes-ui/src/plugin_ui/widget.rs b/crates/pinakes-ui/src/plugin_ui/widget.rs similarity index 100% rename from packages/pinakes-ui/src/plugin_ui/widget.rs rename to crates/pinakes-ui/src/plugin_ui/widget.rs diff --git a/packages/pinakes-ui/src/state.rs b/crates/pinakes-ui/src/state.rs similarity index 100% rename from packages/pinakes-ui/src/state.rs rename to crates/pinakes-ui/src/state.rs diff --git a/packages/pinakes-ui/src/styles.rs b/crates/pinakes-ui/src/styles.rs similarity index 100% rename from packages/pinakes-ui/src/styles.rs rename to crates/pinakes-ui/src/styles.rs diff --git a/docs/HACKING.md b/docs/HACKING.md deleted file mode 100644 index 28eb21c..0000000 --- a/docs/HACKING.md +++ /dev/null @@ -1,276 +0,0 @@ -# Hacking Pinakes - -Pinakes is a lot of things. It also _plans_ to be a lot of things. One of those -things, as you might have noticed, is _complete_. To be complete in features, to -be complete in documentation and to be complete in hackability. This document -covers, very comprehensively, how you may: - -- Develop Pinakes -- Build Pinakes -- Contribute to Pinakes - -for developers as well as: - -- Distribute Pinakes - -for Pinakes maintainers and packagers. - -## Development Environment - -[Nix]: https://nixos.org -[Direnv]: https://direnv.net - -Pinakes uses [Nix] with flakes for pure, reproducible developer environments. -All of the build dependencies that you need, from Rust to GTK and webkit, are -provided in the devshell. In addition to _not_ requiring system libraries, we'd -like to _actively discourage you_ from relying on system libraries. - -[Direnv] may be preferred by some users as an alternative to `nix develop`. - -```bash -# Enter the dev shell -$ nix develop - -# Or with direnv (recommended) -$ direnv allow -``` - -Nix is the only supported way to get all dependencies. Distro package managers -_may_ work for the core crates but are not supported for the UI. - -## Building Pinakes - -Pinakes is a Cargo workspace with multiple crates targeting multiple aspects of -the projects. The user-facing crates are provided in `packages/*`. -`pinakes-server` and `pinakes-tui` can be built normally with `cargo` while the -UI crate, i.e., `pinakes-ui` **must** be built with the Dioxus CLI (`dx`) so -that the SCSS stylesheets are compiled correctly. Everything else should work -perfectly fine with the standard `cargo build`. - -[Just]: https://just.systems - -To make the things a _little_ bit more convenient, this project uses [Just] as -its command runner and provides a few recipes for common tasks. - -```bash -# Build all crates (server + TUI + UI) -$ just build-all - -# Build individual components -$ just build-server # pinakes-server (HTTP API) -$ just build-tui # pinakes-tui (terminal UI) -$ just build-ui # pinakes-ui (Dioxus desktop/web UI, requires dx) -``` - -The dependencies required for the recipes, including Just itself, is provided by -the Nix devshell. You may also run the cargo equivalents of those commands: - -```bash -# Core crates -$ cargo build -p pinakes-server -$ cargo build -p pinakes-tui - -# The UI crate must be built with dx, not cargo. -$ dx build -p pinakes-ui -``` - -Release builds follow the same pattern, but with `--release`. Nothing else -should be required for the most part. - -## Running Pinakes - -For the time being, UI clients require the server to be running before they can -function. Offline sync is planned, but likely come later alongside cross-device -sync. All UIs connect to the server over HTTP. - -```bash -# 1. Copy and edit the example config (first time only) -$ cp pinakes.example.toml pinakes.toml - -# 2. Start the server (defaults to 127.0.0.1:3000) -$ cargo run -p pinakes-server -- pinakes.toml - -# 3a. Run the TUI client -$ cargo run -p pinakes-tui - -# 3b. Run the desktop/web UI -$ cargo run -p pinakes-ui - -# Connect TUI to a non-default server address -$ cargo run -p pinakes-tui -- --server http://localhost:3000 -``` - -The GUI reads `PINAKES_SERVER_URL` if set; otherwise it assumes -`http://127.0.0.1:3000`. You may change the host and port if you require. - -## Testing - -[cargo-nextest]: https://nexte.st - -Pinakes boasts, or well, attempts to boast a very comprehensive testing suite to -ensure no regressions are introduced while in development. For fast parallel -test execution, we use [cargo-nextest] and while you are recommended to use it -over `cargo test` both should be supported. For CI and package tests, only -`cargo-nextest` is supported. - -```bash -# Run all workspace tests (preferred) -$ just test - -# Equivalent without Just -$ cargo nextest run --workspace - -# Tests for a single crate -$ cargo nextest run -p pinakes-core -$ cargo nextest run -p pinakes-server - -# Run a specific test by name and show output -$ cargo test -p pinakes-core -- test_name --nocapture -``` - -## Linting and Formatting - -### Formatting - -There is a treewide formatter provided within the `flake.nix` that invokes the -recommended formatter tooling across the various filetypes, including database -migrations and Markdown sources. While a `just fmt` recipe is provided, it will -not cover most of the files. Still, you may format the Rust sources as such: - -```bash -# Format all code -$ just fmt # or: cargo fmt - -# Check formatting without modifying files -$ cargo fmt --check -``` - -### Linting - -For now lints only cover Rust sources, and are provided by the Clippy tool. You -may, as usual, invoke it with `just lint`. - -```bash -# Run Clippy -$ just lint # or: cargo clippy --workspace - -# Treat Clippy warnings as errors (used in CI) -$ cargo clippy --workspace -- -D warnings # `-D warnings` is recommended -``` - -All Clippy warnings, besides some of the really annoying ones or false -positives, must be resolved at the source. **Do not** use `#[allow(...)]` or -`#[expect(...)]` to silence warnings unless there is a documented reason. - -It may be okay, at times, to suppress lints but you should focus on _resolving_ -them rather than suppressing them. _If_ suppressing, prefer `#[expect]` and -provide the `reason` parameter. - -## Generating API Documentation - -The REST API documentation is generated from OpenAPI annotations in -`pinakes-server` using `cargo xtask`: - -```bash -# Generate the API documentation at docs/api/ -$ just docs # or: cargo xtask docs -``` - -This writes: - -- `docs/api.md` - Index linking all generated files -- `docs/api/.md` - One Markdown file per API tag -- `docs/api/openapi.json` - Full OpenAPI 3.0 specification - -Please do not edit files under `docs/api/` by hand; they are regenerated on each -run. - -## Code Style - -There are many conventions that are in play within the Pinakes codebase. You'll -get used and adjust to most of them naturally, but here are some of the general -conventions written down for convenience. - -### General - -1. **Make illegal states unrepresentable**; ıse ADTs and newtype wrappers to - encode constraints in the type system. Parse and validate at system - boundaries (user input, external APIs); trust internal types everywhere else. -2. This one is kind of obvious, but please use the `tracing` crate with - structured fields. Do not use `println!` or `eprintln!` in library or server - code. -3. All storage operations must be implemented for both the SQLite and PostgreSQL - backends. Neither backend is optional. - -Naming is kind of obvious, but I'd like to remind you that we use `snake_case` -for functions and variables, `PascalCase` for types and traits, -`SCREAMING_SNAKE_CASE` for constants. There is not much else to it. - -### Error Handling - -- **`unwrap()` and `expect()` are banned** in non-test code. Use `?`, - `ok_or_else`, `match`, or `if let` instead. - - The only accepted exception is when a failure is truly impossible (e.g., a - hardcoded regex that is known-valid at compile time). In that case, add - `#[expect(clippy::expect_used, reason = "...")]` with a clear explanation. -- Wrap storage errors with the `db_ctx` helper so error messages include the - operation and relevant ID: - - ```rust - stmt.execute(params).map_err(db_ctx("insert_media", &media_id))?; - ``` - -### Disallowed Types and APIs - -Pinakes uses hashers from `rustc_hash`, i.e., `FxHashMap` and `FxHashSet` over -the `std` equivalents due to their faster nature without random state. This is -enforced by clippy. - -As Pinakes uses Rust 1.90+, the now-deprecated `once_cell` crate is also banned. -Use the stdlib equivalents: - -- `once_cell::unsync::OnceCell` -> `std::cell::OnceCell` -- `once_cell::sync::OnceCell` -> `std::sync::OnceLock` -- `once_cell::unsync::Lazy` -> `std::cell::LazyCell` -- `once_cell::sync::Lazy` -> `std::sync::LazyLock` - -## Contributing - -1. Fork the repository and create a feature branch. -2. Enter the dev shell: `nix develop` (or `direnv allow`). -3. Make your changes; keep commits focused and descriptive. We use **scoped - commits**. -4. Run `just fmt` and `just lint` before opening a pull request. -5. Run `just test` to verify nothing is broken. -6. Open a pull request against `main`. Describe what you changed and why. - -For significant changes, consider opening an issue first to discuss the -approach. - -## Packaging and Distribution - -Pinakes is released under EUPL v1.2. For distributable release builds, please -respect the license. You may create release builds for the user-facing crates -using `cargo build --release` for server and TUI components. Use `dx` instead -for the GUI: - -```bash -# Server -$ cargo build --release -p pinakes-server - -# TUI -$ cargo build --release -p pinakes-tui - -# GUI -$ dx build --release -p pinakes-ui -``` - -The resulting binaries are self-contained except for system libraries required -by the UI (GTK3, libsoup, webkit2gtk). Database migrations run automatically on -first server startup via the `refinery` crate; no manual migration step is -needed. - -SQLite (the default backend) requires no external database server. PostgreSQL -deployments need the `pg_trgm` extension enabled on the target database. It is -generally advisable to document those requirements for the users, even though -they do not require additional packaging steps. diff --git a/docs/README.md b/docs/README.md index d1aa4f2..69145fe 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,41 +11,19 @@ PostgreSQL (production deployments) as available database backends. ## Building -This project uses [Just](https://just.systems/) as its command runner to help -run cargo with a consistent interface. You are recommended to get it with Nix, -using the default devshell which provides Just. - ```bash -# Build everything (core crates + UI) -$ just build - -# Build only core crates (cargo) -$ just build-core - -# Build only the UI (uses dx - see note below) -$ just build-ui -``` - -> [!IMPORTANT] -> The Dioxus UI (`pinakes-ui`) must be built with `dx` (Dioxus CLI) to compile -> SCSS stylesheets. Using `cargo build -p pinakes-ui` will not work correctly as -> it skips the SCSS compilation step. This was previously "remedied" with an -> intermediate build wrapper, which turned out to be fragile. It is highly -> recommended that you prefer `just` to build. - -Manual build commands if not using Just: - -```bash -# Core crates (standard cargo) +# Build all compilable crates $ cargo build -p pinakes-core -p pinakes-server -p pinakes-tui -# UI (requires Dioxus CLI for SCSS compilation) -$ dx build -p pinakes-ui - -# System dependencies for the UI: +# The Dioxus UI requires GTK3 and libsoup system libraries: # On Debian/Ubuntu: apt install libgtk-3-dev libsoup-3.0-dev libwebkit2gtk-4.1-dev # On Fedora: dnf install gtk3-devel libsoup3-devel webkit2gtk4.1-devel # On Nix: Use the dev shell, everything is provided :) +$ cargo build -p pinakes-ui + +# Alternatively, while app deps are in PATH, you may simply build the entire +# workspace. +$ cargo build --workspace ``` ## Configuration @@ -75,20 +53,17 @@ Key settings: ## Running -All commands are available via Just. Run `just --list` to see all available -recipes. - ### Server To use Pinakes, you will need the server to be running. The GUI on its own will work, but it will not be functional without the server. -```bash -# Using Just -$ just run-server - -# Or manually: +```sh +# Start the server first $ cargo run -p pinakes-server -- pinakes.toml + +# or: +$ cargo run -p pinakes-server -- --config pinakes.toml ``` The server starts on the configured host:port (default `127.0.0.1:3000`). In a @@ -102,11 +77,11 @@ terminal. While the server is running you may connect to it using the `--server` flag. ```bash -# Using Just -$ just run-tui - -# Or manually: +# Using defaults $ cargo run -p pinakes-tui + +# or with a custom server URL: +$ cargo run -p pinakes-tui -- --server http://localhost:3000 ``` #### Keybindings @@ -145,15 +120,9 @@ Pinakes features a fully fledged Desktop and Web UI powered by Dioxus. Those two components are meant as a GUI frontend for the Pinakes server, and are interchangeable in terms of usage. -> [!IMPORTANT] -> The UI must be run with `dx` (Dioxus CLI), not `cargo run`. - ```bash -# Using Just -$ just run-ui - -# Or manually with dx: -$ dx serve -p pinakes-ui +# Build the UI +$ cargo run -p pinakes-ui ``` > [!TIP] @@ -201,11 +170,8 @@ and design. ## Storage Backends Two storage backends are supported. For convenience, SQLite is the default -backend out of the box but for production deployments, with improved search and -scaling capabilities you may choose to prefer PostgreSQL. Both backends are -considered first-class citizens, and will be developed as such going further. If -your needs for Pinakes are modest, or if you are simply testing it out, SQLite -is the recommended database. +backend out of the box but for production deployments you may choose to prefer +PostgreSQL. ### **SQLite** (default) @@ -214,34 +180,6 @@ guarantees FTS5 availability. ### **PostgreSQL** -[pg_trgm]: https://www.postgresql.org/docs/current/pgtrgm.html - Native async with connection pooling (deadpool-postgres). Uses tsvector with -weighted columns for full-text search and [pg_trgm] for fuzzy matching. Requires +weighted columns for full-text search and pg_trgm for fuzzy matching. Requires the `pg_trgm` extension. - -## Contributing - -[HACKING document]: ./HACKING.md - -Pinakes, despite all the work going into it, is still in an early beta. Some of -the features still lack the polish they deserve and there _may_ be breaking -changes to the UI, databases, and the APIs. You may find a comprehensive -introduction to developing Pinakes in the [HACKING document]. - -It is generally advisable that you familiarize yourself with the codebase before -proposing or contributing changes. Pinakes consists of _many_ moving parts, and -it is better for the contributor experience to approach issues slowly and -discuss them beforehand. - -## License - - - -[here]: https://interoperable-europe.ec.europa.eu/sites/default/files/custom-page/attachment/eupl_v1.2_en.pdf - -This project is made available under European Union Public Licence (EUPL) -version 1.2. See [LICENSE](LICENSE) for more details on the exact conditions. An -online copy is provided [here]. - - diff --git a/docs/api.md b/docs/api.md deleted file mode 100644 index dbc8c8d..0000000 --- a/docs/api.md +++ /dev/null @@ -1,47 +0,0 @@ -# API Documentation - -This is the index of all generated REST API documentation for Pinakes. - -Documentation is generated from OpenAPI annotations via `cargo xtask docs` (or -`just docs`). Do not edit generated files by hand. - -## Reference - -- [openapi.json](api/openapi.json) - Full OpenAPI 3.0 specification - -## Endpoints by Tag - -- [Analytics](api/analytics.md) - Usage analytics and viewing history -- [Audit](api/audit.md) - Audit log entries -- [Auth](api/auth.md) - Authentication and session management -- [Backup](api/backup.md) - Database backup -- [Books](api/books.md) - Book metadata, series, authors, and reading progress -- [Collections](api/collections.md) - Media collections -- [Config](api/config.md) - Server configuration -- [Database](api/database.md) - Database administration -- [Duplicates](api/duplicates.md) - Duplicate media detection -- [Enrichment](api/enrichment.md) - External metadata enrichment -- [Export](api/export.md) - Media library export -- [Health](api/health.md) - Server health checks -- [Integrity](api/integrity.md) - Library integrity checks and repairs -- [Jobs](api/jobs.md) - Background job management -- [Media](api/media.md) - Media item management -- [Notes](api/notes.md) - Markdown notes link graph -- [Photos](api/photos.md) - Photo timeline and map view -- [Playlists](api/playlists.md) - Media playlists -- [Plugins](api/plugins.md) - Plugin management -- [Saved_searches](api/saved_searches.md) - Saved search queries -- [Scan](api/scan.md) - Directory scanning -- [Scheduled_tasks](api/scheduled_tasks.md) - Scheduled background tasks -- [Search](api/search.md) - Full-text media search -- [Shares](api/shares.md) - Media sharing and notifications -- [Social](api/social.md) - Ratings, comments, favorites, and share links -- [Statistics](api/statistics.md) - Library statistics -- [Streaming](api/streaming.md) - HLS and DASH adaptive streaming -- [Subtitles](api/subtitles.md) - Media subtitle management -- [Sync](api/sync.md) - Multi-device library synchronization -- [Tags](api/tags.md) - Media tag management -- [Transcode](api/transcode.md) - Video transcoding sessions -- [Upload](api/upload.md) - File upload and managed storage -- [Users](api/users.md) - User and library access management -- [Webhooks](api/webhooks.md) - Webhook configuration diff --git a/docs/api/analytics.md b/docs/api/analytics.md index 086e07e..f706d2f 100644 --- a/docs/api/analytics.md +++ b/docs/api/analytics.md @@ -16,11 +16,11 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Event recorded | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Event recorded | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -30,18 +30,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| -------- | ----- | -------- | ------------------------- | -| `limit` | query | No | Maximum number of results | -| `offset` | query | No | Pagination offset | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `limit` | query | No | Maximum number of results | +| `offset` | query | No | Pagination offset | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Most viewed media | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Most viewed media | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -51,18 +51,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| -------- | ----- | -------- | ------------------------- | -| `limit` | query | No | Maximum number of results | -| `offset` | query | No | Pagination offset | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `limit` | query | No | Maximum number of results | +| `offset` | query | No | Pagination offset | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Recently viewed media | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Recently viewed media | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -72,18 +72,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Watch progress | -| 401 | Unauthorized | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Watch progress | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | --- @@ -93,9 +93,9 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | #### Request Body @@ -105,12 +105,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Progress updated | -| 400 | Bad request | -| 401 | Unauthorized | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Progress updated | +| 400 | Bad request | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | --- + diff --git a/docs/api/audit.md b/docs/api/audit.md index 5385ca8..adcf493 100644 --- a/docs/api/audit.md +++ b/docs/api/audit.md @@ -10,17 +10,18 @@ Audit log entries #### Parameters -| Name | In | Required | Description | -| -------- | ----- | -------- | ----------------- | -| `offset` | query | No | Pagination offset | -| `limit` | query | No | Page size | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `offset` | query | No | Pagination offset | +| `limit` | query | No | Page size | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Audit log entries | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Audit log entries | +| 401 | Unauthorized | +| 500 | Internal server error | --- + diff --git a/docs/api/auth.md b/docs/api/auth.md index edbd3bd..f62c099 100644 --- a/docs/api/auth.md +++ b/docs/api/auth.md @@ -16,12 +16,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Login successful | -| 400 | Bad request | -| 401 | Invalid credentials | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Login successful | +| 400 | Bad request | +| 401 | Invalid credentials | +| 500 | Internal server error | --- @@ -31,11 +31,11 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Logged out | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Logged out | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -45,27 +45,28 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Current user info | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Current user info | +| 401 | Unauthorized | +| 500 | Internal server error | --- ### POST /api/v1/auth/refresh -Refresh the current session, extending its expiry by the configured duration. +Refresh the current session, extending its expiry by the configured +duration. **Authentication:** Required (Bearer JWT) #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Session refreshed | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Session refreshed | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -77,11 +78,11 @@ Revoke all sessions for the current user #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | All sessions revoked | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | All sessions revoked | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -91,11 +92,12 @@ Revoke all sessions for the current user #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Active sessions | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Active sessions | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- + diff --git a/docs/api/backup.md b/docs/api/backup.md index 86a184f..e7c7884 100644 --- a/docs/api/backup.md +++ b/docs/api/backup.md @@ -6,21 +6,22 @@ Database backup ### POST /api/v1/admin/backup -Create a database backup and return it as a downloadable file. POST -/api/v1/admin/backup +Create a database backup and return it as a downloadable file. +POST /api/v1/admin/backup -For `SQLite`: creates a backup via VACUUM INTO and returns the file. For -`PostgreSQL`: returns unsupported error (use `pg_dump` instead). +For `SQLite`: creates a backup via VACUUM INTO and returns the file. +For `PostgreSQL`: returns unsupported error (use `pg_dump` instead). **Authentication:** Required (Bearer JWT) #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Backup file download | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Backup file download | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- + diff --git a/docs/api/books.md b/docs/api/books.md index 1ab3162..6af55ad 100644 --- a/docs/api/books.md +++ b/docs/api/books.md @@ -12,23 +12,23 @@ List all books with optional search filters #### Parameters -| Name | In | Required | Description | -| ----------- | ----- | -------- | ------------------- | -| `isbn` | query | No | Filter by ISBN | -| `author` | query | No | Filter by author | -| `series` | query | No | Filter by series | -| `publisher` | query | No | Filter by publisher | -| `language` | query | No | Filter by language | -| `offset` | query | No | Pagination offset | -| `limit` | query | No | Pagination limit | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `isbn` | query | No | Filter by ISBN | +| `author` | query | No | Filter by author | +| `series` | query | No | Filter by series | +| `publisher` | query | No | Filter by publisher | +| `language` | query | No | Filter by language | +| `offset` | query | No | Pagination offset | +| `limit` | query | No | Pagination limit | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | List of books | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | List of books | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -40,17 +40,17 @@ List all authors with book counts #### Parameters -| Name | In | Required | Description | -| -------- | ----- | -------- | ----------------- | -| `offset` | query | No | Pagination offset | -| `limit` | query | No | Pagination limit | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `offset` | query | No | Pagination offset | +| `limit` | query | No | Pagination limit | #### Responses -| Status | Description | -| ------ | ------------------------ | -| 200 | Authors with book counts | -| 401 | Unauthorized | +| Status | Description | +|--------|-------------| +| 200 | Authors with book counts | +| 401 | Unauthorized | --- @@ -62,18 +62,18 @@ Get books by a specific author #### Parameters -| Name | In | Required | Description | -| -------- | ----- | -------- | ----------------- | -| `name` | path | Yes | Author name | -| `offset` | query | No | Pagination offset | -| `limit` | query | No | Pagination limit | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `name` | path | Yes | Author name | +| `offset` | query | No | Pagination offset | +| `limit` | query | No | Pagination limit | #### Responses -| Status | Description | -| ------ | --------------- | -| 200 | Books by author | -| 401 | Unauthorized | +| Status | Description | +|--------|-------------| +| 200 | Books by author | +| 401 | Unauthorized | --- @@ -85,16 +85,16 @@ Get user's reading list #### Parameters -| Name | In | Required | Description | -| -------- | ----- | -------- | ------------------------------------------------------------------------------ | -| `status` | query | No | Filter by reading status. Valid values: to_read, reading, completed, abandoned | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `status` | query | No | Filter by reading status | #### Responses -| Status | Description | -| ------ | ------------ | -| 200 | Reading list | -| 401 | Unauthorized | +| Status | Description | +|--------|-------------| +| 200 | Reading list | +| 401 | Unauthorized | --- @@ -106,10 +106,10 @@ List all series with book counts #### Responses -| Status | Description | -| ------ | -------------------------- | -| 200 | List of series with counts | -| 401 | Unauthorized | +| Status | Description | +|--------|-------------| +| 200 | List of series with counts | +| 401 | Unauthorized | --- @@ -121,16 +121,16 @@ Get books in a specific series #### Parameters -| Name | In | Required | Description | -| ------ | ---- | -------- | ----------- | -| `name` | path | Yes | Series name | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `name` | path | Yes | Series name | #### Responses -| Status | Description | -| ------ | --------------- | -| 200 | Books in series | -| 401 | Unauthorized | +| Status | Description | +|--------|-------------| +| 200 | Books in series | +| 401 | Unauthorized | --- @@ -142,17 +142,17 @@ Get book metadata by media ID #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -| ------ | ------------- | -| 200 | Book metadata | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Book metadata | +| 401 | Unauthorized | +| 404 | Not found | --- @@ -164,17 +164,17 @@ Get reading progress for a book #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -| ------ | ---------------- | -| 200 | Reading progress | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Reading progress | +| 401 | Unauthorized | +| 404 | Not found | --- @@ -186,9 +186,9 @@ Update reading progress for a book #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | #### Request Body @@ -198,10 +198,11 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | ---------------- | -| 204 | Progress updated | -| 400 | Bad request | -| 401 | Unauthorized | +| Status | Description | +|--------|-------------| +| 204 | Progress updated | +| 400 | Bad request | +| 401 | Unauthorized | --- + diff --git a/docs/api/collections.md b/docs/api/collections.md index 9db76ba..f86b5e0 100644 --- a/docs/api/collections.md +++ b/docs/api/collections.md @@ -10,11 +10,11 @@ Media collections #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | List of collections | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | List of collections | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -30,13 +30,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Collection created | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Collection created | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -46,18 +46,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Collection ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Collection ID | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Collection | -| 401 | Unauthorized | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Collection | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | --- @@ -67,19 +67,19 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Collection ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Collection ID | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Collection deleted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Collection deleted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | --- @@ -89,18 +89,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Collection ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Collection ID | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Collection members | -| 401 | Unauthorized | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Collection members | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | --- @@ -110,9 +110,9 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Collection ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Collection ID | #### Request Body @@ -122,13 +122,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Member added | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Member added | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | --- @@ -138,19 +138,20 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---------- | ---- | -------- | ------------- | -| `id` | path | Yes | Collection ID | -| `media_id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Collection ID | +| `media_id` | path | Yes | Media item ID | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Member removed | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Member removed | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | --- + diff --git a/docs/api/config.md b/docs/api/config.md index a05fcd0..f88299f 100644 --- a/docs/api/config.md +++ b/docs/api/config.md @@ -10,12 +10,12 @@ Server configuration #### Responses -| Status | Description | -| ------ | ---------------------------- | -| 200 | Current server configuration | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Current server configuration | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -31,13 +31,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Updated configuration | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Updated configuration | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -53,12 +53,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Updated configuration | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Updated configuration | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -74,12 +74,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Updated configuration | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Updated configuration | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -89,11 +89,11 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | UI configuration | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | UI configuration | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -109,11 +109,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | ------------------------ | -| 200 | Updated UI configuration | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Updated UI configuration | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- + diff --git a/docs/api/database.md b/docs/api/database.md index bed7ff6..373df31 100644 --- a/docs/api/database.md +++ b/docs/api/database.md @@ -10,12 +10,12 @@ Database administration #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Database cleared | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Database cleared | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -25,12 +25,12 @@ Database administration #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Database statistics | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Database statistics | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -40,11 +40,12 @@ Database administration #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Database vacuumed | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Database vacuumed | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- + diff --git a/docs/api/duplicates.md b/docs/api/duplicates.md index 63a200d..b1005b7 100644 --- a/docs/api/duplicates.md +++ b/docs/api/duplicates.md @@ -10,10 +10,11 @@ Duplicate media detection #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Duplicate groups | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Duplicate groups | +| 401 | Unauthorized | +| 500 | Internal server error | --- + diff --git a/docs/api/enrichment.md b/docs/api/enrichment.md index f4ec48e..5012641 100644 --- a/docs/api/enrichment.md +++ b/docs/api/enrichment.md @@ -16,13 +16,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | ------------------------ | -| 200 | Enrichment job submitted | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Enrichment job submitted | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -32,19 +32,19 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -| ------ | ------------------------ | -| 200 | Enrichment job submitted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Enrichment job submitted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | --- @@ -54,17 +54,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | External metadata | -| 401 | Unauthorized | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | External metadata | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | --- + diff --git a/docs/api/export.md b/docs/api/export.md index 593c1aa..3410a9f 100644 --- a/docs/api/export.md +++ b/docs/api/export.md @@ -10,12 +10,12 @@ Media library export #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Export job submitted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Export job submitted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -31,11 +31,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Export job submitted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Export job submitted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- + diff --git a/docs/api/health.md b/docs/api/health.md index 3ed9cf6..44f6d4a 100644 --- a/docs/api/health.md +++ b/docs/api/health.md @@ -12,9 +12,9 @@ Comprehensive health check - includes database, filesystem, and cache status #### Responses -| Status | Description | -| ------ | ------------- | -| 200 | Health status | +| Status | Description | +|--------|-------------| +| 200 | Health status | --- @@ -24,39 +24,40 @@ Comprehensive health check - includes database, filesystem, and cache status #### Responses -| Status | Description | -| ------ | ---------------------- | -| 200 | Detailed health status | +| Status | Description | +|--------|-------------| +| 200 | Detailed health status | --- ### GET /api/v1/health/live -Liveness probe - just checks if the server is running Returns 200 OK if the -server process is alive +Liveness probe - just checks if the server is running +Returns 200 OK if the server process is alive **Authentication:** Required (Bearer JWT) #### Responses -| Status | Description | -| ------ | --------------- | -| 200 | Server is alive | +| Status | Description | +|--------|-------------| +| 200 | Server is alive | --- ### GET /api/v1/health/ready -Readiness probe - checks if the server can serve requests Returns 200 OK if -database is accessible +Readiness probe - checks if the server can serve requests +Returns 200 OK if database is accessible **Authentication:** Required (Bearer JWT) #### Responses -| Status | Description | -| ------ | ---------------- | -| 200 | Server is ready | -| 503 | Server not ready | +| Status | Description | +|--------|-------------| +| 200 | Server is ready | +| 503 | Server not ready | --- + diff --git a/docs/api/integrity.md b/docs/api/integrity.md index 5c2b4c8..fd22c23 100644 --- a/docs/api/integrity.md +++ b/docs/api/integrity.md @@ -10,12 +10,12 @@ Library integrity checks and repairs #### Responses -| Status | Description | -| ------ | ------------------------------ | -| 200 | Orphan detection job submitted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Orphan detection job submitted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -31,12 +31,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Orphans resolved | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Orphans resolved | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -46,12 +46,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | ------------------------------- | -| 200 | Thumbnail cleanup job submitted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Thumbnail cleanup job submitted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -67,12 +67,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | ---------------------------------- | -| 200 | Thumbnail generation job submitted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Thumbnail generation job submitted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -88,11 +88,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | ------------------------------------ | -| 200 | Integrity verification job submitted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Integrity verification job submitted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- + diff --git a/docs/api/jobs.md b/docs/api/jobs.md index 4cf6a2d..a46b7fd 100644 --- a/docs/api/jobs.md +++ b/docs/api/jobs.md @@ -10,11 +10,11 @@ Background job management #### Responses -| Status | Description | -| ------ | ------------ | -| 200 | List of jobs | -| 401 | Unauthorized | -| 403 | Forbidden | +| Status | Description | +|--------|-------------| +| 200 | List of jobs | +| 401 | Unauthorized | +| 403 | Forbidden | --- @@ -24,18 +24,18 @@ Background job management #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | Job ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Job ID | #### Responses -| Status | Description | -| ------ | ------------ | -| 200 | Job details | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Job details | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- @@ -45,17 +45,18 @@ Background job management #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | Job ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Job ID | #### Responses -| Status | Description | -| ------ | ------------- | -| 200 | Job cancelled | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Job cancelled | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- + diff --git a/docs/api/media.md b/docs/api/media.md index bdd1dd7..b612729 100644 --- a/docs/api/media.md +++ b/docs/api/media.md @@ -10,19 +10,19 @@ Media item management #### Parameters -| Name | In | Required | Description | -| -------- | ----- | -------- | ----------------- | -| `offset` | query | No | Pagination offset | -| `limit` | query | No | Page size | -| `sort` | query | No | Sort field | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `offset` | query | No | Pagination offset | +| `limit` | query | No | Page size | +| `sort` | query | No | Sort field | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | List of media items | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | List of media items | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -32,12 +32,12 @@ Media item management #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | All media deleted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | All media deleted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -53,13 +53,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | ----------------------- | -| 200 | Batch collection result | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Batch collection result | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -75,13 +75,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Batch delete result | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Batch delete result | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -97,13 +97,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Batch move result | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Batch move result | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -119,13 +119,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Batch tag result | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Batch tag result | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -141,13 +141,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Batch update result | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Batch update result | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -157,11 +157,11 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Media count | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Media count | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -177,13 +177,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Media imported | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Media imported | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -199,13 +199,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Batch import results | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Batch import results | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -221,13 +221,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | ------------------------ | -| 200 | Directory import results | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Directory import results | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -243,13 +243,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Media imported | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Media imported | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -265,13 +265,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Directory preview | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Directory preview | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -281,18 +281,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| -------- | ----- | -------- | ----------------- | -| `offset` | query | No | Pagination offset | -| `limit` | query | No | Page size | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `offset` | query | No | Pagination offset | +| `limit` | query | No | Page size | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Trashed media items | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Trashed media items | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -302,12 +302,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Trash emptied | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Trash emptied | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -317,11 +317,11 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Trash info | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Trash info | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -331,18 +331,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Media item | -| 401 | Unauthorized | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Media item | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | --- @@ -352,9 +352,9 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | #### Request Body @@ -364,14 +364,14 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Updated media item | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Updated media item | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | --- @@ -381,19 +381,19 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Media deleted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Media deleted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | --- @@ -403,9 +403,9 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | #### Request Body @@ -415,14 +415,14 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Custom field set | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Custom field set | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | --- @@ -432,20 +432,20 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ------ | ---- | -------- | ----------------- | -| `id` | path | Yes | Media item ID | -| `name` | path | Yes | Custom field name | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | +| `name` | path | Yes | Custom field name | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Custom field deleted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Custom field deleted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | --- @@ -455,9 +455,9 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | #### Request Body @@ -467,14 +467,14 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Moved media item | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Moved media item | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | --- @@ -484,18 +484,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Media opened | -| 401 | Unauthorized | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Media opened | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | --- @@ -505,20 +505,20 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ----------- | ----- | -------- | ------------------------------------ | -| `id` | path | Yes | Media item ID | -| `permanent` | query | No | Set to 'true' for permanent deletion | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | +| `permanent` | query | No | Set to 'true' for permanent deletion | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Media deleted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Media deleted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | --- @@ -528,9 +528,9 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | #### Request Body @@ -540,14 +540,14 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Renamed media item | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Renamed media item | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | --- @@ -557,19 +557,19 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Media restored | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Media restored | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | --- @@ -579,19 +579,19 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Media stream | -| 206 | Partial content | -| 401 | Unauthorized | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Media stream | +| 206 | Partial content | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | --- @@ -601,18 +601,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Thumbnail image | -| 401 | Unauthorized | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Thumbnail image | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | --- @@ -622,18 +622,19 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Media moved to trash | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Media moved to trash | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | --- + diff --git a/docs/api/notes.md b/docs/api/notes.md index 0ffc1fe..330c8f3 100644 --- a/docs/api/notes.md +++ b/docs/api/notes.md @@ -14,18 +14,18 @@ GET /api/v1/media/{id}/backlinks #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Backlinks | -| 401 | Unauthorized | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Backlinks | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | --- @@ -39,18 +39,18 @@ GET /api/v1/media/{id}/outgoing-links #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Outgoing links | -| 401 | Unauthorized | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Outgoing links | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | --- @@ -64,18 +64,18 @@ POST /api/v1/media/{id}/reindex-links #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Links reindexed | -| 401 | Unauthorized | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Links reindexed | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | --- @@ -89,18 +89,18 @@ GET /api/v1/notes/graph?center={uuid}&depth={n} #### Parameters -| Name | In | Required | Description | -| -------- | ----- | -------- | ---------------------------------- | -| `center` | query | No | Center node ID | -| `depth` | query | No | Traversal depth (max 5, default 2) | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `center` | query | No | Center node ID | +| `depth` | query | No | Traversal depth (max 5, default 2) | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Graph data | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Graph data | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -114,11 +114,11 @@ POST /api/v1/notes/resolve-links #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Links resolved | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Links resolved | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -132,10 +132,11 @@ GET /api/v1/notes/unresolved-count #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Unresolved link count | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Unresolved link count | +| 401 | Unauthorized | +| 500 | Internal server error | --- + diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 7e25e7c..3f86923 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -1275,7 +1275,7 @@ { "name": "status", "in": "query", - "description": "Filter by reading status. Valid values: to_read, reading, completed, abandoned", + "description": "Filter by reading status", "required": false, "schema": { "type": "string" @@ -4704,11 +4704,14 @@ ], "responses": { "200": { - "description": "Subtitles and available embedded tracks", + "description": "Subtitles", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SubtitleListResponse" + "type": "array", + "items": { + "$ref": "#/components/schemas/SubtitleResponse" + } } } } @@ -8526,7 +8529,6 @@ "integer", "null" ], - "format": "int32", "minimum": 0 } } @@ -11785,28 +11787,6 @@ ], "description": "Response for accessing shared content.\nSingle-media shares return the media object directly (backwards compatible).\nCollection/Tag/SavedSearch shares return a list of items." }, - "SubtitleListResponse": { - "type": "object", - "description": "Response for listing subtitles on a media item.", - "required": [ - "subtitles", - "available_tracks" - ], - "properties": { - "available_tracks": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SubtitleTrackInfoResponse" - } - }, - "subtitles": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SubtitleResponse" - } - } - } - }, "SubtitleResponse": { "type": "object", "required": [ @@ -11849,41 +11829,10 @@ "integer", "null" ], - "format": "int32", "minimum": 0 } } }, - "SubtitleTrackInfoResponse": { - "type": "object", - "description": "Information about an embedded subtitle track available for extraction.", - "required": [ - "index", - "format" - ], - "properties": { - "format": { - "type": "string" - }, - "index": { - "type": "integer", - "format": "int32", - "minimum": 0 - }, - "language": { - "type": [ - "string", - "null" - ] - }, - "title": { - "type": [ - "string", - "null" - ] - } - } - }, "SyncChangeResponse": { "type": "object", "required": [ diff --git a/docs/api/photos.md b/docs/api/photos.md index 3613838..5afdba3 100644 --- a/docs/api/photos.md +++ b/docs/api/photos.md @@ -12,21 +12,21 @@ Get photos in a bounding box for map view #### Parameters -| Name | In | Required | Description | -| ------ | ----- | -------- | ------------------------ | -| `lat1` | query | Yes | Bounding box latitude 1 | -| `lon1` | query | Yes | Bounding box longitude 1 | -| `lat2` | query | Yes | Bounding box latitude 2 | -| `lon2` | query | Yes | Bounding box longitude 2 | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `lat1` | query | Yes | Bounding box latitude 1 | +| `lon1` | query | Yes | Bounding box longitude 1 | +| `lat2` | query | Yes | Bounding box latitude 2 | +| `lon2` | query | Yes | Bounding box longitude 2 | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Map markers | -| 400 | Bad request | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Map markers | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -38,19 +38,20 @@ Get timeline of photos grouped by date #### Parameters -| Name | In | Required | Description | -| ---------- | ----- | -------- | -------------------------- | -| `group_by` | query | No | Grouping: day, month, year | -| `year` | query | No | Filter by year | -| `month` | query | No | Filter by month | -| `limit` | query | No | Max items (default 10000) | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `group_by` | query | No | Grouping: day, month, year | +| `year` | query | No | Filter by year | +| `month` | query | No | Filter by month | +| `limit` | query | No | Max items (default 10000) | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Photo timeline groups | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Photo timeline groups | +| 401 | Unauthorized | +| 500 | Internal server error | --- + diff --git a/docs/api/playlists.md b/docs/api/playlists.md index fdcbf35..2f97cde 100644 --- a/docs/api/playlists.md +++ b/docs/api/playlists.md @@ -10,11 +10,11 @@ Media playlists #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | List of playlists | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | List of playlists | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -30,12 +30,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Playlist created | -| 400 | Bad request | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Playlist created | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -45,18 +45,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | Playlist ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Playlist ID | #### Responses -| Status | Description | -| ------ | ---------------- | -| 200 | Playlist details | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Playlist details | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- @@ -66,9 +66,9 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | Playlist ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Playlist ID | #### Request Body @@ -78,13 +78,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | ---------------- | -| 200 | Playlist updated | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Playlist updated | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- @@ -94,18 +94,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | Playlist ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Playlist ID | #### Responses -| Status | Description | -| ------ | ---------------- | -| 200 | Playlist deleted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Playlist deleted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- @@ -115,18 +115,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | Playlist ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Playlist ID | #### Responses -| Status | Description | -| ------ | -------------- | -| 200 | Playlist items | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Playlist items | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- @@ -136,9 +136,9 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | Playlist ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Playlist ID | #### Request Body @@ -148,12 +148,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | ------------ | -| 200 | Item added | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Item added | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- @@ -163,9 +163,9 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | Playlist ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Playlist ID | #### Request Body @@ -175,12 +175,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | -------------- | -| 200 | Item reordered | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Item reordered | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- @@ -190,19 +190,19 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---------- | ---- | -------- | ------------- | -| `id` | path | Yes | Playlist ID | -| `media_id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Playlist ID | +| `media_id` | path | Yes | Media item ID | #### Responses -| Status | Description | -| ------ | ------------ | -| 200 | Item removed | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Item removed | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- @@ -212,17 +212,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | Playlist ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Playlist ID | #### Responses -| Status | Description | -| ------ | ----------------------- | -| 200 | Shuffled playlist items | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Shuffled playlist items | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- + diff --git a/docs/api/plugins.md b/docs/api/plugins.md index 683de6c..eaab41e 100644 --- a/docs/api/plugins.md +++ b/docs/api/plugins.md @@ -12,11 +12,11 @@ List all installed plugins #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | List of plugins | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | List of plugins | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -34,12 +34,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | ---------------- | -| 200 | Plugin installed | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | +| Status | Description | +|--------|-------------| +| 200 | Plugin installed | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | --- @@ -58,10 +58,10 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | -------------- | -| 200 | Event received | -| 401 | Unauthorized | +| Status | Description | +|--------|-------------| +| 200 | Event received | +| 401 | Unauthorized | --- @@ -73,10 +73,10 @@ List all UI pages provided by loaded plugins #### Responses -| Status | Description | -| ------ | --------------- | -| 200 | Plugin UI pages | -| 401 | Unauthorized | +| Status | Description | +|--------|-------------| +| 200 | Plugin UI pages | +| 401 | Unauthorized | --- @@ -88,10 +88,10 @@ List merged CSS custom property overrides from all enabled plugins #### Responses -| Status | Description | -| ------ | -------------------------- | -| 200 | Plugin UI theme extensions | -| 401 | Unauthorized | +| Status | Description | +|--------|-------------| +| 200 | Plugin UI theme extensions | +| 401 | Unauthorized | --- @@ -103,10 +103,10 @@ List all UI widgets provided by loaded plugins #### Responses -| Status | Description | -| ------ | ----------------- | -| 200 | Plugin UI widgets | -| 401 | Unauthorized | +| Status | Description | +|--------|-------------| +| 200 | Plugin UI widgets | +| 401 | Unauthorized | --- @@ -118,17 +118,17 @@ Get a specific plugin by ID #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | Plugin ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Plugin ID | #### Responses -| Status | Description | -| ------ | -------------- | -| 200 | Plugin details | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Plugin details | +| 401 | Unauthorized | +| 404 | Not found | --- @@ -140,18 +140,18 @@ Uninstall a plugin #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | Plugin ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Plugin ID | #### Responses -| Status | Description | -| ------ | ------------------ | -| 200 | Plugin uninstalled | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Plugin uninstalled | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- @@ -163,18 +163,18 @@ Reload a plugin (for development) #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | Plugin ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Plugin ID | #### Responses -| Status | Description | -| ------ | --------------- | -| 200 | Plugin reloaded | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Plugin reloaded | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- @@ -186,9 +186,9 @@ Enable or disable a plugin #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | Plugin ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Plugin ID | #### Request Body @@ -198,11 +198,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | -------------- | -| 200 | Plugin toggled | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Plugin toggled | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- + diff --git a/docs/api/saved_searches.md b/docs/api/saved_searches.md index 3889deb..12e374d 100644 --- a/docs/api/saved_searches.md +++ b/docs/api/saved_searches.md @@ -10,11 +10,11 @@ Saved search queries #### Responses -| Status | Description | -| ------ | ---------------------- | -| 200 | List of saved searches | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | List of saved searches | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -30,12 +30,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Search saved | -| 400 | Bad request | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Search saved | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -45,17 +45,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | --------------- | -| `id` | path | Yes | Saved search ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Saved search ID | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Saved search deleted | -| 401 | Unauthorized | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Saved search deleted | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | --- + diff --git a/docs/api/scan.md b/docs/api/scan.md index 3101bca..9c2af4b 100644 --- a/docs/api/scan.md +++ b/docs/api/scan.md @@ -18,12 +18,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Scan job submitted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Scan job submitted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -33,9 +33,10 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | ------------ | -| 200 | Scan status | -| 401 | Unauthorized | +| Status | Description | +|--------|-------------| +| 200 | Scan status | +| 401 | Unauthorized | --- + diff --git a/docs/api/scheduled_tasks.md b/docs/api/scheduled_tasks.md index d357c66..2367493 100644 --- a/docs/api/scheduled_tasks.md +++ b/docs/api/scheduled_tasks.md @@ -10,11 +10,11 @@ Scheduled background tasks #### Responses -| Status | Description | -| ------ | ----------------------- | -| 200 | List of scheduled tasks | -| 401 | Unauthorized | -| 403 | Forbidden | +| Status | Description | +|--------|-------------| +| 200 | List of scheduled tasks | +| 401 | Unauthorized | +| 403 | Forbidden | --- @@ -24,18 +24,18 @@ Scheduled background tasks #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | Task ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Task ID | #### Responses -| Status | Description | -| ------ | -------------- | -| 200 | Task triggered | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Task triggered | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- @@ -45,17 +45,18 @@ Scheduled background tasks #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | Task ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Task ID | #### Responses -| Status | Description | -| ------ | ------------ | -| 200 | Task toggled | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Task toggled | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- + diff --git a/docs/api/search.md b/docs/api/search.md index 2ca9429..102d2fb 100644 --- a/docs/api/search.md +++ b/docs/api/search.md @@ -10,21 +10,21 @@ Full-text media search #### Parameters -| Name | In | Required | Description | -| -------- | ----- | -------- | ----------------- | -| `q` | query | Yes | Search query | -| `sort` | query | No | Sort order | -| `offset` | query | No | Pagination offset | -| `limit` | query | No | Pagination limit | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `q` | query | Yes | Search query | +| `sort` | query | No | Sort order | +| `offset` | query | No | Pagination offset | +| `limit` | query | No | Pagination limit | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Search results | -| 400 | Bad request | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Search results | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -40,11 +40,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Search results | -| 400 | Bad request | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Search results | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | --- + diff --git a/docs/api/shares.md b/docs/api/shares.md index cd62b74..9702f41 100644 --- a/docs/api/shares.md +++ b/docs/api/shares.md @@ -6,81 +6,86 @@ Media sharing and notifications ### GET /api/v1/notifications/shares -Get unread share notifications GET /api/notifications/shares +Get unread share notifications +GET /api/notifications/shares **Authentication:** Required (Bearer JWT) #### Responses -| Status | Description | -| ------ | -------------------- | -| 200 | Unread notifications | -| 401 | Unauthorized | +| Status | Description | +|--------|-------------| +| 200 | Unread notifications | +| 401 | Unauthorized | --- ### POST /api/v1/notifications/shares/read-all -Mark all notifications as read POST /api/notifications/shares/read-all +Mark all notifications as read +POST /api/notifications/shares/read-all **Authentication:** Required (Bearer JWT) #### Responses -| Status | Description | -| ------ | -------------------------------- | -| 200 | All notifications marked as read | -| 401 | Unauthorized | +| Status | Description | +|--------|-------------| +| 200 | All notifications marked as read | +| 401 | Unauthorized | --- ### POST /api/v1/notifications/shares/{id}/read -Mark a notification as read POST /api/notifications/shares/{id}/read +Mark a notification as read +POST /api/notifications/shares/{id}/read **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | --------------- | -| `id` | path | Yes | Notification ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Notification ID | #### Responses -| Status | Description | -| ------ | --------------------------- | -| 200 | Notification marked as read | -| 401 | Unauthorized | +| Status | Description | +|--------|-------------| +| 200 | Notification marked as read | +| 401 | Unauthorized | --- ### GET /api/v1/shared/{token} -Access a public shared resource GET /api/shared/{token} +Access a public shared resource +GET /api/shared/{token} **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -| ---------- | ----- | -------- | -------------------------- | -| `token` | path | Yes | Share token | -| `password` | query | No | Share password if required | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `token` | path | Yes | Share token | +| `password` | query | No | Share password if required | #### Responses -| Status | Description | -| ------ | -------------- | -| 200 | Shared content | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Shared content | +| 401 | Unauthorized | +| 404 | Not found | --- ### POST /api/v1/shares -Create a new share POST /api/shares +Create a new share +POST /api/shares **Authentication:** Required (Bearer JWT) @@ -92,18 +97,19 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Share created | -| 400 | Bad request | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Share created | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | --- ### POST /api/v1/shares/batch/delete -Batch delete shares POST /api/shares/batch/delete +Batch delete shares +POST /api/shares/batch/delete **Authentication:** Required (Bearer JWT) @@ -115,93 +121,97 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | -------------- | -| 200 | Shares deleted | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | +| Status | Description | +|--------|-------------| +| 200 | Shares deleted | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | --- ### GET /api/v1/shares/incoming -List incoming shares (shares shared with me) GET /api/shares/incoming +List incoming shares (shares shared with me) +GET /api/shares/incoming **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -| -------- | ----- | -------- | ----------------- | -| `offset` | query | No | Pagination offset | -| `limit` | query | No | Pagination limit | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `offset` | query | No | Pagination offset | +| `limit` | query | No | Pagination limit | #### Responses -| Status | Description | -| ------ | --------------- | -| 200 | Incoming shares | -| 401 | Unauthorized | +| Status | Description | +|--------|-------------| +| 200 | Incoming shares | +| 401 | Unauthorized | --- ### GET /api/v1/shares/outgoing -List outgoing shares (shares I created) GET /api/shares/outgoing +List outgoing shares (shares I created) +GET /api/shares/outgoing **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -| -------- | ----- | -------- | ----------------- | -| `offset` | query | No | Pagination offset | -| `limit` | query | No | Pagination limit | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `offset` | query | No | Pagination offset | +| `limit` | query | No | Pagination limit | #### Responses -| Status | Description | -| ------ | --------------- | -| 200 | Outgoing shares | -| 401 | Unauthorized | +| Status | Description | +|--------|-------------| +| 200 | Outgoing shares | +| 401 | Unauthorized | --- ### GET /api/v1/shares/{id} -Get share details GET /api/shares/{id} +Get share details +GET /api/shares/{id} **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | Share ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Share ID | #### Responses -| Status | Description | -| ------ | ------------- | -| 200 | Share details | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Share details | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- ### PATCH /api/v1/shares/{id} -Update a share PATCH /api/shares/{id} +Update a share +PATCH /api/shares/{id} **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | Share ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Share ID | #### Request Body @@ -211,59 +221,62 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | ------------- | -| 200 | Share updated | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Share updated | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- ### DELETE /api/v1/shares/{id} -Delete (revoke) a share DELETE /api/shares/{id} +Delete (revoke) a share +DELETE /api/shares/{id} **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | Share ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Share ID | #### Responses -| Status | Description | -| ------ | ------------- | -| 204 | Share deleted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 204 | Share deleted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- ### GET /api/v1/shares/{id}/activity -Get share activity log GET /api/shares/{id}/activity +Get share activity log +GET /api/shares/{id}/activity **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -| -------- | ----- | -------- | ----------------- | -| `id` | path | Yes | Share ID | -| `offset` | query | No | Pagination offset | -| `limit` | query | No | Pagination limit | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Share ID | +| `offset` | query | No | Pagination offset | +| `limit` | query | No | Pagination limit | #### Responses -| Status | Description | -| ------ | -------------- | -| 200 | Share activity | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Share activity | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- + diff --git a/docs/api/social.md b/docs/api/social.md index 097a627..e706183 100644 --- a/docs/api/social.md +++ b/docs/api/social.md @@ -10,11 +10,11 @@ Ratings, comments, favorites, and share links #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | User favorites | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | User favorites | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -30,11 +30,11 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Added to favorites | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Added to favorites | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -44,17 +44,17 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---------- | ---- | -------- | ------------- | -| `media_id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `media_id` | path | Yes | Media item ID | #### Responses -| Status | Description | -| ------ | ---------------------- | -| 200 | Removed from favorites | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Removed from favorites | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -70,12 +70,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Share link created | -| 400 | Bad request | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Share link created | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -85,17 +85,17 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Media comments | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Media comments | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -105,9 +105,9 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | #### Request Body @@ -117,12 +117,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Comment added | -| 400 | Bad request | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Comment added | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -132,9 +132,9 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | #### Request Body @@ -144,12 +144,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Rating saved | -| 400 | Bad request | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Rating saved | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -159,17 +159,17 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Media ratings | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Media ratings | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -179,17 +179,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---------- | ----- | -------- | -------------- | -| `token` | path | Yes | Share token | -| `password` | query | No | Share password | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `token` | path | Yes | Share token | +| `password` | query | No | Share password | #### Responses -| Status | Description | -| ------ | ------------ | -| 200 | Shared media | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Shared media | +| 401 | Unauthorized | +| 404 | Not found | --- + diff --git a/docs/api/statistics.md b/docs/api/statistics.md index b74f02f..270ad62 100644 --- a/docs/api/statistics.md +++ b/docs/api/statistics.md @@ -10,10 +10,11 @@ Library statistics #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Library statistics | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Library statistics | +| 401 | Unauthorized | +| 500 | Internal server error | --- + diff --git a/docs/api/streaming.md b/docs/api/streaming.md index 0bd0a35..11a3352 100644 --- a/docs/api/streaming.md +++ b/docs/api/streaming.md @@ -10,18 +10,18 @@ HLS and DASH adaptive streaming #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -| ------ | ------------- | -| 200 | DASH manifest | -| 400 | Bad request | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | DASH manifest | +| 400 | Bad request | +| 401 | Unauthorized | +| 404 | Not found | --- @@ -31,20 +31,20 @@ HLS and DASH adaptive streaming #### Parameters -| Name | In | Required | Description | -| --------- | ---- | -------- | ---------------------- | -| `id` | path | Yes | Media item ID | -| `profile` | path | Yes | Transcode profile name | -| `segment` | path | Yes | Segment filename | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | +| `profile` | path | Yes | Transcode profile name | +| `segment` | path | Yes | Segment filename | #### Responses -| Status | Description | -| ------ | ------------------------- | -| 200 | DASH segment data | -| 202 | Segment not yet available | -| 400 | Bad request | -| 401 | Unauthorized | +| Status | Description | +|--------|-------------| +| 200 | DASH segment data | +| 202 | Segment not yet available | +| 400 | Bad request | +| 401 | Unauthorized | --- @@ -54,17 +54,17 @@ HLS and DASH adaptive streaming #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -| ------ | ------------------- | -| 200 | HLS master playlist | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | HLS master playlist | +| 401 | Unauthorized | +| 404 | Not found | --- @@ -74,19 +74,19 @@ HLS and DASH adaptive streaming #### Parameters -| Name | In | Required | Description | -| --------- | ---- | -------- | ---------------------- | -| `id` | path | Yes | Media item ID | -| `profile` | path | Yes | Transcode profile name | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | +| `profile` | path | Yes | Transcode profile name | #### Responses -| Status | Description | -| ------ | -------------------- | -| 200 | HLS variant playlist | -| 400 | Bad request | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | HLS variant playlist | +| 400 | Bad request | +| 401 | Unauthorized | +| 404 | Not found | --- @@ -96,19 +96,20 @@ HLS and DASH adaptive streaming #### Parameters -| Name | In | Required | Description | -| --------- | ---- | -------- | ---------------------- | -| `id` | path | Yes | Media item ID | -| `profile` | path | Yes | Transcode profile name | -| `segment` | path | Yes | Segment filename | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | +| `profile` | path | Yes | Transcode profile name | +| `segment` | path | Yes | Segment filename | #### Responses -| Status | Description | -| ------ | ------------------------- | -| 200 | HLS segment data | -| 202 | Segment not yet available | -| 400 | Bad request | -| 401 | Unauthorized | +| Status | Description | +|--------|-------------| +| 200 | HLS segment data | +| 202 | Segment not yet available | +| 400 | Bad request | +| 401 | Unauthorized | --- + diff --git a/docs/api/subtitles.md b/docs/api/subtitles.md index 8387484..ce36e05 100644 --- a/docs/api/subtitles.md +++ b/docs/api/subtitles.md @@ -10,17 +10,17 @@ Media subtitle management #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -| ------ | --------------------------------------- | -| 200 | Subtitles and available embedded tracks | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Subtitles | +| 401 | Unauthorized | +| 404 | Not found | --- @@ -30,9 +30,9 @@ Media subtitle management #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | #### Request Body @@ -42,12 +42,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Subtitle added | -| 400 | Bad request | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Subtitle added | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -57,18 +57,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ------------- | ---- | -------- | ------------- | -| `media_id` | path | Yes | Media item ID | -| `subtitle_id` | path | Yes | Subtitle ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `media_id` | path | Yes | Media item ID | +| `subtitle_id` | path | Yes | Subtitle ID | #### Responses -| Status | Description | -| ------ | ---------------- | -| 200 | Subtitle content | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Subtitle content | +| 401 | Unauthorized | +| 404 | Not found | --- @@ -78,17 +78,17 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | Subtitle ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Subtitle ID | #### Responses -| Status | Description | -| ------ | ---------------- | -| 200 | Subtitle deleted | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Subtitle deleted | +| 401 | Unauthorized | +| 404 | Not found | --- @@ -98,9 +98,9 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | Subtitle ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Subtitle ID | #### Request Body @@ -110,10 +110,11 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | -------------- | -| 200 | Offset updated | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Offset updated | +| 401 | Unauthorized | +| 404 | Not found | --- + diff --git a/docs/api/sync.md b/docs/api/sync.md index fd1aa5b..165d4c0 100644 --- a/docs/api/sync.md +++ b/docs/api/sync.md @@ -6,7 +6,8 @@ Multi-device library synchronization ### POST /api/v1/sync/ack -Acknowledge processed changes POST /api/sync/ack +Acknowledge processed changes +POST /api/sync/ack **Authentication:** Required (Bearer JWT) @@ -18,63 +19,66 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | -------------------- | -| 200 | Changes acknowledged | -| 400 | Bad request | -| 401 | Unauthorized | +| Status | Description | +|--------|-------------| +| 200 | Changes acknowledged | +| 400 | Bad request | +| 401 | Unauthorized | --- ### GET /api/v1/sync/changes -Get changes since cursor GET /api/sync/changes +Get changes since cursor +GET /api/sync/changes **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -| -------- | ----- | -------- | ---------------------- | -| `cursor` | query | No | Sync cursor | -| `limit` | query | No | Max changes (max 1000) | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `cursor` | query | No | Sync cursor | +| `limit` | query | No | Max changes (max 1000) | #### Responses -| Status | Description | -| ------ | -------------------- | -| 200 | Changes since cursor | -| 400 | Bad request | -| 401 | Unauthorized | +| Status | Description | +|--------|-------------| +| 200 | Changes since cursor | +| 400 | Bad request | +| 401 | Unauthorized | --- ### GET /api/v1/sync/conflicts -List unresolved conflicts GET /api/sync/conflicts +List unresolved conflicts +GET /api/sync/conflicts **Authentication:** Required (Bearer JWT) #### Responses -| Status | Description | -| ------ | -------------------- | -| 200 | Unresolved conflicts | -| 401 | Unauthorized | +| Status | Description | +|--------|-------------| +| 200 | Unresolved conflicts | +| 401 | Unauthorized | --- ### POST /api/v1/sync/conflicts/{id}/resolve -Resolve a sync conflict POST /api/sync/conflicts/{id}/resolve +Resolve a sync conflict +POST /api/sync/conflicts/{id}/resolve **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | Conflict ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Conflict ID | #### Request Body @@ -84,32 +88,34 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | ----------------- | -| 200 | Conflict resolved | -| 400 | Bad request | -| 401 | Unauthorized | +| Status | Description | +|--------|-------------| +| 200 | Conflict resolved | +| 400 | Bad request | +| 401 | Unauthorized | --- ### GET /api/v1/sync/devices -List user's sync devices GET /api/sync/devices +List user's sync devices +GET /api/sync/devices **Authentication:** Required (Bearer JWT) #### Responses -| Status | Description | -| ------ | --------------- | -| 200 | List of devices | -| 401 | Unauthorized | +| Status | Description | +|--------|-------------| +| 200 | List of devices | +| 401 | Unauthorized | --- ### POST /api/v1/sync/devices -Register a new sync device POST /api/sync/devices +Register a new sync device +POST /api/sync/devices **Authentication:** Required (Bearer JWT) @@ -121,49 +127,51 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Device registered | -| 400 | Bad request | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Device registered | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | --- ### GET /api/v1/sync/devices/{id} -Get device details GET /api/sync/devices/{id} +Get device details +GET /api/sync/devices/{id} **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | Device ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Device ID | #### Responses -| Status | Description | -| ------ | -------------- | -| 200 | Device details | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Device details | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- ### PUT /api/v1/sync/devices/{id} -Update a device PUT /api/sync/devices/{id} +Update a device +PUT /api/sync/devices/{id} **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | Device ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Device ID | #### Request Body @@ -173,87 +181,91 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | -------------- | -| 200 | Device updated | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Device updated | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- ### DELETE /api/v1/sync/devices/{id} -Delete a device DELETE /api/sync/devices/{id} +Delete a device +DELETE /api/sync/devices/{id} **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | Device ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Device ID | #### Responses -| Status | Description | -| ------ | -------------- | -| 204 | Device deleted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 204 | Device deleted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- ### POST /api/v1/sync/devices/{id}/token -Regenerate device token POST /api/sync/devices/{id}/token +Regenerate device token +POST /api/sync/devices/{id}/token **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | Device ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Device ID | #### Responses -| Status | Description | -| ------ | ----------------- | -| 200 | Token regenerated | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Token regenerated | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- ### GET /api/v1/sync/download/{path} -Download a file for sync (supports Range header) GET /api/sync/download/{*path} +Download a file for sync (supports Range header) +GET /api/sync/download/{*path} **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -| ------ | ---- | -------- | ----------- | -| `path` | path | Yes | File path | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `path` | path | Yes | File path | #### Responses -| Status | Description | -| ------ | --------------- | -| 200 | File content | -| 206 | Partial content | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | File content | +| 206 | Partial content | +| 401 | Unauthorized | +| 404 | Not found | --- ### POST /api/v1/sync/report -Report local changes from client POST /api/sync/report +Report local changes from client +POST /api/sync/report **Authentication:** Required (Bearer JWT) @@ -265,17 +277,18 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | ----------------- | -| 200 | Changes processed | -| 400 | Bad request | -| 401 | Unauthorized | +| Status | Description | +|--------|-------------| +| 200 | Changes processed | +| 400 | Bad request | +| 401 | Unauthorized | --- ### POST /api/v1/sync/upload -Create an upload session for chunked upload POST /api/sync/upload +Create an upload session for chunked upload +POST /api/sync/upload **Authentication:** Required (Bearer JWT) @@ -287,107 +300,113 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | ---------------------- | -| 200 | Upload session created | -| 400 | Bad request | -| 401 | Unauthorized | +| Status | Description | +|--------|-------------| +| 200 | Upload session created | +| 400 | Bad request | +| 401 | Unauthorized | --- ### GET /api/v1/sync/upload/{id} -Get upload session status GET /api/sync/upload/{id} +Get upload session status +GET /api/sync/upload/{id} **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------------- | -| `id` | path | Yes | Upload session ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Upload session ID | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Upload session status | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Upload session status | +| 401 | Unauthorized | +| 404 | Not found | --- ### DELETE /api/v1/sync/upload/{id} -Cancel an upload session DELETE /api/sync/upload/{id} +Cancel an upload session +DELETE /api/sync/upload/{id} **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------------- | -| `id` | path | Yes | Upload session ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Upload session ID | #### Responses -| Status | Description | -| ------ | ---------------- | -| 204 | Upload cancelled | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 204 | Upload cancelled | +| 401 | Unauthorized | +| 404 | Not found | --- ### PUT /api/v1/sync/upload/{id}/chunks/{index} -Upload a chunk PUT /api/sync/upload/{id}/chunks/{index} +Upload a chunk +PUT /api/sync/upload/{id}/chunks/{index} **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -| ------- | ---- | -------- | ----------------- | -| `id` | path | Yes | Upload session ID | -| `index` | path | Yes | Chunk index | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Upload session ID | +| `index` | path | Yes | Chunk index | #### Request Body -Chunk binary data `Content-Type: application/octet-stream` +Chunk binary data +`Content-Type: application/octet-stream` See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | -------------- | -| 200 | Chunk received | -| 400 | Bad request | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Chunk received | +| 400 | Bad request | +| 401 | Unauthorized | +| 404 | Not found | --- ### POST /api/v1/sync/upload/{id}/complete -Complete an upload session POST /api/sync/upload/{id}/complete +Complete an upload session +POST /api/sync/upload/{id}/complete **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------------- | -| `id` | path | Yes | Upload session ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Upload session ID | #### Responses -| Status | Description | -| ------ | ---------------- | -| 200 | Upload completed | -| 400 | Bad request | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Upload completed | +| 400 | Bad request | +| 401 | Unauthorized | +| 404 | Not found | --- + diff --git a/docs/api/tags.md b/docs/api/tags.md index 6bdefc4..a9a71c0 100644 --- a/docs/api/tags.md +++ b/docs/api/tags.md @@ -10,18 +10,18 @@ Media tag management #### Parameters -| Name | In | Required | Description | -| ---------- | ---- | -------- | ------------- | -| `media_id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `media_id` | path | Yes | Media item ID | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Media tags | -| 401 | Unauthorized | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Media tags | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | --- @@ -31,9 +31,9 @@ Media tag management #### Parameters -| Name | In | Required | Description | -| ---------- | ---- | -------- | ------------- | -| `media_id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `media_id` | path | Yes | Media item ID | #### Request Body @@ -43,13 +43,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Tag applied | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Tag applied | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | --- @@ -59,20 +59,20 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---------- | ---- | -------- | ------------- | -| `media_id` | path | Yes | Media item ID | -| `tag_id` | path | Yes | Tag ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `media_id` | path | Yes | Media item ID | +| `tag_id` | path | Yes | Tag ID | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Tag removed | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Tag removed | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | --- @@ -82,11 +82,11 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | List of tags | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | List of tags | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -102,13 +102,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Tag created | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Tag created | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -118,18 +118,18 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | Tag ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Tag ID | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Tag | -| 401 | Unauthorized | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Tag | +| 401 | Unauthorized | +| 404 | Not found | +| 500 | Internal server error | --- @@ -139,18 +139,19 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | Tag ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Tag ID | #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | Tag deleted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Tag deleted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | +| 500 | Internal server error | --- + diff --git a/docs/api/transcode.md b/docs/api/transcode.md index 3cbb7f9..126135e 100644 --- a/docs/api/transcode.md +++ b/docs/api/transcode.md @@ -10,9 +10,9 @@ Video transcoding sessions #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | #### Request Body @@ -22,12 +22,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | ----------------------- | -| 200 | Transcode job submitted | -| 400 | Bad request | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Transcode job submitted | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | --- @@ -37,10 +37,10 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | -------------------------- | -| 200 | List of transcode sessions | -| 401 | Unauthorized | +| Status | Description | +|--------|-------------| +| 200 | List of transcode sessions | +| 401 | Unauthorized | --- @@ -50,17 +50,17 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | -------------------- | -| `id` | path | Yes | Transcode session ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Transcode session ID | #### Responses -| Status | Description | -| ------ | ------------------------- | -| 200 | Transcode session details | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Transcode session details | +| 401 | Unauthorized | +| 404 | Not found | --- @@ -70,16 +70,17 @@ See `docs/api/openapi.json` for the full schema. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | -------------------- | -| `id` | path | Yes | Transcode session ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Transcode session ID | #### Responses -| Status | Description | -| ------ | --------------------------- | -| 200 | Transcode session cancelled | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | Transcode session cancelled | +| 401 | Unauthorized | +| 404 | Not found | --- + diff --git a/docs/api/upload.md b/docs/api/upload.md index 698b963..da8a61b 100644 --- a/docs/api/upload.md +++ b/docs/api/upload.md @@ -6,79 +6,84 @@ File upload and managed storage ### GET /api/v1/managed/stats -Get managed storage statistics GET /api/managed/stats +Get managed storage statistics +GET /api/managed/stats **Authentication:** Required (Bearer JWT) #### Responses -| Status | Description | -| ------ | -------------------------- | -| 200 | Managed storage statistics | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | Managed storage statistics | +| 401 | Unauthorized | +| 500 | Internal server error | --- ### GET /api/v1/media/{id}/download -Download a managed file GET /api/media/{id}/download +Download a managed file +GET /api/media/{id}/download **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -| ------ | ------------ | -| 200 | File content | -| 400 | Bad request | -| 401 | Unauthorized | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | File content | +| 400 | Bad request | +| 401 | Unauthorized | +| 404 | Not found | --- ### POST /api/v1/media/{id}/move-to-managed -Migrate an external file to managed storage POST /api/media/{id}/move-to-managed +Migrate an external file to managed storage +POST /api/media/{id}/move-to-managed **Authentication:** Required (Bearer JWT) #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ------------- | -| `id` | path | Yes | Media item ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | Media item ID | #### Responses -| Status | Description | -| ------ | --------------------- | -| 204 | File migrated | -| 400 | Bad request | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 204 | File migrated | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | --- ### POST /api/v1/upload -Upload a file to managed storage POST /api/upload +Upload a file to managed storage +POST /api/upload **Authentication:** Required (Bearer JWT) #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | File uploaded | -| 400 | Bad request | -| 401 | Unauthorized | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | File uploaded | +| 400 | Bad request | +| 401 | Unauthorized | +| 500 | Internal server error | --- + diff --git a/docs/api/users.md b/docs/api/users.md index c76a411..0cd7087 100644 --- a/docs/api/users.md +++ b/docs/api/users.md @@ -12,11 +12,11 @@ List all users (admin only) #### Responses -| Status | Description | -| ------ | ------------- | -| 200 | List of users | -| 401 | Unauthorized | -| 403 | Forbidden | +| Status | Description | +|--------|-------------| +| 200 | List of users | +| 401 | Unauthorized | +| 403 | Forbidden | --- @@ -35,13 +35,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | --------------------- | -| 200 | User created | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 500 | Internal server error | +| Status | Description | +|--------|-------------| +| 200 | User created | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal server error | --- @@ -53,18 +53,18 @@ Get a specific user by ID #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | User ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | User ID | #### Responses -| Status | Description | -| ------ | ------------ | -| 200 | User details | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | User details | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- @@ -76,9 +76,9 @@ Update a user #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | User ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | User ID | #### Request Body @@ -89,13 +89,13 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | ------------ | -| 200 | User updated | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | User updated | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- @@ -107,18 +107,18 @@ Delete a user (admin only) #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | User ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | User ID | #### Responses -| Status | Description | -| ------ | ------------ | -| 200 | User deleted | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not found | +| Status | Description | +|--------|-------------| +| 200 | User deleted | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not found | --- @@ -130,17 +130,17 @@ Get user's accessible libraries #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | User ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | User ID | #### Responses -| Status | Description | -| ------ | -------------- | -| 200 | User libraries | -| 401 | Unauthorized | -| 403 | Forbidden | +| Status | Description | +|--------|-------------| +| 200 | User libraries | +| 401 | Unauthorized | +| 403 | Forbidden | --- @@ -152,9 +152,9 @@ Grant library access to a user (admin only) #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | User ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | User ID | #### Request Body @@ -164,12 +164,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | -------------- | -| 200 | Access granted | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | +| Status | Description | +|--------|-------------| +| 200 | Access granted | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | --- @@ -184,9 +184,9 @@ slashes that conflict with URL routing. #### Parameters -| Name | In | Required | Description | -| ---- | ---- | -------- | ----------- | -| `id` | path | Yes | User ID | +| Name | In | Required | Description | +|------|----|----------|-------------| +| `id` | path | Yes | User ID | #### Request Body @@ -196,11 +196,12 @@ See `docs/api/openapi.json` for the full schema. #### Responses -| Status | Description | -| ------ | -------------- | -| 200 | Access revoked | -| 400 | Bad request | -| 401 | Unauthorized | -| 403 | Forbidden | +| Status | Description | +|--------|-------------| +| 200 | Access revoked | +| 400 | Bad request | +| 401 | Unauthorized | +| 403 | Forbidden | --- + diff --git a/docs/api/webhooks.md b/docs/api/webhooks.md index da0e1f4..9005323 100644 --- a/docs/api/webhooks.md +++ b/docs/api/webhooks.md @@ -10,11 +10,11 @@ Webhook configuration #### Responses -| Status | Description | -| ------ | --------------------------- | -| 200 | List of configured webhooks | -| 401 | Unauthorized | -| 403 | Forbidden | +| Status | Description | +|--------|-------------| +| 200 | List of configured webhooks | +| 401 | Unauthorized | +| 403 | Forbidden | --- @@ -24,10 +24,11 @@ Webhook configuration #### Responses -| Status | Description | -| ------ | ----------------- | -| 200 | Test webhook sent | -| 401 | Unauthorized | -| 403 | Forbidden | +| Status | Description | +|--------|-------------| +| 200 | Test webhook sent | +| 401 | Unauthorized | +| 403 | Forbidden | --- + diff --git a/examples/plugins/README.md b/examples/plugins/README.md new file mode 100644 index 0000000..12c5217 --- /dev/null +++ b/examples/plugins/README.md @@ -0,0 +1,518 @@ +# Pinakes Plugin Examples + +This directory contains example plugins demonstrating the Pinakes plugin system. + +## Overview + +Pinakes supports extensibility through a WASM-based plugin system. Plugins can +extend Pinakes functionality by: + +- **Media Type Providers**: Add support for new file formats +- **Metadata Extractors**: Extract metadata from files +- **Thumbnail Generators**: Generate thumbnails for media types +- **Search Backends**: Implement custom search algorithms +- **Event Handlers**: React to system events +- **Theme Providers**: Provide custom UI themes + +## Example Plugins + +### 1. Markdown Metadata Extractor + +**Directory**: `markdown-metadata/` + +Enhances markdown file support with advanced frontmatter parsing. + +**Demonstrates**: + +- Metadata extraction from files +- Plugin configuration via `plugin.toml` +- Minimal capability requirements + +**Plugin Kind**: `metadata_extractor` + +### 2. HEIF/HEIC Support + +**Directory**: `heif-support/` + +Adds support for HEIF and HEIC image formats. + +**Demonstrates**: + +- Media type registration +- Metadata extraction from binary formats +- Thumbnail generation +- Resource limits (memory, CPU time) + +**Plugin Kinds**: `media_type`, `metadata_extractor`, `thumbnail_generator` + +## Plugin Architecture + +### Plugin Structure + +``` +my-plugin/ +├── plugin.toml # Plugin manifest +├── Cargo.toml # Rust project configuration +├── src/ +│ └── lib.rs # Plugin implementation +└── README.md # Plugin documentation +``` + +### Plugin Manifest (plugin.toml) + +```toml +[plugin] +name = "my-plugin" +version = "1.0.0" +api_version = "1.0" +author = "Your Name" +description = "Description of your plugin" +kind = ["metadata_extractor"] + +[plugin.binary] +wasm = "my_plugin.wasm" + +[capabilities] +network = false + +[capabilities.filesystem] +read = ["/path/to/read"] +write = ["/path/to/write"] + +[config] +# Plugin-specific configuration +option1 = "value1" +option2 = 42 +``` + +### Manifest Fields + +#### [plugin] Section + +- `name`: Plugin identifier (must be unique) +- `version`: Semantic version (e.g., "1.0.0") +- `api_version`: Pinakes Plugin API version (currently "1.0") +- `author`: Plugin author (optional) +- `description`: Short description (optional) +- `homepage`: Plugin homepage URL (optional) +- `license`: License identifier (optional) +- `kind`: Array of plugin kinds +- `dependencies`: Array of plugin names this plugin depends on (optional) + +#### [plugin.binary] Section + +- `wasm`: Path to WASM binary (relative to manifest) +- `entrypoint`: Custom entrypoint function name (optional, default: "_start") + +#### [capabilities] Section + +Capabilities define what the plugin can access: + +**Filesystem**: + +```toml +[capabilities.filesystem] +read = ["/tmp/cache", "/var/data"] +write = ["/tmp/output"] +``` + +**Network**: + +```toml +[capabilities] +network = true # or false +``` + +**Environment**: + +```toml +[capabilities] +environment = ["PATH", "HOME"] # or omit for no access +``` + +**Resource Limits**: + +```toml +[capabilities] +max_memory_mb = 128 +max_cpu_time_secs = 10 +``` + +### Plugin Kinds + +#### media_type + +Register new media types with file extensions and MIME types. + +**Trait**: `MediaTypeProvider` + +**Methods**: + +- `supported_media_types()`: Returns list of media type definitions +- `can_handle(path, mime_type)`: Check if plugin can handle a file + +#### metadata_extractor + +Extract metadata from files. + +**Trait**: `MetadataExtractor` + +**Methods**: + +- `extract_metadata(path)`: Extract metadata from file +- `supported_types()`: Returns list of supported media type IDs + +#### thumbnail_generator + +Generate thumbnails for media files. + +**Trait**: `ThumbnailGenerator` + +**Methods**: + +- `generate_thumbnail(path, output_path, options)`: Generate thumbnail +- `supported_types()`: Returns list of supported media type IDs + +#### search_backend + +Implement custom search algorithms. + +**Trait**: `SearchBackend` + +**Methods**: + +- `index_item(item)`: Index a media item +- `remove_item(item_id)`: Remove item from index +- `search(query)`: Perform search +- `get_stats()`: Get index statistics + +#### event_handler + +React to system events. + +**Trait**: `EventHandler` + +**Methods**: + +- `handle_event(event)`: Handle an event +- `interested_events()`: Returns list of event types to receive + +#### theme_provider + +Provide UI themes. + +**Trait**: `ThemeProvider` + +**Methods**: + +- `get_themes()`: List available themes +- `load_theme(theme_id)`: Load theme data + +## Creating a Plugin + +### Step 1: Set Up Project + +```bash +# Create new Rust library project +cargo new --lib my-plugin +cd my-plugin + +# Add dependencies +cat >> Cargo.toml <, +} + +#[async_trait] +impl Plugin for MyPlugin { + fn metadata(&self) -> &PluginMetadata { + // Return plugin metadata + } + + async fn initialize(&mut self, context: PluginContext) -> PluginResult<()> { + self.context = Some(context); + Ok(()) + } + + async fn shutdown(&mut self) -> PluginResult<()> { + Ok(()) + } + + async fn health_check(&self) -> PluginResult { + Ok(HealthStatus { + healthy: true, + message: None, + metrics: Default::default(), + }) + } +} + +#[async_trait] +impl MetadataExtractor for MyPlugin { + async fn extract_metadata(&self, path: &PathBuf) -> PluginResult { + // Extract metadata from file + } + + fn supported_types(&self) -> Vec { + vec!["my_type".to_string()] + } +} +``` + +### Step 3: Build to WASM + +```bash +# Install WASM target +rustup target add wasm32-wasi + +# Build +cargo build --target wasm32-wasi --release + +# Optimize (optional, wasm-tools provides wasm-strip functionality) +cargo install wasm-tools +wasm-tools strip target/wasm32-wasi/release/my_plugin.wasm -o target/wasm32-wasi/release/my_plugin.wasm + +# Copy to plugin directory +cp target/wasm32-wasi/release/my_plugin.wasm . +``` + +### Step 4: Create Manifest + +Create `plugin.toml` with appropriate configuration (see examples above). + +### Step 5: Install Plugin + +```bash +# Copy to plugins directory +cp -r my-plugin ~/.config/pinakes/plugins/ + +# Or use API +curl -X POST http://localhost:3000/api/v1/plugins/install \ + -d '{"source": "/path/to/my-plugin"}' +``` + +## Testing Plugins + +### Unit Tests + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_metadata_extraction() { + let mut plugin = MyPlugin::default(); + let context = PluginContext { + data_dir: PathBuf::from("/tmp/data"), + cache_dir: PathBuf::from("/tmp/cache"), + config: Default::default(), + capabilities: Default::default(), + }; + + plugin.initialize(context).await.unwrap(); + + let metadata = plugin + .extract_metadata(&PathBuf::from("test.txt")) + .await + .unwrap(); + + assert!(metadata.title.is_some()); + } +} +``` + +### Integration Tests + +```bash +# Load plugin in test Pinakes instance +pinakes --config test-config.toml plugin load /path/to/plugin + +# Verify plugin is loaded +pinakes plugin list + +# Test plugin functionality +pinakes scan /path/to/test/files +``` + +## Security Considerations + +### Capability-Based Security + +Plugins operate in a sandbox with explicit capabilities. Only request the +minimum capabilities needed: + +**Good**: + +```toml +[capabilities.filesystem] +read = ["/tmp/cache"] +``` + +**Bad**: + +```toml +[capabilities.filesystem] +read = ["/", "/home", "/etc"] # Too broad! +``` + +### Resource Limits + +Always set appropriate resource limits: + +```toml +[capabilities] +max_memory_mb = 128 # Reasonable for image processing +max_cpu_time_secs = 10 # Prevent runaway operations +``` + +### Input Validation + +Always validate input in your plugin: + +```rust +async fn extract_metadata(&self, path: &PathBuf) -> PluginResult { + // Check file exists + if !path.exists() { + return Err(PluginError::InvalidInput("File not found".to_string())); + } + + // Check file size + let metadata = std::fs::metadata(path) + .map_err(|e| PluginError::IoError(e.to_string()))?; + if metadata.len() > 10_000_000 { // 10MB limit + return Err(PluginError::InvalidInput("File too large".to_string())); + } + + // Process file... +} +``` + +## Best Practices + +### Error Handling + +- Use descriptive error messages +- Return appropriate `PluginError` variants +- Don't panic - return errors instead + +### Performance + +- Avoid blocking operations in async functions +- Use streaming for large files +- Implement timeouts for external operations +- Cache results when appropriate + +### Configuration + +- Provide sensible defaults +- Document all configuration options +- Validate configuration during initialization + +### Documentation + +- Write clear README with examples +- Document all configuration options +- Include troubleshooting section +- Provide integration examples + +## API Reference + +See the +[pinakes-plugin-api documentation](../../crates/pinakes-plugin-api/README.md) +for detailed API reference. + +## Plugin Distribution + +### Plugin Registry (Future) + +A centralized plugin registry is planned for future releases: + +```bash +# Install from registry +pinakes plugin install markdown-metadata + +# Search plugins +pinakes plugin search heif + +# Update all plugins +pinakes plugin update --all +``` + +### Manual Distribution + +Currently, plugins are distributed as directories containing: + +- `plugin.toml` manifest +- WASM binary +- README and documentation + +## Troubleshooting + +### Plugin Won't Load + +**Check manifest syntax**: + +```bash +# Validate TOML syntax +taplo check plugin.toml +``` + +**Check API version**: Ensure `api_version = "1.0"` in manifest. + +**Check binary path**: Verify WASM binary exists at path specified in +`plugin.binary.wasm`. + +### Plugin Crashes + +**Check resource limits**: Increase `max_memory_mb` or `max_cpu_time_secs` if +operations are timing out. + +**Check capabilities**: Ensure plugin has necessary filesystem/network +capabilities. + +**Check logs**: + +```bash +# View plugin logs +tail -f ~/.local/share/pinakes/logs/plugin.log +``` + +### Permission Denied Errors + +**Filesystem capabilities**: Add required paths to +`capabilities.filesystem.read` or `.write`. + +**Network capabilities**: Set `capabilities.network = true` if plugin needs +network access. + +## Support + +- **Issues**: https://github.com/notashelf/pinakes/issues +- **Discussions**: https://github.com/notashelf/pinakes/discussions +- **Documentation**: https://pinakes.readthedocs.io + +## License + +All example plugins are licensed under MIT. diff --git a/examples/plugins/auto-tagger/Cargo.toml b/examples/plugins/auto-tagger/Cargo.toml deleted file mode 100644 index cdcee85..0000000 --- a/examples/plugins/auto-tagger/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "auto-tagger" -version = "1.0.0" -edition = "2024" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -dlmalloc = { version = "0.2", features = ["global"] } - -[profile.release] -opt-level = "s" -lto = true -strip = true diff --git a/examples/plugins/auto-tagger/plugin.toml b/examples/plugins/auto-tagger/plugin.toml deleted file mode 100644 index 24354f4..0000000 --- a/examples/plugins/auto-tagger/plugin.toml +++ /dev/null @@ -1,13 +0,0 @@ -[plugin] -name = "auto-tagger" -version = "1.0.0" -api_version = "1.0" -description = "Listens for MediaImported events and emits AutoTagSuggested events based on path pattern rules" -kind = ["event_handler"] -priority = 500 - -[plugin.binary] -wasm = "auto_tagger.wasm" - -[capabilities] -network = false diff --git a/examples/plugins/auto-tagger/src/lib.rs b/examples/plugins/auto-tagger/src/lib.rs deleted file mode 100644 index 2f30527..0000000 --- a/examples/plugins/auto-tagger/src/lib.rs +++ /dev/null @@ -1,303 +0,0 @@ -//! Auto-tagger plugin for Pinakes. -//! -//! Listens for `MediaImported` events and, based on configurable path pattern -//! rules, emits `AutoTagSuggested` events. Rules map path substrings to tag -//! names. -//! -//! Configuration key `rules` expects a JSON array of objects: -//! `[{"pattern": "/music/", "tag": "music"}, ...]` -//! -//! If no config is present, built-in defaults are used: -//! - `/music/` -> `music` -//! - `/photos/` -> `photo` -//! - `/videos/` -> `video` -//! - `/books/` -> `book` -//! - `/documents/` -> `document` -//! -//! Build with: -//! RUSTFLAGS="" cargo build --target wasm32-unknown-unknown --release - -#![no_std] - -extern crate alloc; - -use alloc::{format, string::String, vec, vec::Vec}; -use core::alloc::Layout; - -#[global_allocator] -static ALLOC: dlmalloc::GlobalDlmalloc = dlmalloc::GlobalDlmalloc; - -#[panic_handler] -fn panic_handler(_info: &core::panic::PanicInfo) -> ! { - core::arch::wasm32::unreachable() -} - -// Host functions provided by the runtime -unsafe extern "C" { - fn host_set_result(ptr: i32, len: i32); - fn host_log(level: i32, ptr: i32, len: i32); - fn host_emit_event(type_ptr: i32, type_len: i32, payload_ptr: i32, payload_len: i32) -> i32; - fn host_get_config(key_ptr: i32, key_len: i32) -> i32; - fn host_get_buffer(dest_ptr: i32, dest_len: i32) -> i32; -} - -fn set_response(json: &[u8]) { - unsafe { - host_set_result(json.as_ptr() as i32, json.len() as i32); - } -} - -fn log_info(msg: &str) { - unsafe { - host_log(2, msg.as_ptr() as i32, msg.len() as i32); - } -} - -unsafe fn read_request(ptr: i32, len: i32) -> Vec { - if ptr < 0 || len <= 0 { - return Vec::new(); - } - let slice = unsafe { core::slice::from_raw_parts(ptr as *const u8, len as usize) }; - slice.to_vec() -} - -/// Extract a string value from a JSON object for a given key. -fn json_get_str<'a>(json: &'a [u8], key: &str) -> Option<&'a str> { - let json_str = core::str::from_utf8(json).ok()?; - let pattern = format!("\"{}\"", key); - let key_pos = json_str.find(&pattern)?; - let after_key = &json_str[key_pos + pattern.len()..]; - let after_colon = after_key.trim_start().strip_prefix(':')?; - let after_colon = after_colon.trim_start(); - - if after_colon.starts_with('"') { - let value_start = 1; - let value_end = after_colon[value_start..].find('"')?; - Some(&after_colon[value_start..value_start + value_end]) - } else { - None - } -} - -/// A single tagging rule: match `pattern` in path -> apply `tag`. -struct Rule { - pattern: String, - tag: String, -} - -/// Default rules used when no `rules` config key is present. -fn default_rules() -> Vec { - vec![ - Rule { pattern: String::from("/music/"), tag: String::from("music") }, - Rule { pattern: String::from("/photos/"), tag: String::from("photo") }, - Rule { pattern: String::from("/videos/"), tag: String::from("video") }, - Rule { pattern: String::from("/books/"), tag: String::from("book") }, - Rule { pattern: String::from("/documents/"), tag: String::from("document") }, - ] -} - -/// Parse the `rules` JSON array from the config buffer. -/// Expected format: `[{"pattern":"...","tag":"..."},...]` -/// Returns an empty vec on any parse failure (falls back to defaults). -fn parse_rules_json(data: &[u8]) -> Vec { - let text = match core::str::from_utf8(data) { - Ok(s) => s, - Err(_) => return Vec::new(), - }; - - let mut rules = Vec::new(); - // Walk through occurrences of "pattern" keys inside object literals. - let mut search = text; - while let Some(p_pos) = search.find("\"pattern\"") { - let after_p = &search[p_pos + 9..]; - let after_colon = match after_p.trim_start().strip_prefix(':') { - Some(s) => s.trim_start(), - None => { - search = &search[p_pos + 1..]; - continue; - } - }; - let pattern = if after_colon.starts_with('"') { - let inner = &after_colon[1..]; - match inner.find('"') { - Some(end) => String::from(&inner[..end]), - None => { - search = &search[p_pos + 1..]; - continue; - } - } - } else { - search = &search[p_pos + 1..]; - continue; - }; - - // Now search for "tag" after the current pattern position. - let remaining = &search[p_pos..]; - let tag = if let Some(t_pos) = remaining.find("\"tag\"") { - let after_t = &remaining[t_pos + 5..]; - let after_colon_t = match after_t.trim_start().strip_prefix(':') { - Some(s) => s.trim_start(), - None => { - search = &search[p_pos + 1..]; - continue; - } - }; - if after_colon_t.starts_with('"') { - let inner = &after_colon_t[1..]; - match inner.find('"') { - Some(end) => String::from(&inner[..end]), - None => { - search = &search[p_pos + 1..]; - continue; - } - } - } else { - search = &search[p_pos + 1..]; - continue; - } - } else { - search = &search[p_pos + 1..]; - continue; - }; - - rules.push(Rule { pattern, tag }); - search = &search[p_pos + 1..]; - } - - rules -} - -/// Load rules from config, falling back to defaults. -fn load_rules() -> Vec { - let key = b"rules"; - let size = unsafe { host_get_config(key.as_ptr() as i32, key.len() as i32) }; - if size <= 0 { - return default_rules(); - } - - let buf_size = size as usize; - let layout = match Layout::from_size_align(buf_size, 1) { - Ok(l) => l, - Err(_) => return default_rules(), - }; - let ptr = unsafe { alloc::alloc::alloc(layout) }; - if ptr.is_null() { - return default_rules(); - } - - let copied = unsafe { host_get_buffer(ptr as i32, size) }; - if copied <= 0 { - unsafe { alloc::alloc::dealloc(ptr, layout) }; - return default_rules(); - } - - let data = unsafe { core::slice::from_raw_parts(ptr, copied as usize) }; - let rules = parse_rules_json(data); - unsafe { alloc::alloc::dealloc(ptr, layout) }; - - if rules.is_empty() { - default_rules() - } else { - rules - } -} - -/// Escape a string for safe inclusion in a JSON string value. -fn json_escape(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for c in s.chars() { - match c { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - _ => out.push(c), - } - } - out -} - -#[unsafe(no_mangle)] -pub extern "C" fn alloc(size: i32) -> i32 { - if size <= 0 { - return 0; - } - unsafe { - let layout = match Layout::from_size_align(size as usize, 1) { - Ok(l) => l, - Err(_) => return -1, - }; - let ptr = alloc::alloc::alloc(layout); - if ptr.is_null() { - return -1; - } - ptr as i32 - } -} - -#[unsafe(no_mangle)] -pub extern "C" fn initialize() -> i32 { - log_info("auto-tagger initialized"); - 0 -} - -#[unsafe(no_mangle)] -pub extern "C" fn shutdown() -> i32 { - log_info("auto-tagger shutdown"); - 0 -} - -/// Returns the event types this handler is interested in. -#[unsafe(no_mangle)] -pub extern "C" fn interested_events(_ptr: i32, _len: i32) { - set_response(br#"["MediaImported"]"#); -} - -/// Handle a `MediaImported` event: check path against rules and emit tag events. -#[unsafe(no_mangle)] -pub extern "C" fn handle_event(ptr: i32, len: i32) { - let req = unsafe { read_request(ptr, len) }; - - let media_id = json_get_str(&req, "media_id").unwrap_or(""); - // The payload is nested; attempt to extract `path` from the top-level - // request or from a nested `payload` object. - let path = json_get_str(&req, "path").unwrap_or(""); - - let rules = load_rules(); - let mut matched_count = 0u32; - - for rule in &rules { - if !path.is_empty() && path.contains(rule.pattern.as_str()) { - let event_type = b"AutoTagSuggested"; - let payload = format!( - r#"{{"media_id":"{}","tag":"{}"}}"#, - json_escape(media_id), - json_escape(&rule.tag), - ); - unsafe { - host_emit_event( - event_type.as_ptr() as i32, - event_type.len() as i32, - payload.as_ptr() as i32, - payload.len() as i32, - ); - } - matched_count += 1; - } - } - - if matched_count > 0 { - let msg = format!( - "auto-tagger: matched {} rule(s) for path: {}", - matched_count, - path, - ); - log_info(&msg); - } else { - let msg = format!("auto-tagger: no rules matched for path: {}", path); - log_info(&msg); - } - - set_response(b"{}"); -} diff --git a/examples/plugins/cbz-comics/Cargo.lock b/examples/plugins/cbz-comics/Cargo.lock deleted file mode 100644 index 06ebc6a..0000000 Binary files a/examples/plugins/cbz-comics/Cargo.lock and /dev/null differ diff --git a/examples/plugins/cbz-comics/Cargo.toml b/examples/plugins/cbz-comics/Cargo.toml deleted file mode 100644 index 23319ed..0000000 --- a/examples/plugins/cbz-comics/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "cbz-comics" -version = "1.0.0" -edition = "2024" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -dlmalloc = { version = "0.2", features = ["global"] } -miniz_oxide = { version = "0.8", default-features = false, features = [ - "with-alloc", -] } - -[profile.release] -opt-level = "s" -lto = true -strip = true diff --git a/examples/plugins/cbz-comics/plugin.toml b/examples/plugins/cbz-comics/plugin.toml deleted file mode 100644 index e2f6e74..0000000 --- a/examples/plugins/cbz-comics/plugin.toml +++ /dev/null @@ -1,20 +0,0 @@ -[plugin] -name = "cbz-comics" -version = "1.0.0" -api_version = "1.0" -description = "Supports CBZ (Comic Book ZIP) and CBR files with metadata extraction and thumbnail generation" -kind = ["media_type", "metadata_extractor", "thumbnail_generator"] -priority = 500 - -[plugin.binary] -wasm = "cbz_comics.wasm" - -[capabilities] -network = false - -[capabilities.filesystem] -# Users must add their media root directories here. Example: -# read = ["/home/user/comics"] -# write = ["/home/user/.cache/pinakes/thumbnails"] -read = [] -write = [] diff --git a/examples/plugins/cbz-comics/src/lib.rs b/examples/plugins/cbz-comics/src/lib.rs deleted file mode 100644 index 98d8f7b..0000000 --- a/examples/plugins/cbz-comics/src/lib.rs +++ /dev/null @@ -1,742 +0,0 @@ -//! CBZ/CBR comics plugin for Pinakes. -//! -//! Registers comic book ZIP (`cbz`) and RAR (`cbr`) media types, extracts -//! metadata from CBZ archives (including `ComicInfo.xml` when present), and -//! generates thumbnails from the cover image. -//! -//! CBR is registered as a media type but metadata extraction is limited to -//! format detection only (RAR parsing is not implemented). -//! -//! ZIP parsing is implemented from scratch without external ZIP crates to keep -//! the WASM binary small. -//! -//! The `filesystem.read` and `filesystem.write` capabilities in `plugin.toml` -//! must be configured for the directories containing comic files and the -//! thumbnail output directory respectively. -//! -//! Build with: -//! RUSTFLAGS="" cargo build --target wasm32-unknown-unknown --release - -#![no_std] - -extern crate alloc; - -use alloc::{format, string::{String, ToString}, vec, vec::Vec}; -use core::alloc::Layout; - -#[global_allocator] -static ALLOC: dlmalloc::GlobalDlmalloc = dlmalloc::GlobalDlmalloc; - -#[panic_handler] -fn panic_handler(_info: &core::panic::PanicInfo) -> ! { - core::arch::wasm32::unreachable() -} - -// Host functions provided by the runtime -unsafe extern "C" { - fn host_set_result(ptr: i32, len: i32); - fn host_log(level: i32, ptr: i32, len: i32); - fn host_read_file(path_ptr: i32, path_len: i32) -> i32; - fn host_get_buffer(dest_ptr: i32, dest_len: i32) -> i32; - fn host_write_file(path_ptr: i32, path_len: i32, data_ptr: i32, data_len: i32) -> i32; -} - -fn set_response(json: &[u8]) { - unsafe { - host_set_result(json.as_ptr() as i32, json.len() as i32); - } -} - -fn log_info(msg: &str) { - unsafe { - host_log(2, msg.as_ptr() as i32, msg.len() as i32); - } -} - -unsafe fn read_request(ptr: i32, len: i32) -> Vec { - if ptr < 0 || len <= 0 { - return Vec::new(); - } - let slice = unsafe { core::slice::from_raw_parts(ptr as *const u8, len as usize) }; - slice.to_vec() -} - -/// Extract a string value from a JSON object for a given key. -fn json_get_str<'a>(json: &'a [u8], key: &str) -> Option<&'a str> { - let json_str = core::str::from_utf8(json).ok()?; - let pattern = format!("\"{}\"", key); - let key_pos = json_str.find(&pattern)?; - let after_key = &json_str[key_pos + pattern.len()..]; - let after_colon = after_key.trim_start().strip_prefix(':')?; - let after_colon = after_colon.trim_start(); - - if after_colon.starts_with('"') { - let value_start = 1; - let value_end = after_colon[value_start..].find('"')?; - Some(&after_colon[value_start..value_start + value_end]) - } else { - None - } -} - -/// Escape a string for safe inclusion in a JSON string value. -fn json_escape(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for c in s.chars() { - match c { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - _ => out.push(c), - } - } - out -} - -// 20 MB content read limit for comic archives -const MAX_FILE_BYTES: usize = 20 * 1024 * 1024; - -// ZIP signatures (little-endian u32) -const SIG_LOCAL_FILE: u32 = 0x04034b50; -const SIG_CENTRAL_DIR: u32 = 0x02014b50; -const SIG_EOCD: u32 = 0x06054b50; - -// Compression methods -const COMPRESS_STORE: u16 = 0; -const COMPRESS_DEFLATE: u16 = 8; - -/// Read a little-endian u16 from a byte slice at the given offset. -/// Returns `None` if out of bounds. -fn read_u16_le(data: &[u8], offset: usize) -> Option { - let b0 = *data.get(offset)? as u16; - let b1 = *data.get(offset + 1)? as u16; - Some(b0 | (b1 << 8)) -} - -/// Read a little-endian u32 from a byte slice at the given offset. -/// Returns `None` if out of bounds. -fn read_u32_le(data: &[u8], offset: usize) -> Option { - let b0 = *data.get(offset)? as u32; - let b1 = *data.get(offset + 1)? as u32; - let b2 = *data.get(offset + 2)? as u32; - let b3 = *data.get(offset + 3)? as u32; - Some(b0 | (b1 << 8) | (b2 << 16) | (b3 << 24)) -} - -/// Read a big-endian u16 from a byte slice at the given offset. -fn read_u16_be(data: &[u8], offset: usize) -> Option { - let b0 = *data.get(offset)? as u16; - let b1 = *data.get(offset + 1)? as u16; - Some((b0 << 8) | b1) -} - -/// Read a big-endian u32 from a byte slice at the given offset. -fn read_u32_be(data: &[u8], offset: usize) -> Option { - let b0 = *data.get(offset)? as u32; - let b1 = *data.get(offset + 1)? as u32; - let b2 = *data.get(offset + 2)? as u32; - let b3 = *data.get(offset + 3)? as u32; - Some((b0 << 24) | (b1 << 16) | (b2 << 8) | b3) -} - -/// A parsed central directory entry from a ZIP archive. -struct ZipEntry { - name: String, - compression: u16, - compressed_size: u32, - local_offset: u32, -} - -/// Find the End of Central Directory record offset by scanning backwards. -fn find_eocd(data: &[u8]) -> Option { - if data.len() < 22 { - return None; - } - // Scan backwards for the EOCD signature. The maximum comment size is - // 65535 bytes, so we only need to scan that far from the end. - let scan_start = if data.len() > 22 + 65535 { - data.len() - 22 - 65535 - } else { - 0 - }; - let mut i = data.len() - 22; - loop { - if read_u32_le(data, i) == Some(SIG_EOCD) { - return Some(i); - } - if i == scan_start { - break; - } - i -= 1; - } - None -} - -/// Parse all central directory entries from a ZIP archive. -fn parse_central_directory(data: &[u8]) -> Vec { - let mut entries = Vec::new(); - - let eocd_offset = match find_eocd(data) { - Some(o) => o, - None => return entries, - }; - - // EOCD layout (offsets relative to EOCD start): - // 0: signature (4) - // 4: disk number (2) - // 6: start disk (2) - // 8: entries on disk (2) - // 10: total entries (2) - // 12: central dir size (4) - // 16: central dir offset (4) - // 20: comment length (2) - let cd_offset = match read_u32_le(data, eocd_offset + 16) { - Some(o) => o as usize, - None => return entries, - }; - let total_entries = match read_u16_le(data, eocd_offset + 10) { - Some(n) => n as usize, - None => return entries, - }; - - let mut pos = cd_offset; - for _ in 0..total_entries { - if pos + 46 > data.len() { - break; - } - if read_u32_le(data, pos) != Some(SIG_CENTRAL_DIR) { - break; - } - - // Central directory entry layout: - // 0: signature (4) - // 4: version made by (2) - // 6: version needed (2) - // 8: flags (2) - // 10: compression (2) - // 12: mod time (2) - // 14: mod date (2) - // 16: crc32 (4) - // 20: compressed size (4) - // 24: uncompressed size (4) - // 28: filename length (2) - // 30: extra field length (2) - // 32: file comment length (2) - // 34: disk start (2) - // 36: internal attrs (2) - // 38: external attrs (4) - // 42: local header offset (4) - // 46: filename... - let compression = match read_u16_le(data, pos + 10) { Some(v) => v, None => break }; - let compressed_size = match read_u32_le(data, pos + 20) { Some(v) => v, None => break }; - // uncompressed_size at pos+24 is intentionally not stored; size comes from decompressor output. - let fname_len = match read_u16_le(data, pos + 28) { Some(v) => v as usize, None => break }; - let extra_len = match read_u16_le(data, pos + 30) { Some(v) => v as usize, None => break }; - let comment_len = match read_u16_le(data, pos + 32) { Some(v) => v as usize, None => break }; - let local_offset = match read_u32_le(data, pos + 42) { Some(v) => v, None => break }; - - let fname_start = pos + 46; - let fname_end = fname_start + fname_len; - if fname_end > data.len() { - break; - } - - let name = core::str::from_utf8(&data[fname_start..fname_end]) - .unwrap_or("") - .to_ascii_lowercase(); - - entries.push(ZipEntry { - name, - compression, - compressed_size, - local_offset, - }); - - pos = fname_end + extra_len + comment_len; - } - - entries -} - -/// Read raw bytes for a local file entry (the actual compressed/stored data). -/// Returns a slice into `data` containing the compressed bytes. -fn local_file_data<'a>(data: &'a [u8], entry: &ZipEntry) -> Option<&'a [u8]> { - let off = entry.local_offset as usize; - if off + 30 > data.len() { - return None; - } - if read_u32_le(data, off) != Some(SIG_LOCAL_FILE) { - return None; - } - - // Local file header layout: - // 0: signature (4) - // 4: version needed (2) - // 6: flags (2) - // 8: compression (2) - // 10: mod time (2) - // 12: mod date (2) - // 14: crc32 (4) - // 18: compressed size (4) - // 22: uncompressed size (4) - // 26: filename length (2) - // 28: extra length (2) - // 30: filename... - let fname_len = read_u16_le(data, off + 26)? as usize; - let extra_len = read_u16_le(data, off + 28)? as usize; - let data_start = off + 30 + fname_len + extra_len; - let data_end = data_start + entry.compressed_size as usize; - if data_end > data.len() { - return None; - } - Some(&data[data_start..data_end]) -} - -/// Decompress a stored (STORE) or deflated (DEFLATE) entry. -/// Returns the uncompressed bytes. -fn decompress_entry(data: &[u8], entry: &ZipEntry) -> Option> { - let raw = local_file_data(data, entry)?; - match entry.compression { - COMPRESS_STORE => Some(raw.to_vec()), - COMPRESS_DEFLATE => { - miniz_oxide::inflate::decompress_to_vec(raw).ok() - } - _ => None, - } -} - -/// Returns true if a filename has an image extension. -fn is_image_filename(name: &str) -> bool { - name.ends_with(".jpg") - || name.ends_with(".jpeg") - || name.ends_with(".png") - || name.ends_with(".webp") -} - -/// Extract a simple XML element value using substring search. -/// Looks for `value` and returns the inner text. -fn xml_get_text<'a>(xml: &'a str, tag: &str) -> Option<&'a str> { - let open = format!("<{}>", tag); - let close = format!("", tag); - let start = xml.find(&open)?; - let after_open = &xml[start + open.len()..]; - let end = after_open.find(&close)?; - Some(&after_open[..end]) -} - -/// Metadata extracted from a ComicInfo.xml file. -struct ComicInfo { - title: Option, - series: Option, - issue_number: Option, - writer: Option, - page_count: Option, - language: Option, - genre: Option, - summary: Option, -} - -/// Parse key fields from a ComicInfo.xml byte slice. -fn parse_comic_info(data: &[u8]) -> ComicInfo { - let text = core::str::from_utf8(data).unwrap_or(""); - ComicInfo { - title: xml_get_text(text, "Title") .map(|s| s.trim().to_ascii_lowercase()).filter(|s| !s.is_empty()).map(|s| { - // Re-capitalize first letter for title - let mut c = s.chars(); - match c.next() { - None => String::new(), - Some(f) => f.to_uppercase().collect::() + c.as_str(), - } - }), - series: xml_get_text(text, "Series") .map(|s| s.trim().to_string()).filter(|s| !s.is_empty()), - issue_number: xml_get_text(text, "Number") .map(|s| s.trim().to_string()).filter(|s| !s.is_empty()), - writer: xml_get_text(text, "Writer") .map(|s| s.trim().to_string()).filter(|s| !s.is_empty()), - page_count: xml_get_text(text, "PageCount") .map(|s| s.trim().to_string()).filter(|s| !s.is_empty()), - language: xml_get_text(text, "LanguageISO").map(|s| s.trim().to_string()).filter(|s| !s.is_empty()), - genre: xml_get_text(text, "Genre") .map(|s| s.trim().to_string()).filter(|s| !s.is_empty()), - summary: xml_get_text(text, "Summary") .map(|s| s.trim().to_string()).filter(|s| !s.is_empty()), - } -} - -/// Image dimension and format information. -struct ImageInfo { - width: u32, - height: u32, - format: &'static str, -} - -/// Parse image dimensions and detect format from raw image bytes. -fn parse_image_info(data: &[u8]) -> Option { - if data.len() < 4 { - return None; - } - // JPEG: starts with 0xFF 0xD8 - if data[0] == 0xFF && data[1] == 0xD8 { - // Scan for SOF0 (0xFF 0xC0) or SOF2 (0xFF 0xC2) marker - let mut i = 2usize; - while i + 8 < data.len() { - if data[i] == 0xFF { - let marker = data[i + 1]; - if marker == 0xC0 || marker == 0xC2 { - // SOF marker layout: - // 0: 0xFF - // 1: marker - // 2-3: segment length (big-endian) - // 4: precision - // 5-6: height (big-endian u16) - // 7-8: width (big-endian u16) - let height = read_u16_be(data, i + 5)? as u32; - let width = read_u16_be(data, i + 7)? as u32; - return Some(ImageInfo { width, height, format: "jpeg" }); - } else if marker == 0xFF { - // Padding byte - i += 1; - continue; - } else if marker == 0xD8 || marker == 0xD9 { - // SOI / EOI - no length field - i += 2; - continue; - } else { - // Skip segment: length at i+2 (includes the 2 length bytes) - if let Some(seg_len) = read_u16_be(data, i + 2) { - i += 2 + seg_len as usize; - } else { - break; - } - } - } else { - i += 1; - } - } - // Return a JPEG without dimensions if SOF not found - return Some(ImageInfo { width: 0, height: 0, format: "jpeg" }); - } - - // PNG: starts with 0x89 0x50 0x4E 0x47 ('PNG') - if data.len() >= 24 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 { - // IHDR chunk: width at bytes 16-19, height at bytes 20-23 (big-endian u32) - let width = read_u32_be(data, 16)?; - let height = read_u32_be(data, 20)?; - return Some(ImageInfo { width, height, format: "png" }); - } - - // WebP: RIFF....WEBP - if data.len() >= 12 - && &data[0..4] == b"RIFF" - && &data[8..12] == b"WEBP" - { - return Some(ImageInfo { width: 0, height: 0, format: "webp" }); - } - - None -} - -/// Load a CBZ archive into memory. Returns the raw bytes or an error string. -fn load_cbz_file(path: &str) -> Result, &'static str> { - let file_size = unsafe { host_read_file(path.as_ptr() as i32, path.len() as i32) }; - if file_size < 0 { - return Err("read failed"); - } - if file_size as usize >= MAX_FILE_BYTES { - return Err("too large"); - } - let buf_size = file_size as usize; - if buf_size == 0 { - return Ok(Vec::new()); - } - - let layout = Layout::from_size_align(buf_size, 1).map_err(|_| "alloc failed")?; - let buf_ptr = unsafe { alloc::alloc::alloc(layout) }; - if buf_ptr.is_null() { - return Err("alloc failed"); - } - - let copied = unsafe { host_get_buffer(buf_ptr as i32, file_size) }; - if copied <= 0 { - unsafe { alloc::alloc::dealloc(buf_ptr, layout) }; - return Err("buffer copy failed"); - } - - let data = unsafe { core::slice::from_raw_parts(buf_ptr, copied as usize) }.to_vec(); - unsafe { alloc::alloc::dealloc(buf_ptr, layout) }; - Ok(data) -} - -#[unsafe(no_mangle)] -pub extern "C" fn alloc(size: i32) -> i32 { - if size <= 0 { - return 0; - } - unsafe { - let layout = match Layout::from_size_align(size as usize, 1) { - Ok(l) => l, - Err(_) => return -1, - }; - let ptr = alloc::alloc::alloc(layout); - if ptr.is_null() { - return -1; - } - ptr as i32 - } -} - -#[unsafe(no_mangle)] -pub extern "C" fn initialize() -> i32 { - log_info("cbz-comics initialized"); - 0 -} - -#[unsafe(no_mangle)] -pub extern "C" fn shutdown() -> i32 { - log_info("cbz-comics shutdown"); - 0 -} - -/// Returns the comic media type definitions. -#[unsafe(no_mangle)] -pub extern "C" fn supported_media_types(_ptr: i32, _len: i32) { - let response = br#"[ -{"id":"comic-cbz","name":"Comic Book ZIP","category":"document","extensions":["cbz"],"mime_types":["application/vnd.comicbook+zip"]}, -{"id":"comic-cbr","name":"Comic Book RAR","category":"document","extensions":["cbr"],"mime_types":["application/vnd.comicbook-rar"]} -]"#; - set_response(response); -} - -/// Check whether this plugin can handle a given path. -#[unsafe(no_mangle)] -pub extern "C" fn can_handle(ptr: i32, len: i32) { - let req = unsafe { read_request(ptr, len) }; - let path = json_get_str(&req, "path").unwrap_or("").to_ascii_lowercase(); - let can = path.ends_with(".cbz") || path.ends_with(".cbr"); - if can { - set_response(br#"{"can_handle":true}"#); - } else { - set_response(br#"{"can_handle":false}"#); - } -} - -/// Returns the media type IDs this extractor supports. -#[unsafe(no_mangle)] -pub extern "C" fn supported_types(_ptr: i32, _len: i32) { - set_response(br#"["comic-cbz","comic-cbr"]"#); -} - -/// Extract metadata from a CBZ or CBR file. -#[unsafe(no_mangle)] -pub extern "C" fn extract_metadata(ptr: i32, len: i32) { - let req = unsafe { read_request(ptr, len) }; - let path = match json_get_str(&req, "path") { - Some(p) => p, - None => { - set_response(br#"{"extra":{"error":"missing path"}}"#); - return; - } - }; - - let lower = path.to_ascii_lowercase(); - - // CBR: register the type but do not attempt to parse RAR. - if lower.ends_with(".cbr") { - set_response(br#"{"extra":{"format":"cbr","note":"cbr-unsupported"}}"#); - return; - } - - // Load CBZ archive - let data = match load_cbz_file(path) { - Ok(d) => d, - Err("too large") => { - set_response(br#"{"extra":{"format":"cbz","too_large":"true"}}"#); - return; - } - Err(e) => { - let resp = format!(r#"{{"extra":{{"format":"cbz","error":"{}"}}}}"#, e); - set_response(resp.as_bytes()); - return; - } - }; - - let entries = parse_central_directory(&data); - - // Count image files as page count. - let image_count = entries.iter().filter(|e| is_image_filename(&e.name)).count(); - - // Look for ComicInfo.xml (case-insensitive). - let comic_info_entry = entries.iter().find(|e| { - let n = e.name.as_str(); - n == "comicinfo.xml" || n.ends_with("/comicinfo.xml") - }); - - let info = if let Some(entry) = comic_info_entry { - // Only decompress STORE entries here for simplicity; skip DEFLATE ones. - if entry.compression == COMPRESS_STORE || entry.compression == COMPRESS_DEFLATE { - if let Some(xml_bytes) = decompress_entry(&data, entry) { - Some(parse_comic_info(&xml_bytes)) - } else { - None - } - } else { - None - } - } else { - None - }; - - let msg = format!( - "cbz-comics: {} entries, {} images, ComicInfo.xml={}", - entries.len(), - image_count, - info.is_some(), - ); - log_info(&msg); - - // Build response JSON - let mut extra_pairs: Vec<(&str, String)> = vec![ - ("format", String::from("cbz")), - ]; - - let page_count_str; - if let Some(ref ci) = info { - if let Some(ref pc) = ci.page_count { - page_count_str = pc.clone(); - extra_pairs.push(("page_count", page_count_str.clone())); - } else { - page_count_str = format!("{}", image_count); - extra_pairs.push(("page_count", page_count_str.clone())); - } - if let Some(ref s) = ci.series { extra_pairs.push(("series", s.clone())) } - if let Some(ref n) = ci.issue_number { extra_pairs.push(("issue_number", n.clone())) } - if let Some(ref l) = ci.language { extra_pairs.push(("language", l.clone())) } - } else { - page_count_str = format!("{}", image_count); - extra_pairs.push(("page_count", page_count_str.clone())); - } - - // Build extra JSON object - let mut extra_json = String::from("{"); - for (i, (k, v)) in extra_pairs.iter().enumerate() { - if i > 0 { extra_json.push(','); } - extra_json.push('"'); - extra_json.push_str(k); - extra_json.push_str("\":\""); - extra_json.push_str(&json_escape(v)); - extra_json.push('"'); - } - extra_json.push('}'); - - let title_field = info.as_ref() - .and_then(|ci| ci.title.as_ref()) - .map(|t| format!(r#","title":"{}""#, json_escape(t))) - .unwrap_or_default(); - - let artist_field = info.as_ref() - .and_then(|ci| ci.writer.as_ref()) - .map(|w| format!(r#","artist":"{}""#, json_escape(w))) - .unwrap_or_default(); - - let genre_field = info.as_ref() - .and_then(|ci| ci.genre.as_ref()) - .map(|g| format!(r#","genre":"{}""#, json_escape(g))) - .unwrap_or_default(); - - let desc_field = info.as_ref() - .and_then(|ci| ci.summary.as_ref()) - .map(|s| format!(r#","description":"{}""#, json_escape(s))) - .unwrap_or_default(); - - let resp = format!( - r#"{{"extra":{}{}{}{}{}}}"#, - extra_json, title_field, artist_field, genre_field, desc_field, - ); - set_response(resp.as_bytes()); -} - -/// Generate a thumbnail from the cover image of a CBZ archive. -#[unsafe(no_mangle)] -pub extern "C" fn generate_thumbnail(ptr: i32, len: i32) { - let req = unsafe { read_request(ptr, len) }; - let source_path = match json_get_str(&req, "source_path") { - Some(p) => p, - None => { - set_response(br#"{"path":"","width":0,"height":0,"format":"jpeg"}"#); - return; - } - }; - let output_path = match json_get_str(&req, "output_path") { - Some(p) => p, - None => { - set_response(br#"{"path":"","width":0,"height":0,"format":"jpeg"}"#); - return; - } - }; - - let lower = source_path.to_ascii_lowercase(); - if !lower.ends_with(".cbz") { - set_response(br#"{"path":"","width":0,"height":0,"format":"unknown"}"#); - return; - } - - let data = match load_cbz_file(source_path) { - Ok(d) => d, - Err(_) => { - set_response(br#"{"path":"","width":0,"height":0,"format":"jpeg"}"#); - return; - } - }; - - let entries = parse_central_directory(&data); - - // Find alphabetically first image file for the cover. - let mut image_entries: Vec<&ZipEntry> = entries.iter().filter(|e| is_image_filename(&e.name)).collect(); - image_entries.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str())); - - let cover = match image_entries.first() { - Some(e) => e, - None => { - set_response(br#"{"path":"","width":0,"height":0,"format":"jpeg"}"#); - return; - } - }; - - let image_bytes = match decompress_entry(&data, cover) { - Some(b) => b, - None => { - set_response(br#"{"path":"","width":0,"height":0,"format":"jpeg"}"#); - return; - } - }; - - let info = parse_image_info(&image_bytes).unwrap_or(ImageInfo { - width: 0, height: 0, format: "jpeg", - }); - - // Write thumbnail bytes to output path - let write_result = unsafe { - host_write_file( - output_path.as_ptr() as i32, - output_path.len() as i32, - image_bytes.as_ptr() as i32, - image_bytes.len() as i32, - ) - }; - if write_result < 0 { - set_response(br#"{"path":"","width":0,"height":0,"format":"jpeg"}"#); - return; - } - - let msg = format!( - "cbz-comics: thumbnail {}x{} {} written to {}", - info.width, info.height, info.format, output_path, - ); - log_info(&msg); - - let resp = format!( - r#"{{"path":"{}","width":{},"height":{},"format":"{}"}}"#, - json_escape(output_path), - info.width, - info.height, - info.format, - ); - set_response(resp.as_bytes()); -} diff --git a/examples/plugins/heif-support/README.md b/examples/plugins/heif-support/README.md new file mode 100644 index 0000000..b64a002 --- /dev/null +++ b/examples/plugins/heif-support/README.md @@ -0,0 +1,257 @@ +# HEIF/HEIC Support Plugin + +This example plugin adds support for HEIF (High Efficiency Image Format) and HEIC (HEIF Container) to Pinakes. + +## Overview + +HEIF is a modern image format that provides better compression than JPEG while maintaining higher quality. This plugin enables Pinakes to: +- Recognize HEIF/HEIC files as a media type +- Extract metadata from HEIF images +- Generate thumbnails from HEIF images + +## Features + +- **Media Type Registration**: Registers `.heif`, `.heic`, `.hif` extensions as image media types +- **EXIF Extraction**: Extracts EXIF metadata including camera info, GPS coordinates, timestamps +- **Thumbnail Generation**: Generates thumbnails in JPEG, PNG, or WebP format +- **Resource Limits**: Configurable memory and CPU limits for safe processing +- **Large Image Support**: Handles images up to 8192x8192 pixels + +## Supported Formats + +- **HEIF**: High Efficiency Image Format (`.heif`, `.hif`) +- **HEIC**: HEIF Container format used by Apple devices (`.heic`) +- **HEIF Sequences**: Multi-image HEIF files +- **HEIF with Alpha**: HEIF images with transparency + +## Implementation + +The plugin implements three traits: + +### MediaTypeProvider + +```rust +#[async_trait] +impl MediaTypeProvider for HeifPlugin { + fn supported_media_types(&self) -> Vec { + vec![MediaTypeDefinition { + id: "heif".to_string(), + name: "HEIF Image".to_string(), + category: "image".to_string(), + extensions: vec!["heif".to_string(), "heic".to_string(), "hif".to_string()], + mime_types: vec!["image/heif".to_string(), "image/heic".to_string()], + icon: Some("image".to_string()), + }] + } + + async fn can_handle(&self, path: &PathBuf, mime_type: Option<&str>) -> PluginResult { + // Check file extension and/or MIME type + } +} +``` + +### MetadataExtractor + +```rust +#[async_trait] +impl MetadataExtractor for HeifPlugin { + async fn extract_metadata(&self, path: &PathBuf) -> PluginResult { + // 1. Parse HEIF file structure + // 2. Extract EXIF metadata + // 3. Get image dimensions + // 4. Return ExtractedMetadata + } + + fn supported_types(&self) -> Vec { + vec!["heif".to_string()] + } +} +``` + +### ThumbnailGenerator + +```rust +#[async_trait] +impl ThumbnailGenerator for HeifPlugin { + async fn generate_thumbnail( + &self, + path: &PathBuf, + output_path: &PathBuf, + options: ThumbnailOptions, + ) -> PluginResult { + // 1. Decode HEIF image + // 2. Resize to thumbnail dimensions + // 3. Encode to output format + // 4. Save to output_path + // 5. Return ThumbnailInfo + } + + fn supported_types(&self) -> Vec { + vec!["heif".to_string()] + } +} +``` + +## Dependencies + +The plugin uses the following Rust crates (compiled to WASM): +- `libheif-rs`: HEIF decoding and encoding +- `image`: Image processing and thumbnail generation +- `kamadak-exif`: EXIF metadata parsing + +## Building + +### Prerequisites + +```bash +# Install WASM target +rustup target add wasm32-wasi + +# Install wasm-tools for optimization (provides strip functionality) +cargo install wasm-tools +``` + +### Build Process + +```bash +# Build the plugin +cargo build --target wasm32-wasi --release + +# Strip debug symbols to reduce size +wasm-tools strip target/wasm32-wasi/release/heif_support.wasm -o target/wasm32-wasi/release/heif_support.wasm + +# Copy to plugin directory +cp target/wasm32-wasi/release/heif_support.wasm . +``` + +### Size Optimization + +```bash +# Use wasm-opt for further optimization +wasm-opt -Oz heif_support.wasm -o heif_support_optimized.wasm +``` + +## Installation + +### Manual Installation + +```bash +# Copy plugin directory to Pinakes plugins directory +cp -r examples/plugins/heif-support ~/.config/pinakes/plugins/ +``` + +### Via API + +```bash +curl -X POST http://localhost:3000/api/v1/plugins/install \ + -H "Content-Type: application/json" \ + -d '{"source": "/path/to/heif-support"}' +``` + +### Via Plugin Manager + +```bash +pinakes plugin install /path/to/heif-support +``` + +## Configuration + +The plugin can be configured through the `config` section in `plugin.toml`: + +### EXIF Extraction +- `extract_exif`: Enable EXIF metadata extraction (default: true) + +### Thumbnail Generation +- `generate_thumbnails`: Enable thumbnail generation (default: true) +- `thumbnail_quality`: JPEG quality for thumbnails, 1-100 (default: 85) +- `thumbnail_format`: Output format - "jpeg", "png", or "webp" (default: "jpeg") + +### Resource Limits +- `max_memory_mb`: Maximum memory the plugin can use in megabytes (default: 256, set in `[capabilities]`) +- `max_width`: Maximum image width to process (default: 8192) +- `max_height`: Maximum image height to process (default: 8192) + +## Security + +### Capabilities + +- **Filesystem Read**: Only files being processed (via Pinakes) +- **Filesystem Write**: Thumbnail directory only +- **Network**: Disabled +- **Environment**: No access + +### Resource Limits + +- **Memory**: 256 MB maximum +- **CPU Time**: 30 seconds maximum per operation + +### Sandboxing + +The plugin runs in a WASM sandbox with: +- No access to host filesystem beyond granted paths +- No network access +- No arbitrary code execution +- Memory and CPU time limits enforced by runtime + +## Performance + +### Typical Performance + +- **Metadata Extraction**: ~50-100ms for typical HEIF files +- **Thumbnail Generation**: ~200-500ms depending on source image size +- **Memory Usage**: 50-150 MB typical, 256 MB maximum + +### Optimization Tips + +1. Keep source images below 8192x8192 for best performance +2. Use JPEG thumbnail format for smaller file sizes +3. Adjust thumbnail quality vs file size tradeoff with `thumbnail_quality` + +## Error Handling + +The plugin handles: +- **Corrupted Files**: Returns descriptive error +- **Unsupported Variants**: Gracefully skips unsupported HEIF features +- **Memory Limits**: Fails safely if image too large +- **Timeout**: Returns error if processing exceeds CPU time limit + +## Testing + +```bash +# Run unit tests +cargo test + +# Test with sample HEIF files +cargo test --test integration -- --nocapture +``` + +## Troubleshooting + +### Plugin Fails to Load + +- Check that `heif_support.wasm` exists in plugin directory +- Verify `plugin.toml` is valid TOML +- Check Pinakes logs for detailed error messages + +### Thumbnails Not Generated + +- Verify `generate_thumbnails = true` in config +- Check filesystem write permissions for thumbnail directory +- Ensure source image is below size limits + +### Out of Memory Errors + +- Reduce `max_width` and `max_height` in config +- Increase `max_memory_mb` if source images are large +- Check that source files aren't corrupted + +## Future Enhancements + +- Support for HEIF image sequences (burst photos) +- HDR metadata extraction +- Live Photo support +- AVIF format support (similar to HEIF) + +## License + +MIT diff --git a/examples/plugins/heif-support/plugin.toml b/examples/plugins/heif-support/plugin.toml new file mode 100644 index 0000000..6f30eb1 --- /dev/null +++ b/examples/plugins/heif-support/plugin.toml @@ -0,0 +1,29 @@ +[plugin] +name = "heif-support" +version = "1.0.0" +api_version = "1.0" +author = "Pinakes Contributors" +description = "HEIF/HEIC image format support for Pinakes" +homepage = "https://github.com/notashelf/pinakes" +license = "MIT" +kind = ["media_type", "metadata_extractor", "thumbnail_generator"] + +[plugin.binary] +wasm = "heif_support.wasm" + +[capabilities] +network = false +max_memory_mb = 256 +max_cpu_time_secs = 30 + +[capabilities.filesystem] +read = ["/media"] +write = ["/tmp/pinakes"] + +[config] +extract_exif = true +generate_thumbnails = true +thumbnail_quality = 85 +thumbnail_format = "jpeg" +max_width = 8192 +max_height = 8192 diff --git a/examples/plugins/markdown-metadata/README.md b/examples/plugins/markdown-metadata/README.md new file mode 100644 index 0000000..91f2f4c --- /dev/null +++ b/examples/plugins/markdown-metadata/README.md @@ -0,0 +1,103 @@ +# Markdown Metadata Extractor Plugin + +This example plugin demonstrates how to create a metadata extractor plugin for Pinakes. + +## Overview + +The Markdown Metadata Extractor enhances Pinakes' built-in markdown support by: +- Parsing YAML and TOML frontmatter +- Extracting metadata from frontmatter fields +- Converting frontmatter tags to Pinakes media tags +- Extracting custom fields from frontmatter + +## Features + +- **Frontmatter Parsing**: Supports both YAML (`---`) and TOML (`+++`) frontmatter formats +- **Tag Extraction**: Automatically extracts tags from frontmatter and applies them to media items +- **Custom Fields**: Preserves all frontmatter fields as custom metadata +- **Configuration**: Configurable via `plugin.toml` config section + +## Frontmatter Example + +```markdown +--- +title: "My Document" +author: "John Doe" +date: "2024-01-15" +tags: ["documentation", "example", "markdown"] +category: "tutorials" +draft: false +--- + +# My Document + +Content goes here... +``` + +## Implementation + +The plugin implements the `MetadataExtractor` trait from `pinakes-plugin-api`: + +```rust +#[async_trait] +impl MetadataExtractor for MarkdownMetadataPlugin { + async fn extract_metadata(&self, path: &PathBuf) -> PluginResult { + // 1. Read the file + // 2. Parse frontmatter + // 3. Extract metadata fields + // 4. Return ExtractedMetadata + } + + fn supported_types(&self) -> Vec { + vec!["markdown".to_string()] + } +} +``` + +## Building + +The plugin is compiled to WebAssembly: + +```bash +cargo build --target wasm32-wasi --release +wasm-strip target/wasm32-wasi/release/markdown_metadata.wasm +cp target/wasm32-wasi/release/markdown_metadata.wasm . +``` + +## Installation + +```bash +# Copy plugin directory to Pinakes plugins directory +cp -r examples/plugins/markdown-metadata ~/.config/pinakes/plugins/ + +# Or via API +curl -X POST http://localhost:3000/api/v1/plugins/install \ + -H "Content-Type: application/json" \ + -d '{"source": "/path/to/markdown-metadata"}' +``` + +## Configuration + +The plugin can be configured through the `config` section in `plugin.toml`: + +- `extract_tags`: Extract tags from frontmatter (default: true) +- `parse_yaml`: Parse YAML frontmatter (default: true) +- `parse_toml`: Parse TOML frontmatter (default: true) +- `max_file_size`: Maximum file size to process in bytes (default: 10MB) + +## Security + +This plugin has minimal capabilities: +- **Filesystem**: No write access, read access only to files being processed +- **Network**: Disabled +- **Environment**: No access + +## Testing + +```bash +cargo test +``` + +## License + +MIT diff --git a/examples/plugins/markdown-metadata/plugin.toml b/examples/plugins/markdown-metadata/plugin.toml new file mode 100644 index 0000000..a558095 --- /dev/null +++ b/examples/plugins/markdown-metadata/plugin.toml @@ -0,0 +1,25 @@ +[plugin] +name = "markdown-metadata" +version = "1.0.0" +api_version = "1.0" +author = "Pinakes Contributors" +description = "Extract metadata from Markdown files with YAML frontmatter" +homepage = "https://github.com/notashelf/pinakes" +license = "MIT" +kind = ["metadata_extractor"] + +[plugin.binary] +wasm = "markdown_metadata.wasm" + +[capabilities] +network = false + +[capabilities.filesystem] +read = [] +write = [] + +[config] +extract_tags = true +parse_yaml = true +parse_toml = true +max_file_size = 10485760 diff --git a/examples/plugins/auto-tagger/Cargo.lock b/examples/plugins/media-stats-ui/Cargo.lock similarity index 90% rename from examples/plugins/auto-tagger/Cargo.lock rename to examples/plugins/media-stats-ui/Cargo.lock index a398a38..882e3ef 100644 Binary files a/examples/plugins/auto-tagger/Cargo.lock and b/examples/plugins/media-stats-ui/Cargo.lock differ diff --git a/examples/plugins/media-stats-ui/Cargo.toml b/examples/plugins/media-stats-ui/Cargo.toml new file mode 100644 index 0000000..f3004cc --- /dev/null +++ b/examples/plugins/media-stats-ui/Cargo.toml @@ -0,0 +1,20 @@ +[workspace] + +[package] +name = "media-stats-ui" +version = "1.0.0" +edition = "2024" +description = "Library statistics dashboard and tag manager, a UI-only Pinakes plugin" +license = "EUPL-1.2" + +[lib] +name = "media_stats_ui" +crate-type = ["cdylib"] + +[dependencies] +dlmalloc = { version = "0.2.12", features = ["global"] } + +[profile.release] +opt-level = "s" +lto = true +strip = true diff --git a/examples/plugins/media-stats-ui/pages/stats.json b/examples/plugins/media-stats-ui/pages/stats.json new file mode 100644 index 0000000..6f860c5 --- /dev/null +++ b/examples/plugins/media-stats-ui/pages/stats.json @@ -0,0 +1,132 @@ +{ + "id": "stats", + "title": "Library Statistics", + "route": "/plugins/media-stats-ui/stats", + "icon": "chart-bar", + "layout": { + "type": "tabs", + "default_tab": 0, + "tabs": [ + { + "label": "Overview", + "content": { + "type": "container", + "gap": 24, + "children": [ + { + "type": "heading", + "level": 2, + "content": "Library Statistics" + }, + { + "type": "text", + "content": "Live summary of your media library. Refreshes every 30 seconds.", + "variant": "secondary" + }, + { + "type": "card", + "title": "Summary", + "content": [ + { + "type": "description_list", + "data": "stats", + "horizontal": true + } + ] + }, + { + "type": "chart", + "chart_type": "bar", + "data": "type-breakdown", + "title": "Files by Type", + "x_axis_label": "Media Type", + "y_axis_label": "Count", + "height": 280 + } + ] + } + }, + { + "label": "Recent Files", + "content": { + "type": "container", + "gap": 16, + "children": [ + { + "type": "heading", + "level": 2, + "content": "Recently Added" + }, + { + "type": "data_table", + "data": "recent", + "sortable": true, + "filterable": true, + "page_size": 10, + "columns": [ + { + "key": "file_name", + "header": "Filename" + }, + { + "key": "title", + "header": "Title" + }, + { + "key": "media_type", + "header": "Type" + }, + { + "key": "file_size", + "header": "Size", + "data_type": "file_size" + }, + { + "key": "created_at", + "header": "Added", + "data_type": "date_time" + } + ] + } + ] + } + }, + { + "label": "Media Grid", + "content": { + "type": "container", + "gap": 16, + "children": [ + { + "type": "heading", + "level": 2, + "content": "Browse Media" + }, + { + "type": "media_grid", + "data": "recent", + "columns": 4, + "gap": 12 + } + ] + } + } + ] + }, + "data_sources": { + "stats": { + "type": "endpoint", + "path": "/api/v1/statistics", + "poll_interval": 30 + }, + "recent": { + "type": "endpoint", + "path": "/api/v1/media" + }, + "type-breakdown": { + "type": "transform", + "source": "stats", + "expression": "stats.media_by_type" + } + } +} diff --git a/examples/plugins/media-stats-ui/pages/tag-manager.json b/examples/plugins/media-stats-ui/pages/tag-manager.json new file mode 100644 index 0000000..30b3c2f --- /dev/null +++ b/examples/plugins/media-stats-ui/pages/tag-manager.json @@ -0,0 +1,126 @@ +{ + "id": "tag-manager", + "title": "Tag Manager", + "route": "/plugins/media-stats-ui/tag-manager", + "icon": "tag", + "layout": { + "type": "tabs", + "default_tab": 0, + "tabs": [ + { + "label": "All Tags", + "content": { + "type": "container", + "gap": 16, + "children": [ + { + "type": "heading", + "level": 2, + "content": "Manage Tags" + }, + { + "type": "conditional", + "condition": { + "op": "eq", + "left": { "function": "len", "args": ["tags"] }, + "right": 0 + }, + "then": { + "type": "text", + "content": "No tags yet. Use the 'Create Tag' tab to add one.", + "variant": "secondary" + }, + "else": { + "type": "data_table", + "data": "tags", + "sortable": true, + "filterable": true, + "page_size": 20, + "columns": [ + { "key": "name", "header": "Tag Name" }, + { "key": "color", "header": "Color" }, + { "key": "item_count", "header": "Items", "data_type": "number" } + ] + } + } + ] + } + }, + { + "label": "Create Tag", + "content": { + "type": "container", + "gap": 24, + "children": [ + { + "type": "heading", + "level": 2, + "content": "Create New Tag" + }, + { + "type": "text", + "content": "Tags are used to organise media items. Choose a name and an optional colour.", + "variant": "secondary" + }, + { + "type": "form", + "submit_label": "Create Tag", + "submit_action": "create-tag", + "cancel_label": "Reset", + "fields": [ + { + "id": "name", + "label": "Tag Name", + "type": { "type": "text", "max_length": 64 }, + "required": true, + "placeholder": "e.g. favourite, to-watch, archived", + "help_text": "Must be unique. Alphanumeric characters, spaces, and hyphens.", + "validation": [ + { "type": "min_length", "value": 1 }, + { "type": "max_length", "value": 64 }, + { "type": "pattern", "regex": "^[a-zA-Z0-9 \\-]+$" } + ] + }, + { + "id": "color", + "label": "Colour", + "type": { + "type": "select", + "options": [ + { "value": "#ef4444", "label": "Red" }, + { "value": "#f97316", "label": "Orange" }, + { "value": "#eab308", "label": "Yellow" }, + { "value": "#22c55e", "label": "Green" }, + { "value": "#3b82f6", "label": "Blue" }, + { "value": "#8b5cf6", "label": "Purple" }, + { "value": "#ec4899", "label": "Pink" }, + { "value": "#6b7280", "label": "Grey" } + ] + }, + "required": false, + "default_value": "#3b82f6", + "help_text": "Optional accent colour shown beside the tag." + } + ] + } + ] + } + } + ] + }, + "data_sources": { + "tags": { + "type": "endpoint", + "path": "/api/v1/tags", + "poll_interval": 0 + } + }, + "actions": { + "create-tag": { + "method": "POST", + "path": "/api/v1/tags", + "success_message": "Tag created successfully!", + "error_message": "Failed to create tag: the name may already be in use." + } + } +} diff --git a/examples/plugins/media-stats-ui/plugin.toml b/examples/plugins/media-stats-ui/plugin.toml new file mode 100644 index 0000000..0e8116a --- /dev/null +++ b/examples/plugins/media-stats-ui/plugin.toml @@ -0,0 +1,39 @@ +[plugin] +name = "media-stats-ui" +version = "1.0.0" +api_version = "1.0" +author = "Pinakes Contributors" +description = "Library statistics dashboard and tag manager UI plugin" +homepage = "https://github.com/notashelf/pinakes" +license = "EUPL-1.2" +kind = ["ui_page"] + +[plugin.binary] +wasm = "target/wasm32-unknown-unknown/release/media_stats_ui.wasm" + +[capabilities] +network = false + +[capabilities.filesystem] +read = [] +write = [] + +[ui] +required_endpoints = ["/api/v1/statistics", "/api/v1/media", "/api/v1/tags"] + +# UI pages +[[ui.pages]] +file = "pages/stats.json" + +[[ui.pages]] +file = "pages/tag-manager.json" + +# Widgets injected into host views +[[ui.widgets]] +id = "stats-badge" +target = "library_header" + +[ui.widgets.content] +type = "badge" +text = "Stats" +variant = "info" diff --git a/examples/plugins/media-stats-ui/src/lib.rs b/examples/plugins/media-stats-ui/src/lib.rs new file mode 100644 index 0000000..c11a346 --- /dev/null +++ b/examples/plugins/media-stats-ui/src/lib.rs @@ -0,0 +1,101 @@ +//! Media Stats UI - Pinakes plugin +//! +//! A UI-only plugin that adds a library statistics dashboard and a tag manager +//! page. All UI definitions live in `pages/stats.json` and +//! `pages/tag-manager.json`; this WASM binary provides the minimum lifecycle +//! surface the host runtime requires. +//! +//! This plugin is kind = ["ui_page"]: no media-type, metadata, thumbnail, or +//! event-handler extension points are needed. The host will never call them, +//! but exporting them avoids linker warnings if the host performs capability +//! discovery via symbol inspection. + +#![no_std] + +extern crate alloc; + +use core::alloc::Layout; + +#[global_allocator] +static ALLOC: dlmalloc::GlobalDlmalloc = dlmalloc::GlobalDlmalloc; + +#[panic_handler] +fn panic(_: &core::panic::PanicInfo) -> ! { + core::arch::wasm32::unreachable() +} + +// Host functions provided by the Pinakes runtime. +unsafe extern "C" { + // Write a result value back to the host (ptr + byte length). + fn host_set_result(ptr: i32, len: i32); + + // Emit a structured log message to the host logger. + // `level` mirrors tracing severity: 0=trace 1=debug 2=info 3=warn 4=error + fn host_log(level: i32, ptr: i32, len: i32); +} + +/// # Safety +/// +/// `json` is a valid slice; the host copies the bytes before +/// returning so there are no lifetime concerns. +fn set_response(json: &[u8]) { + unsafe { host_set_result(json.as_ptr() as i32, json.len() as i32) } +} + +/// # Safety +/// +/// Same as [`set_response`] +fn log_info(msg: &[u8]) { + unsafe { host_log(2, msg.as_ptr() as i32, msg.len() as i32) } +} + +/// Allocate a buffer for the host to write request data into. +/// +/// # Returns +/// +/// The byte offset of the allocation, or -1 on failure. +/// +/// # Safety +/// +/// Size is positive; Layout construction cannot fail for align=1. +#[unsafe(no_mangle)] +pub extern "C" fn alloc(size: i32) -> i32 { + if size <= 0 { + return 0; + } + unsafe { + let layout = Layout::from_size_align_unchecked(size as usize, 1); + let ptr = alloc::alloc::alloc(layout); + if ptr.is_null() { -1 } else { ptr as i32 } + } +} + +/// Called once after the plugin is loaded. Returns 0 on success. +#[unsafe(no_mangle)] +pub extern "C" fn initialize() -> i32 { + log_info(b"media-stats-ui: initialized"); + 0 +} + +/// Called before the plugin is unloaded. Returns 0 on success. +#[unsafe(no_mangle)] +pub extern "C" fn shutdown() -> i32 { + log_info(b"media-stats-ui: shutdown"); + 0 +} + +/// # Returns +/// +/// an empty JSON array; this plugin adds no custom media types. +#[unsafe(no_mangle)] +pub extern "C" fn supported_media_types(_ptr: i32, _len: i32) { + set_response(b"[]"); +} + +/// # Returns +/// +/// An empty JSON array; this plugin handles no event types. +#[unsafe(no_mangle)] +pub extern "C" fn interested_events(_ptr: i32, _len: i32) { + set_response(b"[]"); +} diff --git a/examples/plugins/subtitle-detector/Cargo.lock b/examples/plugins/subtitle-detector/Cargo.lock deleted file mode 100644 index dda81f7..0000000 Binary files a/examples/plugins/subtitle-detector/Cargo.lock and /dev/null differ diff --git a/examples/plugins/subtitle-detector/Cargo.toml b/examples/plugins/subtitle-detector/Cargo.toml deleted file mode 100644 index 2b7d8ec..0000000 --- a/examples/plugins/subtitle-detector/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "subtitle-detector" -version = "1.0.0" -edition = "2024" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -dlmalloc = { version = "0.2", features = ["global"] } - -[profile.release] -opt-level = "s" -lto = true -strip = true diff --git a/examples/plugins/subtitle-detector/plugin.toml b/examples/plugins/subtitle-detector/plugin.toml deleted file mode 100644 index d836b75..0000000 --- a/examples/plugins/subtitle-detector/plugin.toml +++ /dev/null @@ -1,18 +0,0 @@ -[plugin] -name = "subtitle-detector" -version = "1.0.0" -api_version = "1.0" -description = "Registers SRT, VTT, and ASS subtitle formats and extracts language and entry count metadata" -kind = ["media_type", "metadata_extractor"] -priority = 500 - -[plugin.binary] -wasm = "subtitle_detector.wasm" - -[capabilities] -network = false - -[capabilities.filesystem] -# Users must add their media root directories here. Example: -# read = ["/home/user/media", "/mnt/nas/subtitles"] -read = [] diff --git a/examples/plugins/subtitle-detector/src/lib.rs b/examples/plugins/subtitle-detector/src/lib.rs deleted file mode 100644 index bfab5e7..0000000 --- a/examples/plugins/subtitle-detector/src/lib.rs +++ /dev/null @@ -1,345 +0,0 @@ -//! Subtitle-detector plugin for Pinakes. -//! -//! Registers SRT, VTT, and ASS/SSA subtitle file formats and extracts -//! language code and entry count metadata from them. -//! -//! Registered media types: -//! - `subtitle-srt`: extensions `["srt"]`, mime `["text/x-subrip"]` -//! - `subtitle-vtt`: extensions `["vtt"]`, mime `["text/vtt"]` -//! - `subtitle-ass`: extensions `["ass","ssa"]`, mime `["text/x-ass"]` -//! -//! Language detection uses filename conventions: `movie.en.srt` -> `en`. -//! -//! The `filesystem.read` capability in `plugin.toml` must be configured -//! to include the directories containing subtitle files. -//! -//! Build with: -//! RUSTFLAGS="" cargo build --target wasm32-unknown-unknown --release - -#![no_std] - -extern crate alloc; - -use alloc::{format, vec::Vec}; -use core::alloc::Layout; - -#[global_allocator] -static ALLOC: dlmalloc::GlobalDlmalloc = dlmalloc::GlobalDlmalloc; - -#[panic_handler] -fn panic_handler(_info: &core::panic::PanicInfo) -> ! { - core::arch::wasm32::unreachable() -} - -// Host functions provided by the runtime -unsafe extern "C" { - fn host_set_result(ptr: i32, len: i32); - fn host_log(level: i32, ptr: i32, len: i32); - fn host_read_file(path_ptr: i32, path_len: i32) -> i32; - fn host_get_buffer(dest_ptr: i32, dest_len: i32) -> i32; -} - -fn set_response(json: &[u8]) { - unsafe { - host_set_result(json.as_ptr() as i32, json.len() as i32); - } -} - -fn log_info(msg: &str) { - unsafe { - host_log(2, msg.as_ptr() as i32, msg.len() as i32); - } -} - -unsafe fn read_request(ptr: i32, len: i32) -> Vec { - if ptr < 0 || len <= 0 { - return Vec::new(); - } - let slice = unsafe { core::slice::from_raw_parts(ptr as *const u8, len as usize) }; - slice.to_vec() -} - -/// Extract a string value from a JSON object for a given key. -fn json_get_str<'a>(json: &'a [u8], key: &str) -> Option<&'a str> { - let json_str = core::str::from_utf8(json).ok()?; - let pattern = format!("\"{}\"", key); - let key_pos = json_str.find(&pattern)?; - let after_key = &json_str[key_pos + pattern.len()..]; - let after_colon = after_key.trim_start().strip_prefix(':')?; - let after_colon = after_colon.trim_start(); - - if after_colon.starts_with('"') { - let value_start = 1; - let value_end = after_colon[value_start..].find('"')?; - Some(&after_colon[value_start..value_start + value_end]) - } else { - None - } -} - -/// Escape a string for safe inclusion in a JSON string value. -fn json_escape(s: &str) -> alloc::string::String { - let mut out = alloc::string::String::with_capacity(s.len()); - for c in s.chars() { - match c { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - _ => out.push(c), - } - } - out -} - -// 512 KB content read limit for subtitle files -const MAX_FILE_BYTES: usize = 512 * 1024; - -/// Subtitle format variants. -enum SubtitleFormat { - Srt, - Vtt, - Ass, -} - -/// Determine subtitle format from file path extension. -fn detect_format(path: &str) -> Option { - let lower = path.to_ascii_lowercase(); - if lower.ends_with(".srt") { - Some(SubtitleFormat::Srt) - } else if lower.ends_with(".vtt") { - Some(SubtitleFormat::Vtt) - } else if lower.ends_with(".ass") || lower.ends_with(".ssa") { - Some(SubtitleFormat::Ass) - } else { - None - } -} - -/// Try to detect a 2-3 letter language code from a filename stem. -/// Matches patterns like `movie.en.srt` or `film.fra.vtt`. -/// Returns the code if found. -fn detect_language(path: &str) -> Option<&str> { - // Get the filename component - let filename = path.rsplit('/').next().unwrap_or(path); - // Strip the final extension - let stem = if let Some(dot) = filename.rfind('.') { - &filename[..dot] - } else { - filename - }; - // Check for another dot-separated segment at the end of the stem - if let Some(dot) = stem.rfind('.') { - let candidate = &stem[dot + 1..]; - let len = candidate.len(); - if (len == 2 || len == 3) && candidate.bytes().all(|b| b.is_ascii_alphabetic()) { - return Some(candidate); - } - } - None -} - -/// Count `-->` occurrences in a byte slice. -fn count_arrow_markers(data: &[u8]) -> usize { - let mut count = 0usize; - let mut i = 0usize; - while i + 2 < data.len() { - if data[i] == b'-' && data[i + 1] == b'-' && data[i + 2] == b'>' { - count += 1; - i += 3; - } else { - i += 1; - } - } - count -} - -/// Count `Dialogue:` lines in an ASS/SSA file. -fn count_ass_dialogues(data: &[u8]) -> usize { - let mut count = 0usize; - let needle = b"Dialogue:"; - let mut i = 0usize; - // Count only at line starts (after newline or at file start) - let mut at_line_start = true; - while i < data.len() { - if at_line_start && data[i..].starts_with(needle) { - count += 1; - i += needle.len(); - at_line_start = false; - } else { - if data[i] == b'\n' { - at_line_start = true; - } else { - at_line_start = false; - } - i += 1; - } - } - count -} - -#[unsafe(no_mangle)] -pub extern "C" fn alloc(size: i32) -> i32 { - if size <= 0 { - return 0; - } - unsafe { - let layout = match Layout::from_size_align(size as usize, 1) { - Ok(l) => l, - Err(_) => return -1, - }; - let ptr = alloc::alloc::alloc(layout); - if ptr.is_null() { - return -1; - } - ptr as i32 - } -} - -#[unsafe(no_mangle)] -pub extern "C" fn initialize() -> i32 { - log_info("subtitle-detector initialized"); - 0 -} - -#[unsafe(no_mangle)] -pub extern "C" fn shutdown() -> i32 { - log_info("subtitle-detector shutdown"); - 0 -} - -/// Returns the media type definitions provided by this plugin. -#[unsafe(no_mangle)] -pub extern "C" fn supported_media_types(_ptr: i32, _len: i32) { - let response = br#"[ -{"id":"subtitle-srt","name":"SubRip Subtitle","category":"document","extensions":["srt"],"mime_types":["text/x-subrip"]}, -{"id":"subtitle-vtt","name":"WebVTT Subtitle","category":"document","extensions":["vtt"],"mime_types":["text/vtt"]}, -{"id":"subtitle-ass","name":"Advanced SubStation Alpha Subtitle","category":"document","extensions":["ass","ssa"],"mime_types":["text/x-ass"]} -]"#; - set_response(response); -} - -/// Check whether this plugin can handle a given path. -#[unsafe(no_mangle)] -pub extern "C" fn can_handle(ptr: i32, len: i32) { - let req = unsafe { read_request(ptr, len) }; - let path = json_get_str(&req, "path").unwrap_or(""); - let can = detect_format(path).is_some(); - if can { - set_response(br#"{"can_handle":true}"#); - } else { - set_response(br#"{"can_handle":false}"#); - } -} - -/// Returns the media type IDs this extractor supports. -#[unsafe(no_mangle)] -pub extern "C" fn supported_types(_ptr: i32, _len: i32) { - set_response(br#"["subtitle-srt","subtitle-vtt","subtitle-ass"]"#); -} - -/// Extract metadata from a subtitle file. -#[unsafe(no_mangle)] -pub extern "C" fn extract_metadata(ptr: i32, len: i32) { - let req = unsafe { read_request(ptr, len) }; - let path = match json_get_str(&req, "path") { - Some(p) => p, - None => { - set_response(br#"{"extra":{"error":"missing path"}}"#); - return; - } - }; - - let format = match detect_format(path) { - Some(f) => f, - None => { - set_response(br#"{"extra":{"error":"unsupported format"}}"#); - return; - } - }; - - let format_str = match format { - SubtitleFormat::Srt => "srt", - SubtitleFormat::Vtt => "vtt", - SubtitleFormat::Ass => "ass", - }; - - let language = detect_language(path); - - // Load file contents - let file_size = unsafe { host_read_file(path.as_ptr() as i32, path.len() as i32) }; - if file_size < 0 { - // Return what we have without entry count - let lang_field = language - .map(|l| format!(r#","language":"{}""#, json_escape(l))) - .unwrap_or_default(); - let resp = format!( - r#"{{"extra":{{"format":"{}"{}}}}}"#, - format_str, lang_field, - ); - set_response(resp.as_bytes()); - return; - } - - if file_size as usize >= MAX_FILE_BYTES { - let lang_field = language - .map(|l| format!(r#","language":"{}""#, json_escape(l))) - .unwrap_or_default(); - let resp = format!( - r#"{{"extra":{{"format":"{}","too_large":"true"{}}}}}"#, - format_str, lang_field, - ); - set_response(resp.as_bytes()); - return; - } - - let buf_size = file_size as usize; - let entry_count = if buf_size == 0 { - 0usize - } else { - let layout = match Layout::from_size_align(buf_size, 1) { - Ok(l) => l, - Err(_) => { - set_response(br#"{"extra":{"error":"alloc failed"}}"#); - return; - } - }; - let buf_ptr = unsafe { alloc::alloc::alloc(layout) }; - if buf_ptr.is_null() { - set_response(br#"{"extra":{"error":"alloc failed"}}"#); - return; - } - - let copied = unsafe { host_get_buffer(buf_ptr as i32, file_size) }; - let count = if copied > 0 { - let data = unsafe { core::slice::from_raw_parts(buf_ptr, copied as usize) }; - match format_str { - "srt" => count_arrow_markers(data), - "vtt" => count_arrow_markers(data), - _ => count_ass_dialogues(data), - } - } else { - 0 - }; - - unsafe { alloc::alloc::dealloc(buf_ptr, layout) }; - count - }; - - let msg = format!( - "subtitle-detector: format={}, entries={}, path={}", - format_str, entry_count, path, - ); - log_info(&msg); - - let lang_field = language - .map(|l| format!(r#","language":"{}""#, json_escape(l))) - .unwrap_or_default(); - - let resp = format!( - r#"{{"extra":{{"format":"{}","entry_count":"{}"{}}}}}"#, - format_str, entry_count, lang_field, - ); - set_response(resp.as_bytes()); -} diff --git a/examples/plugins/text-enrichment/Cargo.lock b/examples/plugins/text-enrichment/Cargo.lock deleted file mode 100644 index 0697c24..0000000 Binary files a/examples/plugins/text-enrichment/Cargo.lock and /dev/null differ diff --git a/examples/plugins/text-enrichment/Cargo.toml b/examples/plugins/text-enrichment/Cargo.toml deleted file mode 100644 index d073c1f..0000000 --- a/examples/plugins/text-enrichment/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "text-enrichment" -version = "1.0.0" -edition = "2024" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -dlmalloc = { version = "0.2", features = ["global"] } - -[profile.release] -opt-level = "s" -lto = true -strip = true diff --git a/examples/plugins/text-enrichment/plugin.toml b/examples/plugins/text-enrichment/plugin.toml deleted file mode 100644 index f451a3e..0000000 --- a/examples/plugins/text-enrichment/plugin.toml +++ /dev/null @@ -1,18 +0,0 @@ -[plugin] -name = "text-enrichment" -version = "1.0.0" -api_version = "1.0" -description = "Enriches plain text files with word count, line count, character count, and estimated reading time" -kind = ["metadata_extractor"] -priority = 500 - -[plugin.binary] -wasm = "text_enrichment.wasm" - -[capabilities] -network = false - -[capabilities.filesystem] -# Users must add their media root directories here. Example: -# read = ["/home/user/media", "/mnt/nas/texts"] -read = [] diff --git a/examples/plugins/text-enrichment/src/lib.rs b/examples/plugins/text-enrichment/src/lib.rs deleted file mode 100644 index f6b248d..0000000 --- a/examples/plugins/text-enrichment/src/lib.rs +++ /dev/null @@ -1,198 +0,0 @@ -//! Text-enrichment plugin for Pinakes. -//! -//! Extracts word count, line count, character count, and estimated reading -//! time from plain text (`.txt`) files. -//! -//! The `filesystem.read` capability list in `plugin.toml` must be configured -//! to include the directories where text files live. -//! -//! Build with: -//! RUSTFLAGS="" cargo build --target wasm32-unknown-unknown --release - -#![no_std] - -extern crate alloc; - -use alloc::{format, vec::Vec}; -use core::alloc::Layout; - -#[global_allocator] -static ALLOC: dlmalloc::GlobalDlmalloc = dlmalloc::GlobalDlmalloc; - -#[panic_handler] -fn panic_handler(_info: &core::panic::PanicInfo) -> ! { - core::arch::wasm32::unreachable() -} - -// Host functions provided by the runtime -unsafe extern "C" { - fn host_set_result(ptr: i32, len: i32); - fn host_log(level: i32, ptr: i32, len: i32); - fn host_read_file(path_ptr: i32, path_len: i32) -> i32; - fn host_get_buffer(dest_ptr: i32, dest_len: i32) -> i32; -} - -fn set_response(json: &[u8]) { - unsafe { - host_set_result(json.as_ptr() as i32, json.len() as i32); - } -} - -fn log_info(msg: &str) { - unsafe { - host_log(2, msg.as_ptr() as i32, msg.len() as i32); - } -} - -unsafe fn read_request(ptr: i32, len: i32) -> Vec { - if ptr < 0 || len <= 0 { - return Vec::new(); - } - let slice = unsafe { core::slice::from_raw_parts(ptr as *const u8, len as usize) }; - slice.to_vec() -} - -/// Extract a string value from a JSON object for a given key. -fn json_get_str<'a>(json: &'a [u8], key: &str) -> Option<&'a str> { - let json_str = core::str::from_utf8(json).ok()?; - let pattern = format!("\"{}\"", key); - let key_pos = json_str.find(&pattern)?; - let after_key = &json_str[key_pos + pattern.len()..]; - let after_colon = after_key.trim_start().strip_prefix(':')?; - let after_colon = after_colon.trim_start(); - - if after_colon.starts_with('"') { - let value_start = 1; - let value_end = after_colon[value_start..].find('"')?; - Some(&after_colon[value_start..value_start + value_end]) - } else { - None - } -} - -// 5 MB content read limit -const MAX_FILE_BYTES: usize = 5 * 1024 * 1024; - -#[unsafe(no_mangle)] -pub extern "C" fn alloc(size: i32) -> i32 { - if size <= 0 { - return 0; - } - unsafe { - let layout = match Layout::from_size_align(size as usize, 1) { - Ok(l) => l, - Err(_) => return -1, - }; - let ptr = alloc::alloc::alloc(layout); - if ptr.is_null() { - return -1; - } - ptr as i32 - } -} - -#[unsafe(no_mangle)] -pub extern "C" fn initialize() -> i32 { - log_info("text-enrichment initialized"); - 0 -} - -#[unsafe(no_mangle)] -pub extern "C" fn shutdown() -> i32 { - log_info("text-enrichment shutdown"); - 0 -} - -/// Returns the media types this extractor supports. -#[unsafe(no_mangle)] -pub extern "C" fn supported_types(_ptr: i32, _len: i32) { - set_response(br#"["text"]"#); -} - -/// Extract text statistics from a plain text file. -#[unsafe(no_mangle)] -pub extern "C" fn extract_metadata(ptr: i32, len: i32) { - let req = unsafe { read_request(ptr, len) }; - let path = match json_get_str(&req, "path") { - Some(p) => p, - None => { - set_response(br#"{"extra":{"error":"missing path"}}"#); - return; - } - }; - - // Ask the host to load the file into the exchange buffer. - let file_size = unsafe { host_read_file(path.as_ptr() as i32, path.len() as i32) }; - if file_size < 0 { - set_response(br#"{"extra":{"error":"read failed"}}"#); - return; - } - - if file_size as usize >= MAX_FILE_BYTES { - set_response(br#"{"extra":{"too_large":"true"}}"#); - return; - } - - let buf_size = file_size as usize; - if buf_size == 0 { - let resp = r#"{"extra":{"word_count":"0","line_count":"0","byte_count":"0","reading_minutes":"0"}}"#; - set_response(resp.as_bytes()); - return; - } - - let layout = match Layout::from_size_align(buf_size, 1) { - Ok(l) => l, - Err(_) => { - set_response(br#"{"extra":{"error":"alloc failed"}}"#); - return; - } - }; - let buf_ptr = unsafe { alloc::alloc::alloc(layout) }; - if buf_ptr.is_null() { - set_response(br#"{"extra":{"error":"alloc failed"}}"#); - return; - } - - let copied = unsafe { host_get_buffer(buf_ptr as i32, file_size) }; - if copied <= 0 { - unsafe { alloc::alloc::dealloc(buf_ptr, layout) }; - set_response(br#"{"extra":{"error":"buffer copy failed"}}"#); - return; - } - - let content = unsafe { core::slice::from_raw_parts(buf_ptr, copied as usize) }; - - let byte_count = content.len(); - let line_count = content.iter().filter(|&&b| b == b'\n').count() - + if content.last().map_or(true, |&b| b != b'\n') { 1 } else { 0 }; - - // Count words: transitions from whitespace to non-whitespace. - let mut word_count = 0usize; - let mut in_word = false; - for &b in content { - let is_ws = b == b' ' || b == b'\t' || b == b'\n' || b == b'\r'; - if !is_ws && !in_word { - word_count += 1; - in_word = true; - } else if is_ws { - in_word = false; - } - } - - // Estimate reading time at 200 words per minute, rounding up. - let reading_minutes = (word_count + 199) / 200; - - unsafe { alloc::alloc::dealloc(buf_ptr, layout) }; - - let msg = format!( - "text-enrichment: {} words, {} lines, {} chars", - word_count, line_count, byte_count - ); - log_info(&msg); - - let resp = format!( - r#"{{"extra":{{"word_count":"{}","line_count":"{}","byte_count":"{}","reading_minutes":"{}"}}}}"#, - word_count, line_count, byte_count, reading_minutes, - ); - set_response(resp.as_bytes()); -} diff --git a/flake.lock b/flake.lock index d08ec7d..52fc03b 100644 Binary files a/flake.lock and b/flake.lock differ diff --git a/flake.nix b/flake.nix index d9ca783..5e4017d 100644 --- a/flake.nix +++ b/flake.nix @@ -21,38 +21,5 @@ in { default = pkgs.callPackage ./nix/shell.nix {}; }); - - formatter = forEachSystem (system: let - pkgs = nixpkgs.legacyPackages.${system}; - in - pkgs.writeShellApplication { - name = "nix3-fmt-wrapper"; - - runtimeInputs = [ - pkgs.alejandra - pkgs.fd - pkgs.prettier - pkgs.deno - pkgs.taplo - pkgs.sql-formatter - ]; - - text = '' - # Format Nix with Alejandra - fd "$@" -t f -e nix -x alejandra -q '{}' - - # Format TOML with Taplo - fd "$@" -t f -e toml -x taplo fmt '{}' - - # Format CSS with Prettier - fd "$@" -t f -e css -x prettier --write '{}' - - # Format SQL with sql-format - fd "$@" -t f -e sql -x sql-formatter --fix '{}' -l postgresql - - # Format Markdown with Deno - fd "$@" -t f -e md -x deno fmt -q '{}' - ''; - }); }; } diff --git a/justfile b/justfile deleted file mode 100644 index 563e545..0000000 --- a/justfile +++ /dev/null @@ -1,40 +0,0 @@ -# Default recipe to show help -@default: - just --list - -# Build all crates -build-all: build-server build-tui build-ui - -# Build the server crate -build-server: - cargo build -p pinakes-server - -# Build the TUI crate -build-tui: - cargo build -p pinakes-tui - -# Build the UI using Dioxus CLI. The UI *has* to be built with `dx`, because CSS -# is not correctly embedded otherwise. -build-ui: - dx build -p pinakes-ui - -# Generate REST API documentation using cargo xtask -@docs: - cargo xtask docs - -# Run all tests -@test: - cargo nextest run --workspace - -# Format code -@fmt: - cargo fmt - -# Run clippy linting -@lint: - cargo clippy --workspace - -# Clean build artifacts -@clean: - cargo clean - rm -rf target/dx/ diff --git a/crates/pinakes-migrations/migrations/postgres/V10__incremental_scan.sql b/migrations/postgres/V10__incremental_scan.sql similarity index 55% rename from crates/pinakes-migrations/migrations/postgres/V10__incremental_scan.sql rename to migrations/postgres/V10__incremental_scan.sql index 85b196e..8fdc2cb 100644 --- a/crates/pinakes-migrations/migrations/postgres/V10__incremental_scan.sql +++ b/migrations/postgres/V10__incremental_scan.sql @@ -1,19 +1,19 @@ -- Add file_mtime column to media_items table for incremental scanning -- Stores Unix timestamp in seconds of the file's modification time -ALTER TABLE media_items -ADD COLUMN file_mtime BIGINT; + +ALTER TABLE media_items ADD COLUMN file_mtime BIGINT; -- Create index for quick mtime lookups -CREATE INDEX IF NOT EXISTS idx_media_items_file_mtime ON media_items (file_mtime); +CREATE INDEX IF NOT EXISTS idx_media_items_file_mtime ON media_items(file_mtime); -- Create a scan_history table to track when each directory was last scanned CREATE TABLE IF NOT EXISTS scan_history ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - directory TEXT NOT NULL UNIQUE, - last_scan_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - files_scanned INTEGER NOT NULL DEFAULT 0, - files_changed INTEGER NOT NULL DEFAULT 0, - scan_duration_ms INTEGER + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + directory TEXT NOT NULL UNIQUE, + last_scan_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + files_scanned INTEGER NOT NULL DEFAULT 0, + files_changed INTEGER NOT NULL DEFAULT 0, + scan_duration_ms INTEGER ); -CREATE INDEX IF NOT EXISTS idx_scan_history_directory ON scan_history (directory); +CREATE INDEX IF NOT EXISTS idx_scan_history_directory ON scan_history(directory); diff --git a/migrations/postgres/V11__session_persistence.sql b/migrations/postgres/V11__session_persistence.sql new file mode 100644 index 0000000..8603d0b --- /dev/null +++ b/migrations/postgres/V11__session_persistence.sql @@ -0,0 +1,18 @@ +-- Session persistence for database-backed sessions +-- Replaces in-memory session storage + +CREATE TABLE IF NOT EXISTS sessions ( + session_token TEXT PRIMARY KEY NOT NULL, + user_id TEXT, + username TEXT NOT NULL, + role TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + last_accessed TIMESTAMPTZ NOT NULL +); + +-- Index for efficient cleanup of expired sessions +CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); + +-- Index for listing sessions by username +CREATE INDEX IF NOT EXISTS idx_sessions_username ON sessions(username); diff --git a/migrations/postgres/V12__book_management.sql b/migrations/postgres/V12__book_management.sql new file mode 100644 index 0000000..2452032 --- /dev/null +++ b/migrations/postgres/V12__book_management.sql @@ -0,0 +1,60 @@ +-- V12: Book Management Schema (PostgreSQL) +-- Adds comprehensive book metadata tracking, authors, and identifiers + +-- Book metadata (supplements media_items for EPUB/PDF/MOBI) +CREATE TABLE book_metadata ( + media_id UUID PRIMARY KEY REFERENCES media_items(id) ON DELETE CASCADE, + isbn TEXT, + isbn13 TEXT, -- Normalized ISBN-13 for lookups + publisher TEXT, + language TEXT, -- ISO 639-1 code + page_count INTEGER, + publication_date DATE, + series_name TEXT, + series_index DOUBLE PRECISION, -- Supports 1.5, etc. + format TEXT, -- 'epub', 'pdf', 'mobi', 'azw3' + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_book_isbn13 ON book_metadata(isbn13); +CREATE INDEX idx_book_series ON book_metadata(series_name, series_index); +CREATE INDEX idx_book_publisher ON book_metadata(publisher); +CREATE INDEX idx_book_language ON book_metadata(language); + +-- Multiple authors per book (many-to-many) +CREATE TABLE book_authors ( + media_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, + author_name TEXT NOT NULL, + author_sort TEXT, -- "Last, First" for sorting + role TEXT NOT NULL DEFAULT 'author', -- author, translator, editor, illustrator + position INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (media_id, author_name, role) +); + +CREATE INDEX idx_book_authors_name ON book_authors(author_name); +CREATE INDEX idx_book_authors_sort ON book_authors(author_sort); + +-- Multiple identifiers (ISBN variants, ASIN, DOI, etc.) +CREATE TABLE book_identifiers ( + media_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, + identifier_type TEXT NOT NULL, -- isbn, isbn13, asin, doi, lccn, oclc + identifier_value TEXT NOT NULL, + PRIMARY KEY (media_id, identifier_type, identifier_value) +); + +CREATE INDEX idx_book_identifiers ON book_identifiers(identifier_type, identifier_value); + +-- Trigger to update updated_at on book_metadata changes +CREATE OR REPLACE FUNCTION update_book_metadata_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER update_book_metadata_timestamp + BEFORE UPDATE ON book_metadata + FOR EACH ROW + EXECUTE FUNCTION update_book_metadata_timestamp(); diff --git a/migrations/postgres/V13__photo_metadata.sql b/migrations/postgres/V13__photo_metadata.sql new file mode 100644 index 0000000..f1365cd --- /dev/null +++ b/migrations/postgres/V13__photo_metadata.sql @@ -0,0 +1,15 @@ +-- V13: Enhanced photo metadata support +-- Add photo-specific fields to media_items table + +ALTER TABLE media_items ADD COLUMN date_taken TIMESTAMPTZ; +ALTER TABLE media_items ADD COLUMN latitude DOUBLE PRECISION; +ALTER TABLE media_items ADD COLUMN longitude DOUBLE PRECISION; +ALTER TABLE media_items ADD COLUMN camera_make TEXT; +ALTER TABLE media_items ADD COLUMN camera_model TEXT; +ALTER TABLE media_items ADD COLUMN rating INTEGER CHECK (rating >= 0 AND rating <= 5); + +-- Indexes for photo queries +CREATE INDEX idx_media_date_taken ON media_items(date_taken) WHERE date_taken IS NOT NULL; +CREATE INDEX idx_media_location ON media_items(latitude, longitude) WHERE latitude IS NOT NULL AND longitude IS NOT NULL; +CREATE INDEX idx_media_camera ON media_items(camera_make) WHERE camera_make IS NOT NULL; +CREATE INDEX idx_media_rating ON media_items(rating) WHERE rating IS NOT NULL; diff --git a/migrations/postgres/V14__perceptual_hash.sql b/migrations/postgres/V14__perceptual_hash.sql new file mode 100644 index 0000000..4bdc677 --- /dev/null +++ b/migrations/postgres/V14__perceptual_hash.sql @@ -0,0 +1,7 @@ +-- V14: Perceptual hash for duplicate detection +-- Add perceptual hash column for image similarity detection + +ALTER TABLE media_items ADD COLUMN perceptual_hash TEXT; + +-- Index for perceptual hash lookups +CREATE INDEX idx_media_phash ON media_items(perceptual_hash) WHERE perceptual_hash IS NOT NULL; diff --git a/crates/pinakes-migrations/migrations/postgres/V15__managed_storage.sql b/migrations/postgres/V15__managed_storage.sql similarity index 50% rename from crates/pinakes-migrations/migrations/postgres/V15__managed_storage.sql rename to migrations/postgres/V15__managed_storage.sql index e3fb615..56ef8f4 100644 --- a/crates/pinakes-migrations/migrations/postgres/V15__managed_storage.sql +++ b/migrations/postgres/V15__managed_storage.sql @@ -1,33 +1,30 @@ -- V15: Managed File Storage -- Adds server-side content-addressable storage for uploaded files + -- Add storage mode to media_items (external = file on disk, managed = in content-addressable storage) -ALTER TABLE media_items -ADD COLUMN storage_mode TEXT NOT NULL DEFAULT 'external'; +ALTER TABLE media_items ADD COLUMN storage_mode TEXT NOT NULL DEFAULT 'external'; -- Original filename for managed uploads (preserved separately from file_name which may be normalized) -ALTER TABLE media_items -ADD COLUMN original_filename TEXT; +ALTER TABLE media_items ADD COLUMN original_filename TEXT; -- When the file was uploaded to managed storage -ALTER TABLE media_items -ADD COLUMN uploaded_at TIMESTAMPTZ; +ALTER TABLE media_items ADD COLUMN uploaded_at TIMESTAMPTZ; -- Storage key for looking up the blob (usually same as content_hash for deduplication) -ALTER TABLE media_items -ADD COLUMN storage_key TEXT; +ALTER TABLE media_items ADD COLUMN storage_key TEXT; -- Managed blobs table - tracks deduplicated file storage CREATE TABLE managed_blobs ( - content_hash TEXT PRIMARY KEY NOT NULL, - file_size BIGINT NOT NULL, - mime_type TEXT NOT NULL, - reference_count INTEGER NOT NULL DEFAULT 1, - stored_at TIMESTAMPTZ NOT NULL, - last_verified TIMESTAMPTZ + content_hash TEXT PRIMARY KEY NOT NULL, + file_size BIGINT NOT NULL, + mime_type TEXT NOT NULL, + reference_count INTEGER NOT NULL DEFAULT 1, + stored_at TIMESTAMPTZ NOT NULL, + last_verified TIMESTAMPTZ ); -- Index for finding managed media items -CREATE INDEX idx_media_storage_mode ON media_items (storage_mode); +CREATE INDEX idx_media_storage_mode ON media_items(storage_mode); -- Index for finding orphaned blobs (reference_count = 0) -CREATE INDEX idx_blobs_reference_count ON managed_blobs (reference_count); +CREATE INDEX idx_blobs_reference_count ON managed_blobs(reference_count); diff --git a/migrations/postgres/V16__sync_system.sql b/migrations/postgres/V16__sync_system.sql new file mode 100644 index 0000000..8a87823 --- /dev/null +++ b/migrations/postgres/V16__sync_system.sql @@ -0,0 +1,110 @@ +-- V16: Cross-Device Sync System +-- Adds device registration, change tracking, and chunked upload support + +-- Sync devices table +CREATE TABLE sync_devices ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + device_type TEXT NOT NULL, + client_version TEXT NOT NULL, + os_info TEXT, + device_token_hash TEXT NOT NULL UNIQUE, + last_sync_at TIMESTAMPTZ, + last_seen_at TIMESTAMPTZ NOT NULL, + sync_cursor BIGINT DEFAULT 0, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX idx_sync_devices_user ON sync_devices(user_id); +CREATE INDEX idx_sync_devices_token ON sync_devices(device_token_hash); + +-- Sync log table - tracks all changes for sync +CREATE TABLE sync_log ( + id TEXT PRIMARY KEY NOT NULL, + sequence BIGSERIAL UNIQUE NOT NULL, + change_type TEXT NOT NULL, + media_id TEXT REFERENCES media_items(id) ON DELETE SET NULL, + path TEXT NOT NULL, + content_hash TEXT, + file_size BIGINT, + metadata_json TEXT, + changed_by_device TEXT REFERENCES sync_devices(id) ON DELETE SET NULL, + timestamp TIMESTAMPTZ NOT NULL +); + +CREATE INDEX idx_sync_log_sequence ON sync_log(sequence); +CREATE INDEX idx_sync_log_path ON sync_log(path); +CREATE INDEX idx_sync_log_timestamp ON sync_log(timestamp); + +-- Sequence counter for sync log +CREATE TABLE sync_sequence ( + id INTEGER PRIMARY KEY CHECK (id = 1), + current_value BIGINT NOT NULL DEFAULT 0 +); +INSERT INTO sync_sequence (id, current_value) VALUES (1, 0); + +-- Device sync state - tracks sync status per device per file +CREATE TABLE device_sync_state ( + device_id TEXT NOT NULL REFERENCES sync_devices(id) ON DELETE CASCADE, + path TEXT NOT NULL, + local_hash TEXT, + server_hash TEXT, + local_mtime BIGINT, + server_mtime BIGINT, + sync_status TEXT NOT NULL, + last_synced_at TIMESTAMPTZ, + conflict_info_json TEXT, + PRIMARY KEY (device_id, path) +); + +CREATE INDEX idx_device_sync_status ON device_sync_state(device_id, sync_status); + +-- Upload sessions for chunked uploads +CREATE TABLE upload_sessions ( + id TEXT PRIMARY KEY NOT NULL, + device_id TEXT NOT NULL REFERENCES sync_devices(id) ON DELETE CASCADE, + target_path TEXT NOT NULL, + expected_hash TEXT NOT NULL, + expected_size BIGINT NOT NULL, + chunk_size BIGINT NOT NULL, + chunk_count BIGINT NOT NULL, + status TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + last_activity TIMESTAMPTZ NOT NULL +); + +CREATE INDEX idx_upload_sessions_device ON upload_sessions(device_id); +CREATE INDEX idx_upload_sessions_status ON upload_sessions(status); +CREATE INDEX idx_upload_sessions_expires ON upload_sessions(expires_at); + +-- Upload chunks - tracks received chunks +CREATE TABLE upload_chunks ( + upload_id TEXT NOT NULL REFERENCES upload_sessions(id) ON DELETE CASCADE, + chunk_index BIGINT NOT NULL, + offset BIGINT NOT NULL, + size BIGINT NOT NULL, + hash TEXT NOT NULL, + received_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (upload_id, chunk_index) +); + +-- Sync conflicts +CREATE TABLE sync_conflicts ( + id TEXT PRIMARY KEY NOT NULL, + device_id TEXT NOT NULL REFERENCES sync_devices(id) ON DELETE CASCADE, + path TEXT NOT NULL, + local_hash TEXT NOT NULL, + local_mtime BIGINT NOT NULL, + server_hash TEXT NOT NULL, + server_mtime BIGINT NOT NULL, + detected_at TIMESTAMPTZ NOT NULL, + resolved_at TIMESTAMPTZ, + resolution TEXT +); + +CREATE INDEX idx_sync_conflicts_device ON sync_conflicts(device_id); +CREATE INDEX idx_sync_conflicts_unresolved ON sync_conflicts(device_id) WHERE resolved_at IS NULL; diff --git a/migrations/postgres/V17__enhanced_sharing.sql b/migrations/postgres/V17__enhanced_sharing.sql new file mode 100644 index 0000000..2107b5c --- /dev/null +++ b/migrations/postgres/V17__enhanced_sharing.sql @@ -0,0 +1,83 @@ +-- V17: Enhanced Sharing System +-- Replaces simple share_links with comprehensive sharing capabilities + +-- Enhanced shares table +CREATE TABLE shares ( + id TEXT PRIMARY KEY NOT NULL, + target_type TEXT NOT NULL CHECK (target_type IN ('media', 'collection', 'tag', 'saved_search')), + target_id TEXT NOT NULL, + owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + recipient_type TEXT NOT NULL CHECK (recipient_type IN ('public_link', 'user', 'group', 'federated')), + recipient_user_id TEXT REFERENCES users(id) ON DELETE CASCADE, + recipient_group_id TEXT, + recipient_federated_handle TEXT, + recipient_federated_server TEXT, + public_token TEXT UNIQUE, + public_password_hash TEXT, + perm_view BOOLEAN NOT NULL DEFAULT TRUE, + perm_download BOOLEAN NOT NULL DEFAULT FALSE, + perm_edit BOOLEAN NOT NULL DEFAULT FALSE, + perm_delete BOOLEAN NOT NULL DEFAULT FALSE, + perm_reshare BOOLEAN NOT NULL DEFAULT FALSE, + perm_add BOOLEAN NOT NULL DEFAULT FALSE, + note TEXT, + expires_at TIMESTAMPTZ, + access_count BIGINT NOT NULL DEFAULT 0, + last_accessed TIMESTAMPTZ, + inherit_to_children BOOLEAN NOT NULL DEFAULT TRUE, + parent_share_id TEXT REFERENCES shares(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + UNIQUE(owner_id, target_type, target_id, recipient_type, recipient_user_id) +); + +CREATE INDEX idx_shares_owner ON shares(owner_id); +CREATE INDEX idx_shares_recipient_user ON shares(recipient_user_id); +CREATE INDEX idx_shares_target ON shares(target_type, target_id); +CREATE INDEX idx_shares_token ON shares(public_token); +CREATE INDEX idx_shares_expires ON shares(expires_at); + +-- Share activity log +CREATE TABLE share_activity ( + id TEXT PRIMARY KEY NOT NULL, + share_id TEXT NOT NULL REFERENCES shares(id) ON DELETE CASCADE, + actor_id TEXT REFERENCES users(id) ON DELETE SET NULL, + actor_ip TEXT, + action TEXT NOT NULL, + details TEXT, + timestamp TIMESTAMPTZ NOT NULL +); + +CREATE INDEX idx_share_activity_share ON share_activity(share_id); +CREATE INDEX idx_share_activity_timestamp ON share_activity(timestamp); + +-- Share notifications +CREATE TABLE share_notifications ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + share_id TEXT NOT NULL REFERENCES shares(id) ON DELETE CASCADE, + notification_type TEXT NOT NULL, + is_read BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX idx_share_notifications_user ON share_notifications(user_id); +CREATE INDEX idx_share_notifications_unread ON share_notifications(user_id) WHERE is_read = FALSE; + +-- Migrate existing share_links to new shares table +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'share_links') THEN + INSERT INTO shares ( + id, target_type, target_id, owner_id, recipient_type, + public_token, public_password_hash, perm_view, perm_download, + access_count, expires_at, created_at, updated_at + ) + SELECT + id, 'media', media_id, created_by, 'public_link', + token, password_hash, TRUE, TRUE, + view_count, expires_at, created_at, created_at + FROM share_links + ON CONFLICT DO NOTHING; + END IF; +END $$; diff --git a/crates/pinakes-migrations/migrations/postgres/V18__file_management.sql b/migrations/postgres/V18__file_management.sql similarity index 57% rename from crates/pinakes-migrations/migrations/postgres/V18__file_management.sql rename to migrations/postgres/V18__file_management.sql index 6059e65..30403c3 100644 --- a/crates/pinakes-migrations/migrations/postgres/V18__file_management.sql +++ b/migrations/postgres/V18__file_management.sql @@ -1,13 +1,11 @@ -- V18: File Management (Rename, Move, Trash) -- Adds soft delete support for trash/recycle bin functionality + -- Add deleted_at column for soft delete (trash) -ALTER TABLE media_items -ADD COLUMN deleted_at TIMESTAMPTZ; +ALTER TABLE media_items ADD COLUMN deleted_at TIMESTAMPTZ; -- Index for efficient trash queries -CREATE INDEX idx_media_deleted_at ON media_items (deleted_at); +CREATE INDEX idx_media_deleted_at ON media_items(deleted_at); -- Partial index for listing non-deleted items (most common query pattern) -CREATE INDEX idx_media_not_deleted ON media_items (id) -WHERE - deleted_at IS NULL; +CREATE INDEX idx_media_not_deleted ON media_items(id) WHERE deleted_at IS NULL; diff --git a/migrations/postgres/V19__markdown_links.sql b/migrations/postgres/V19__markdown_links.sql new file mode 100644 index 0000000..b0d5475 --- /dev/null +++ b/migrations/postgres/V19__markdown_links.sql @@ -0,0 +1,35 @@ +-- V19: Markdown Links (Obsidian-style bidirectional links) +-- Adds support for wikilinks, markdown links, embeds, and backlink tracking + +-- Table for storing extracted markdown links +CREATE TABLE IF NOT EXISTS markdown_links ( + id TEXT PRIMARY KEY NOT NULL, + source_media_id TEXT NOT NULL, + target_path TEXT NOT NULL, -- raw link target (wikilink or path) + target_media_id TEXT, -- resolved media_id (nullable if unresolved) + link_type TEXT NOT NULL, -- 'wikilink', 'markdown_link', 'embed' + link_text TEXT, -- display text for the link + line_number INTEGER, -- line number in source file + context TEXT, -- surrounding text for preview + created_at TIMESTAMPTZ NOT NULL, + FOREIGN KEY (source_media_id) REFERENCES media_items(id) ON DELETE CASCADE, + FOREIGN KEY (target_media_id) REFERENCES media_items(id) ON DELETE SET NULL +); + +-- Index for efficient outgoing link queries (what does this note link to?) +CREATE INDEX idx_links_source ON markdown_links(source_media_id); + +-- Index for efficient backlink queries (what links to this note?) +CREATE INDEX idx_links_target ON markdown_links(target_media_id); + +-- Index for path-based resolution (finding unresolved links) +CREATE INDEX idx_links_target_path ON markdown_links(target_path); + +-- Index for link type filtering +CREATE INDEX idx_links_type ON markdown_links(link_type); + +-- Track when links were last extracted from a media item +ALTER TABLE media_items ADD COLUMN links_extracted_at TIMESTAMPTZ; + +-- Index for finding media items that need link extraction +CREATE INDEX idx_media_links_extracted ON media_items(links_extracted_at); diff --git a/migrations/postgres/V1__initial_schema.sql b/migrations/postgres/V1__initial_schema.sql new file mode 100644 index 0000000..cd6a0c8 --- /dev/null +++ b/migrations/postgres/V1__initial_schema.sql @@ -0,0 +1,73 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE TABLE IF NOT EXISTS root_dirs ( + path TEXT PRIMARY KEY NOT NULL +); + +CREATE TABLE IF NOT EXISTS media_items ( + id UUID PRIMARY KEY NOT NULL, + path TEXT NOT NULL UNIQUE, + file_name TEXT NOT NULL, + media_type TEXT NOT NULL, + content_hash TEXT NOT NULL UNIQUE, + file_size BIGINT NOT NULL, + title TEXT, + artist TEXT, + album TEXT, + genre TEXT, + year INTEGER, + duration_secs DOUBLE PRECISION, + description TEXT, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS tags ( + id UUID PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + parent_id UUID REFERENCES tags(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_name_parent ON tags(name, COALESCE(parent_id, '00000000-0000-0000-0000-000000000000')); + +CREATE TABLE IF NOT EXISTS media_tags ( + media_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, + tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (media_id, tag_id) +); + +CREATE TABLE IF NOT EXISTS collections ( + id UUID PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + description TEXT, + kind TEXT NOT NULL, + filter_query TEXT, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS collection_members ( + collection_id UUID NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + media_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, + position INTEGER NOT NULL DEFAULT 0, + added_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (collection_id, media_id) +); + +CREATE TABLE IF NOT EXISTS audit_log ( + id UUID PRIMARY KEY NOT NULL, + media_id UUID REFERENCES media_items(id) ON DELETE SET NULL, + action TEXT NOT NULL, + details TEXT, + timestamp TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS custom_fields ( + media_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, + field_name TEXT NOT NULL, + field_type TEXT NOT NULL, + field_value TEXT NOT NULL, + PRIMARY KEY (media_id, field_name) +); diff --git a/migrations/postgres/V2__fts_indexes.sql b/migrations/postgres/V2__fts_indexes.sql new file mode 100644 index 0000000..510fce6 --- /dev/null +++ b/migrations/postgres/V2__fts_indexes.sql @@ -0,0 +1,11 @@ +ALTER TABLE media_items ADD COLUMN IF NOT EXISTS search_vector tsvector + GENERATED ALWAYS AS ( + setweight(to_tsvector('english', COALESCE(title, '')), 'A') || + setweight(to_tsvector('english', COALESCE(artist, '')), 'B') || + setweight(to_tsvector('english', COALESCE(album, '')), 'B') || + setweight(to_tsvector('english', COALESCE(genre, '')), 'C') || + setweight(to_tsvector('english', COALESCE(description, '')), 'C') || + setweight(to_tsvector('english', COALESCE(file_name, '')), 'D') + ) STORED; + +CREATE INDEX IF NOT EXISTS idx_media_search ON media_items USING GIN(search_vector); diff --git a/migrations/postgres/V3__audit_indexes.sql b/migrations/postgres/V3__audit_indexes.sql new file mode 100644 index 0000000..d8c423a --- /dev/null +++ b/migrations/postgres/V3__audit_indexes.sql @@ -0,0 +1,8 @@ +CREATE INDEX IF NOT EXISTS idx_audit_media_id ON audit_log(media_id); +CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp); +CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action); +CREATE INDEX IF NOT EXISTS idx_media_content_hash ON media_items(content_hash); +CREATE INDEX IF NOT EXISTS idx_media_media_type ON media_items(media_type); +CREATE INDEX IF NOT EXISTS idx_media_created_at ON media_items(created_at); +CREATE INDEX IF NOT EXISTS idx_media_title_trgm ON media_items USING GIN(title gin_trgm_ops); +CREATE INDEX IF NOT EXISTS idx_media_artist_trgm ON media_items USING GIN(artist gin_trgm_ops); diff --git a/migrations/postgres/V4__thumbnail_path.sql b/migrations/postgres/V4__thumbnail_path.sql new file mode 100644 index 0000000..9021884 --- /dev/null +++ b/migrations/postgres/V4__thumbnail_path.sql @@ -0,0 +1 @@ +ALTER TABLE media_items ADD COLUMN thumbnail_path TEXT; diff --git a/migrations/postgres/V5__integrity_and_saved_searches.sql b/migrations/postgres/V5__integrity_and_saved_searches.sql new file mode 100644 index 0000000..f2807f4 --- /dev/null +++ b/migrations/postgres/V5__integrity_and_saved_searches.sql @@ -0,0 +1,12 @@ +-- Integrity tracking columns +ALTER TABLE media_items ADD COLUMN last_verified_at TIMESTAMPTZ; +ALTER TABLE media_items ADD COLUMN integrity_status TEXT DEFAULT 'unverified'; + +-- Saved searches +CREATE TABLE IF NOT EXISTS saved_searches ( + id UUID PRIMARY KEY, + name TEXT NOT NULL, + query TEXT NOT NULL, + sort_order TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/migrations/postgres/V6__plugin_system.sql b/migrations/postgres/V6__plugin_system.sql new file mode 100644 index 0000000..bd52a1a --- /dev/null +++ b/migrations/postgres/V6__plugin_system.sql @@ -0,0 +1,15 @@ +-- Plugin registry table +CREATE TABLE plugin_registry ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + version TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + config_json TEXT, + manifest_json TEXT, + installed_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL +); + +-- Index for quick lookups +CREATE INDEX idx_plugin_registry_enabled ON plugin_registry(enabled); +CREATE INDEX idx_plugin_registry_name ON plugin_registry(name); diff --git a/migrations/postgres/V7__user_management.sql b/migrations/postgres/V7__user_management.sql new file mode 100644 index 0000000..a9eb12f --- /dev/null +++ b/migrations/postgres/V7__user_management.sql @@ -0,0 +1,35 @@ +-- Users table +CREATE TABLE users ( + id UUID PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role JSONB NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL +); + +-- User profiles table +CREATE TABLE user_profiles ( + user_id UUID PRIMARY KEY, + avatar_path TEXT, + bio TEXT, + preferences_json JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) +); + +-- User library access table +CREATE TABLE user_libraries ( + user_id UUID NOT NULL, + root_path TEXT NOT NULL, + permission JSONB NOT NULL, + granted_at TIMESTAMP WITH TIME ZONE NOT NULL, + PRIMARY KEY (user_id, root_path), + FOREIGN KEY (user_id) REFERENCES users(id) +); + +-- Indexes for efficient lookups +CREATE INDEX idx_users_username ON users(username); +CREATE INDEX idx_user_libraries_user_id ON user_libraries(user_id); +CREATE INDEX idx_user_libraries_root_path ON user_libraries(root_path); diff --git a/migrations/postgres/V8__media_server_features.sql b/migrations/postgres/V8__media_server_features.sql new file mode 100644 index 0000000..7d22838 --- /dev/null +++ b/migrations/postgres/V8__media_server_features.sql @@ -0,0 +1,131 @@ +-- Ratings +CREATE TABLE IF NOT EXISTS ratings ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL, + media_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, + stars INTEGER NOT NULL CHECK (stars >= 1 AND stars <= 5), + review_text TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id, media_id) +); + +-- Comments +CREATE TABLE IF NOT EXISTS comments ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL, + media_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, + parent_comment_id UUID REFERENCES comments(id) ON DELETE CASCADE, + text TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Favorites +CREATE TABLE IF NOT EXISTS favorites ( + user_id UUID NOT NULL, + media_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, media_id) +); + +-- Share links +CREATE TABLE IF NOT EXISTS share_links ( + id UUID PRIMARY KEY, + media_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, + created_by UUID NOT NULL, + token TEXT NOT NULL UNIQUE, + password_hash TEXT, + expires_at TIMESTAMPTZ, + view_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Playlists +CREATE TABLE IF NOT EXISTS playlists ( + id UUID PRIMARY KEY, + owner_id UUID NOT NULL, + name TEXT NOT NULL, + description TEXT, + is_public BOOLEAN NOT NULL DEFAULT FALSE, + is_smart BOOLEAN NOT NULL DEFAULT FALSE, + filter_query TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Playlist items +CREATE TABLE IF NOT EXISTS playlist_items ( + playlist_id UUID NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, + media_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, + position INTEGER NOT NULL DEFAULT 0, + added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (playlist_id, media_id) +); + +-- Usage events +CREATE TABLE IF NOT EXISTS usage_events ( + id UUID PRIMARY KEY, + media_id UUID REFERENCES media_items(id) ON DELETE SET NULL, + user_id UUID, + event_type TEXT NOT NULL, + timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), + duration_secs DOUBLE PRECISION, + context_json JSONB +); + +CREATE INDEX IF NOT EXISTS idx_usage_events_media ON usage_events(media_id); +CREATE INDEX IF NOT EXISTS idx_usage_events_user ON usage_events(user_id); +CREATE INDEX IF NOT EXISTS idx_usage_events_timestamp ON usage_events(timestamp); + +-- Watch history / progress +CREATE TABLE IF NOT EXISTS watch_history ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL, + media_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, + progress_secs DOUBLE PRECISION NOT NULL DEFAULT 0, + last_watched TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id, media_id) +); + +-- Subtitles +CREATE TABLE IF NOT EXISTS subtitles ( + id UUID PRIMARY KEY, + media_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, + language TEXT, + format TEXT NOT NULL, + file_path TEXT, + is_embedded BOOLEAN NOT NULL DEFAULT FALSE, + track_index INTEGER, + offset_ms INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_subtitles_media ON subtitles(media_id); + +-- External metadata (enrichment) +CREATE TABLE IF NOT EXISTS external_metadata ( + id UUID PRIMARY KEY, + media_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, + source TEXT NOT NULL, + external_id TEXT, + metadata_json JSONB NOT NULL DEFAULT '{}', + confidence DOUBLE PRECISION NOT NULL DEFAULT 0.0, + last_updated TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_external_metadata_media ON external_metadata(media_id); + +-- Transcode sessions +CREATE TABLE IF NOT EXISTS transcode_sessions ( + id UUID PRIMARY KEY, + media_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, + user_id UUID, + profile TEXT NOT NULL, + cache_path TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + progress DOUBLE PRECISION NOT NULL DEFAULT 0.0, + error_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_transcode_sessions_media ON transcode_sessions(media_id); diff --git a/crates/pinakes-migrations/migrations/postgres/V9__fix_indexes_and_constraints.sql b/migrations/postgres/V9__fix_indexes_and_constraints.sql similarity index 64% rename from crates/pinakes-migrations/migrations/postgres/V9__fix_indexes_and_constraints.sql rename to migrations/postgres/V9__fix_indexes_and_constraints.sql index 9446a94..a65fda3 100644 --- a/crates/pinakes-migrations/migrations/postgres/V9__fix_indexes_and_constraints.sql +++ b/migrations/postgres/V9__fix_indexes_and_constraints.sql @@ -1,26 +1,19 @@ -- Drop redundant indexes (already covered by UNIQUE constraints) DROP INDEX IF EXISTS idx_users_username; - DROP INDEX IF EXISTS idx_user_libraries_user_id; -- Add missing indexes for comments table -CREATE INDEX IF NOT EXISTS idx_comments_media ON comments (media_id); - -CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments (parent_comment_id); +CREATE INDEX IF NOT EXISTS idx_comments_media ON comments(media_id); +CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_comment_id); -- Remove duplicates before adding unique constraint DELETE FROM external_metadata e1 -WHERE - EXISTS ( - SELECT - 1 - FROM - external_metadata e2 - WHERE - e1.media_id = e2.media_id - AND e1.source = e2.source - AND e1.ctid < e2.ctid - ); +WHERE EXISTS ( + SELECT 1 FROM external_metadata e2 + WHERE e1.media_id = e2.media_id + AND e1.source = e2.source + AND e1.ctid < e2.ctid +); -- Add unique constraint for external_metadata (idempotent) DO $$ diff --git a/crates/pinakes-migrations/migrations/sqlite/V10__incremental_scan.sql b/migrations/sqlite/V10__incremental_scan.sql similarity index 58% rename from crates/pinakes-migrations/migrations/sqlite/V10__incremental_scan.sql rename to migrations/sqlite/V10__incremental_scan.sql index a070c7f..76db5c9 100644 --- a/crates/pinakes-migrations/migrations/sqlite/V10__incremental_scan.sql +++ b/migrations/sqlite/V10__incremental_scan.sql @@ -1,19 +1,19 @@ -- Add file_mtime column to media_items table for incremental scanning -- Stores Unix timestamp in seconds of the file's modification time -ALTER TABLE media_items -ADD COLUMN file_mtime INTEGER; + +ALTER TABLE media_items ADD COLUMN file_mtime INTEGER; -- Create index for quick mtime lookups -CREATE INDEX IF NOT EXISTS idx_media_items_file_mtime ON media_items (file_mtime); +CREATE INDEX IF NOT EXISTS idx_media_items_file_mtime ON media_items(file_mtime); -- Create a scan_history table to track when each directory was last scanned CREATE TABLE IF NOT EXISTS scan_history ( - id TEXT PRIMARY KEY, - directory TEXT NOT NULL UNIQUE, - last_scan_at TEXT NOT NULL, - files_scanned INTEGER NOT NULL DEFAULT 0, - files_changed INTEGER NOT NULL DEFAULT 0, - scan_duration_ms INTEGER + id TEXT PRIMARY KEY, + directory TEXT NOT NULL UNIQUE, + last_scan_at TEXT NOT NULL, + files_scanned INTEGER NOT NULL DEFAULT 0, + files_changed INTEGER NOT NULL DEFAULT 0, + scan_duration_ms INTEGER ); -CREATE INDEX IF NOT EXISTS idx_scan_history_directory ON scan_history (directory); +CREATE INDEX IF NOT EXISTS idx_scan_history_directory ON scan_history(directory); diff --git a/migrations/sqlite/V11__session_persistence.sql b/migrations/sqlite/V11__session_persistence.sql new file mode 100644 index 0000000..b4e2753 --- /dev/null +++ b/migrations/sqlite/V11__session_persistence.sql @@ -0,0 +1,18 @@ +-- Session persistence for database-backed sessions +-- Replaces in-memory session storage + +CREATE TABLE IF NOT EXISTS sessions ( + session_token TEXT PRIMARY KEY NOT NULL, + user_id TEXT, + username TEXT NOT NULL, + role TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + last_accessed TEXT NOT NULL +); + +-- Index for efficient cleanup of expired sessions +CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); + +-- Index for listing sessions by username +CREATE INDEX IF NOT EXISTS idx_sessions_username ON sessions(username); diff --git a/migrations/sqlite/V12__book_management.sql b/migrations/sqlite/V12__book_management.sql new file mode 100644 index 0000000..9823b87 --- /dev/null +++ b/migrations/sqlite/V12__book_management.sql @@ -0,0 +1,54 @@ +-- V12: Book Management Schema +-- Adds comprehensive book metadata tracking, authors, and identifiers + +-- Book metadata (supplements media_items for EPUB/PDF/MOBI) +CREATE TABLE book_metadata ( + media_id TEXT PRIMARY KEY REFERENCES media_items(id) ON DELETE CASCADE, + isbn TEXT, + isbn13 TEXT, -- Normalized ISBN-13 for lookups + publisher TEXT, + language TEXT, -- ISO 639-1 code + page_count INTEGER, + publication_date TEXT, -- ISO 8601 date string + series_name TEXT, + series_index REAL, -- Supports 1.5, etc. + format TEXT, -- 'epub', 'pdf', 'mobi', 'azw3' + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +) STRICT; + +CREATE INDEX idx_book_isbn13 ON book_metadata(isbn13); +CREATE INDEX idx_book_series ON book_metadata(series_name, series_index); +CREATE INDEX idx_book_publisher ON book_metadata(publisher); +CREATE INDEX idx_book_language ON book_metadata(language); + +-- Multiple authors per book (many-to-many) +CREATE TABLE book_authors ( + media_id TEXT NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, + author_name TEXT NOT NULL, + author_sort TEXT, -- "Last, First" for sorting + role TEXT NOT NULL DEFAULT 'author', -- author, translator, editor, illustrator + position INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (media_id, author_name, role) +) STRICT; + +CREATE INDEX idx_book_authors_name ON book_authors(author_name); +CREATE INDEX idx_book_authors_sort ON book_authors(author_sort); + +-- Multiple identifiers (ISBN variants, ASIN, DOI, etc.) +CREATE TABLE book_identifiers ( + media_id TEXT NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, + identifier_type TEXT NOT NULL, -- isbn, isbn13, asin, doi, lccn, oclc + identifier_value TEXT NOT NULL, + PRIMARY KEY (media_id, identifier_type, identifier_value) +) STRICT; + +CREATE INDEX idx_book_identifiers ON book_identifiers(identifier_type, identifier_value); + +-- Trigger to update updated_at on book_metadata changes +CREATE TRIGGER update_book_metadata_timestamp + AFTER UPDATE ON book_metadata + FOR EACH ROW +BEGIN + UPDATE book_metadata SET updated_at = datetime('now') WHERE media_id = NEW.media_id; +END; diff --git a/migrations/sqlite/V13__photo_metadata.sql b/migrations/sqlite/V13__photo_metadata.sql new file mode 100644 index 0000000..616b2fa --- /dev/null +++ b/migrations/sqlite/V13__photo_metadata.sql @@ -0,0 +1,15 @@ +-- V13: Enhanced photo metadata support +-- Add photo-specific fields to media_items table + +ALTER TABLE media_items ADD COLUMN date_taken TIMESTAMP; +ALTER TABLE media_items ADD COLUMN latitude REAL; +ALTER TABLE media_items ADD COLUMN longitude REAL; +ALTER TABLE media_items ADD COLUMN camera_make TEXT; +ALTER TABLE media_items ADD COLUMN camera_model TEXT; +ALTER TABLE media_items ADD COLUMN rating INTEGER CHECK (rating >= 0 AND rating <= 5); + +-- Indexes for photo queries +CREATE INDEX idx_media_date_taken ON media_items(date_taken) WHERE date_taken IS NOT NULL; +CREATE INDEX idx_media_location ON media_items(latitude, longitude) WHERE latitude IS NOT NULL AND longitude IS NOT NULL; +CREATE INDEX idx_media_camera ON media_items(camera_make) WHERE camera_make IS NOT NULL; +CREATE INDEX idx_media_rating ON media_items(rating) WHERE rating IS NOT NULL; diff --git a/migrations/sqlite/V14__perceptual_hash.sql b/migrations/sqlite/V14__perceptual_hash.sql new file mode 100644 index 0000000..4bdc677 --- /dev/null +++ b/migrations/sqlite/V14__perceptual_hash.sql @@ -0,0 +1,7 @@ +-- V14: Perceptual hash for duplicate detection +-- Add perceptual hash column for image similarity detection + +ALTER TABLE media_items ADD COLUMN perceptual_hash TEXT; + +-- Index for perceptual hash lookups +CREATE INDEX idx_media_phash ON media_items(perceptual_hash) WHERE perceptual_hash IS NOT NULL; diff --git a/crates/pinakes-migrations/migrations/sqlite/V15__managed_storage.sql b/migrations/sqlite/V15__managed_storage.sql similarity index 51% rename from crates/pinakes-migrations/migrations/sqlite/V15__managed_storage.sql rename to migrations/sqlite/V15__managed_storage.sql index 1f10c7b..b7f2a9d 100644 --- a/crates/pinakes-migrations/migrations/sqlite/V15__managed_storage.sql +++ b/migrations/sqlite/V15__managed_storage.sql @@ -1,33 +1,30 @@ -- V15: Managed File Storage -- Adds server-side content-addressable storage for uploaded files + -- Add storage mode to media_items (external = file on disk, managed = in content-addressable storage) -ALTER TABLE media_items -ADD COLUMN storage_mode TEXT NOT NULL DEFAULT 'external'; +ALTER TABLE media_items ADD COLUMN storage_mode TEXT NOT NULL DEFAULT 'external'; -- Original filename for managed uploads (preserved separately from file_name which may be normalized) -ALTER TABLE media_items -ADD COLUMN original_filename TEXT; +ALTER TABLE media_items ADD COLUMN original_filename TEXT; -- When the file was uploaded to managed storage -ALTER TABLE media_items -ADD COLUMN uploaded_at TEXT; +ALTER TABLE media_items ADD COLUMN uploaded_at TEXT; -- Storage key for looking up the blob (usually same as content_hash for deduplication) -ALTER TABLE media_items -ADD COLUMN storage_key TEXT; +ALTER TABLE media_items ADD COLUMN storage_key TEXT; -- Managed blobs table - tracks deduplicated file storage CREATE TABLE managed_blobs ( - content_hash TEXT PRIMARY KEY NOT NULL, - file_size INTEGER NOT NULL, - mime_type TEXT NOT NULL, - reference_count INTEGER NOT NULL DEFAULT 1, - stored_at TEXT NOT NULL, - last_verified TEXT + content_hash TEXT PRIMARY KEY NOT NULL, + file_size INTEGER NOT NULL, + mime_type TEXT NOT NULL, + reference_count INTEGER NOT NULL DEFAULT 1, + stored_at TEXT NOT NULL, + last_verified TEXT ); -- Index for finding managed media items -CREATE INDEX idx_media_storage_mode ON media_items (storage_mode); +CREATE INDEX idx_media_storage_mode ON media_items(storage_mode); -- Index for finding orphaned blobs (reference_count = 0) -CREATE INDEX idx_blobs_reference_count ON managed_blobs (reference_count); +CREATE INDEX idx_blobs_reference_count ON managed_blobs(reference_count); diff --git a/migrations/sqlite/V16__sync_system.sql b/migrations/sqlite/V16__sync_system.sql new file mode 100644 index 0000000..8941500 --- /dev/null +++ b/migrations/sqlite/V16__sync_system.sql @@ -0,0 +1,117 @@ +-- V16: Cross-Device Sync System +-- Adds device registration, change tracking, and chunked upload support + +-- Sync devices table +CREATE TABLE sync_devices ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + device_type TEXT NOT NULL, + client_version TEXT NOT NULL, + os_info TEXT, + device_token_hash TEXT NOT NULL UNIQUE, + last_sync_at TEXT, + last_seen_at TEXT NOT NULL, + sync_cursor INTEGER DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX idx_sync_devices_user ON sync_devices(user_id); +CREATE INDEX idx_sync_devices_token ON sync_devices(device_token_hash); + +-- Sync log table - tracks all changes for sync +CREATE TABLE sync_log ( + id TEXT PRIMARY KEY NOT NULL, + sequence INTEGER NOT NULL UNIQUE, + change_type TEXT NOT NULL, + media_id TEXT, + path TEXT NOT NULL, + content_hash TEXT, + file_size INTEGER, + metadata_json TEXT, + changed_by_device TEXT, + timestamp TEXT NOT NULL, + FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE SET NULL, + FOREIGN KEY (changed_by_device) REFERENCES sync_devices(id) ON DELETE SET NULL +); + +CREATE INDEX idx_sync_log_sequence ON sync_log(sequence); +CREATE INDEX idx_sync_log_path ON sync_log(path); +CREATE INDEX idx_sync_log_timestamp ON sync_log(timestamp); + +-- Sequence counter for sync log +CREATE TABLE sync_sequence ( + id INTEGER PRIMARY KEY CHECK (id = 1), + current_value INTEGER NOT NULL DEFAULT 0 +); +INSERT INTO sync_sequence (id, current_value) VALUES (1, 0); + +-- Device sync state - tracks sync status per device per file +CREATE TABLE device_sync_state ( + device_id TEXT NOT NULL, + path TEXT NOT NULL, + local_hash TEXT, + server_hash TEXT, + local_mtime INTEGER, + server_mtime INTEGER, + sync_status TEXT NOT NULL, + last_synced_at TEXT, + conflict_info_json TEXT, + PRIMARY KEY (device_id, path), + FOREIGN KEY (device_id) REFERENCES sync_devices(id) ON DELETE CASCADE +); + +CREATE INDEX idx_device_sync_status ON device_sync_state(device_id, sync_status); + +-- Upload sessions for chunked uploads +CREATE TABLE upload_sessions ( + id TEXT PRIMARY KEY NOT NULL, + device_id TEXT NOT NULL, + target_path TEXT NOT NULL, + expected_hash TEXT NOT NULL, + expected_size INTEGER NOT NULL, + chunk_size INTEGER NOT NULL, + chunk_count INTEGER NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + last_activity TEXT NOT NULL, + FOREIGN KEY (device_id) REFERENCES sync_devices(id) ON DELETE CASCADE +); + +CREATE INDEX idx_upload_sessions_device ON upload_sessions(device_id); +CREATE INDEX idx_upload_sessions_status ON upload_sessions(status); +CREATE INDEX idx_upload_sessions_expires ON upload_sessions(expires_at); + +-- Upload chunks - tracks received chunks +CREATE TABLE upload_chunks ( + upload_id TEXT NOT NULL, + chunk_index INTEGER NOT NULL, + offset INTEGER NOT NULL, + size INTEGER NOT NULL, + hash TEXT NOT NULL, + received_at TEXT NOT NULL, + PRIMARY KEY (upload_id, chunk_index), + FOREIGN KEY (upload_id) REFERENCES upload_sessions(id) ON DELETE CASCADE +); + +-- Sync conflicts +CREATE TABLE sync_conflicts ( + id TEXT PRIMARY KEY NOT NULL, + device_id TEXT NOT NULL, + path TEXT NOT NULL, + local_hash TEXT NOT NULL, + local_mtime INTEGER NOT NULL, + server_hash TEXT NOT NULL, + server_mtime INTEGER NOT NULL, + detected_at TEXT NOT NULL, + resolved_at TEXT, + resolution TEXT, + FOREIGN KEY (device_id) REFERENCES sync_devices(id) ON DELETE CASCADE +); + +CREATE INDEX idx_sync_conflicts_device ON sync_conflicts(device_id); +CREATE INDEX idx_sync_conflicts_unresolved ON sync_conflicts(device_id, resolved_at) WHERE resolved_at IS NULL; diff --git a/migrations/sqlite/V17__enhanced_sharing.sql b/migrations/sqlite/V17__enhanced_sharing.sql new file mode 100644 index 0000000..1cd17f3 --- /dev/null +++ b/migrations/sqlite/V17__enhanced_sharing.sql @@ -0,0 +1,85 @@ +-- V17: Enhanced Sharing System +-- Replaces simple share_links with comprehensive sharing capabilities + +-- Enhanced shares table +CREATE TABLE shares ( + id TEXT PRIMARY KEY NOT NULL, + target_type TEXT NOT NULL CHECK (target_type IN ('media', 'collection', 'tag', 'saved_search')), + target_id TEXT NOT NULL, + owner_id TEXT NOT NULL, + recipient_type TEXT NOT NULL CHECK (recipient_type IN ('public_link', 'user', 'group', 'federated')), + recipient_user_id TEXT, + recipient_group_id TEXT, + recipient_federated_handle TEXT, + recipient_federated_server TEXT, + public_token TEXT UNIQUE, + public_password_hash TEXT, + perm_view INTEGER NOT NULL DEFAULT 1, + perm_download INTEGER NOT NULL DEFAULT 0, + perm_edit INTEGER NOT NULL DEFAULT 0, + perm_delete INTEGER NOT NULL DEFAULT 0, + perm_reshare INTEGER NOT NULL DEFAULT 0, + perm_add INTEGER NOT NULL DEFAULT 0, + note TEXT, + expires_at TEXT, + access_count INTEGER NOT NULL DEFAULT 0, + last_accessed TEXT, + inherit_to_children INTEGER NOT NULL DEFAULT 1, + parent_share_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (recipient_user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (parent_share_id) REFERENCES shares(id) ON DELETE CASCADE, + UNIQUE(owner_id, target_type, target_id, recipient_type, recipient_user_id) +); + +CREATE INDEX idx_shares_owner ON shares(owner_id); +CREATE INDEX idx_shares_recipient_user ON shares(recipient_user_id); +CREATE INDEX idx_shares_target ON shares(target_type, target_id); +CREATE INDEX idx_shares_token ON shares(public_token); +CREATE INDEX idx_shares_expires ON shares(expires_at); + +-- Share activity log +CREATE TABLE share_activity ( + id TEXT PRIMARY KEY NOT NULL, + share_id TEXT NOT NULL, + actor_id TEXT, + actor_ip TEXT, + action TEXT NOT NULL, + details TEXT, + timestamp TEXT NOT NULL, + FOREIGN KEY (share_id) REFERENCES shares(id) ON DELETE CASCADE, + FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL +); + +CREATE INDEX idx_share_activity_share ON share_activity(share_id); +CREATE INDEX idx_share_activity_timestamp ON share_activity(timestamp); + +-- Share notifications +CREATE TABLE share_notifications ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL, + share_id TEXT NOT NULL, + notification_type TEXT NOT NULL, + is_read INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (share_id) REFERENCES shares(id) ON DELETE CASCADE +); + +CREATE INDEX idx_share_notifications_user ON share_notifications(user_id); +CREATE INDEX idx_share_notifications_unread ON share_notifications(user_id, is_read) WHERE is_read = 0; + +-- Migrate existing share_links to new shares table (if share_links exists) +INSERT OR IGNORE INTO shares ( + id, target_type, target_id, owner_id, recipient_type, + public_token, public_password_hash, perm_view, perm_download, + access_count, expires_at, created_at, updated_at +) +SELECT + id, 'media', media_id, created_by, 'public_link', + token, password_hash, 1, 1, + view_count, expires_at, created_at, created_at +FROM share_links +WHERE EXISTS (SELECT 1 FROM sqlite_master WHERE type='table' AND name='share_links'); diff --git a/crates/pinakes-migrations/migrations/sqlite/V18__file_management.sql b/migrations/sqlite/V18__file_management.sql similarity index 57% rename from crates/pinakes-migrations/migrations/sqlite/V18__file_management.sql rename to migrations/sqlite/V18__file_management.sql index 08599dd..0af0738 100644 --- a/crates/pinakes-migrations/migrations/sqlite/V18__file_management.sql +++ b/migrations/sqlite/V18__file_management.sql @@ -1,13 +1,11 @@ -- V18: File Management (Rename, Move, Trash) -- Adds soft delete support for trash/recycle bin functionality + -- Add deleted_at column for soft delete (trash) -ALTER TABLE media_items -ADD COLUMN deleted_at TEXT; +ALTER TABLE media_items ADD COLUMN deleted_at TEXT; -- Index for efficient trash queries -CREATE INDEX idx_media_deleted_at ON media_items (deleted_at); +CREATE INDEX idx_media_deleted_at ON media_items(deleted_at); -- Index for listing non-deleted items (most common query pattern) -CREATE INDEX idx_media_not_deleted ON media_items (id) -WHERE - deleted_at IS NULL; +CREATE INDEX idx_media_not_deleted ON media_items(id) WHERE deleted_at IS NULL; diff --git a/migrations/sqlite/V19__markdown_links.sql b/migrations/sqlite/V19__markdown_links.sql new file mode 100644 index 0000000..7cbdda3 --- /dev/null +++ b/migrations/sqlite/V19__markdown_links.sql @@ -0,0 +1,35 @@ +-- V19: Markdown Links (Obsidian-style bidirectional links) +-- Adds support for wikilinks, markdown links, embeds, and backlink tracking + +-- Table for storing extracted markdown links +CREATE TABLE IF NOT EXISTS markdown_links ( + id TEXT PRIMARY KEY NOT NULL, + source_media_id TEXT NOT NULL, + target_path TEXT NOT NULL, -- raw link target (wikilink or path) + target_media_id TEXT, -- resolved media_id (nullable if unresolved) + link_type TEXT NOT NULL, -- 'wikilink', 'markdown_link', 'embed' + link_text TEXT, -- display text for the link + line_number INTEGER, -- line number in source file + context TEXT, -- surrounding text for preview + created_at TEXT NOT NULL, + FOREIGN KEY (source_media_id) REFERENCES media_items(id) ON DELETE CASCADE, + FOREIGN KEY (target_media_id) REFERENCES media_items(id) ON DELETE SET NULL +); + +-- Index for efficient outgoing link queries (what does this note link to?) +CREATE INDEX idx_links_source ON markdown_links(source_media_id); + +-- Index for efficient backlink queries (what links to this note?) +CREATE INDEX idx_links_target ON markdown_links(target_media_id); + +-- Index for path-based resolution (finding unresolved links) +CREATE INDEX idx_links_target_path ON markdown_links(target_path); + +-- Index for link type filtering +CREATE INDEX idx_links_type ON markdown_links(link_type); + +-- Track when links were last extracted from a media item +ALTER TABLE media_items ADD COLUMN links_extracted_at TEXT; + +-- Index for finding media items that need link extraction +CREATE INDEX idx_media_links_extracted ON media_items(links_extracted_at); diff --git a/migrations/sqlite/V1__initial_schema.sql b/migrations/sqlite/V1__initial_schema.sql new file mode 100644 index 0000000..5b16abf --- /dev/null +++ b/migrations/sqlite/V1__initial_schema.sql @@ -0,0 +1,77 @@ +CREATE TABLE IF NOT EXISTS root_dirs ( + path TEXT PRIMARY KEY NOT NULL +); + +CREATE TABLE IF NOT EXISTS media_items ( + id TEXT PRIMARY KEY NOT NULL, + path TEXT NOT NULL UNIQUE, + file_name TEXT NOT NULL, + media_type TEXT NOT NULL, + content_hash TEXT NOT NULL UNIQUE, + file_size INTEGER NOT NULL, + title TEXT, + artist TEXT, + album TEXT, + genre TEXT, + year INTEGER, + duration_secs REAL, + description TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS tags ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + parent_id TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (parent_id) REFERENCES tags(id) ON DELETE SET NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_name_parent ON tags(name, parent_id); + +CREATE TABLE IF NOT EXISTS media_tags ( + media_id TEXT NOT NULL, + tag_id TEXT NOT NULL, + PRIMARY KEY (media_id, tag_id), + FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS collections ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + description TEXT, + kind TEXT NOT NULL, + filter_query TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS collection_members ( + collection_id TEXT NOT NULL, + media_id TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + added_at TEXT NOT NULL, + PRIMARY KEY (collection_id, media_id), + FOREIGN KEY (collection_id) REFERENCES collections(id) ON DELETE CASCADE, + FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS audit_log ( + id TEXT PRIMARY KEY NOT NULL, + media_id TEXT, + action TEXT NOT NULL, + details TEXT, + timestamp TEXT NOT NULL, + FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS custom_fields ( + media_id TEXT NOT NULL, + field_name TEXT NOT NULL, + field_type TEXT NOT NULL, + field_value TEXT NOT NULL, + PRIMARY KEY (media_id, field_name), + FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE CASCADE +); diff --git a/migrations/sqlite/V2__fts5_indexes.sql b/migrations/sqlite/V2__fts5_indexes.sql new file mode 100644 index 0000000..00c5597 --- /dev/null +++ b/migrations/sqlite/V2__fts5_indexes.sql @@ -0,0 +1,27 @@ +CREATE VIRTUAL TABLE IF NOT EXISTS media_fts USING fts5( + title, + artist, + album, + genre, + description, + file_name, + content='media_items', + content_rowid='rowid' +); + +CREATE TRIGGER IF NOT EXISTS media_fts_insert AFTER INSERT ON media_items BEGIN + INSERT INTO media_fts(rowid, title, artist, album, genre, description, file_name) + VALUES (new.rowid, new.title, new.artist, new.album, new.genre, new.description, new.file_name); +END; + +CREATE TRIGGER IF NOT EXISTS media_fts_update AFTER UPDATE ON media_items BEGIN + INSERT INTO media_fts(media_fts, rowid, title, artist, album, genre, description, file_name) + VALUES ('delete', old.rowid, old.title, old.artist, old.album, old.genre, old.description, old.file_name); + INSERT INTO media_fts(rowid, title, artist, album, genre, description, file_name) + VALUES (new.rowid, new.title, new.artist, new.album, new.genre, new.description, new.file_name); +END; + +CREATE TRIGGER IF NOT EXISTS media_fts_delete AFTER DELETE ON media_items BEGIN + INSERT INTO media_fts(media_fts, rowid, title, artist, album, genre, description, file_name) + VALUES ('delete', old.rowid, old.title, old.artist, old.album, old.genre, old.description, old.file_name); +END; diff --git a/migrations/sqlite/V3__audit_indexes.sql b/migrations/sqlite/V3__audit_indexes.sql new file mode 100644 index 0000000..1c741fe --- /dev/null +++ b/migrations/sqlite/V3__audit_indexes.sql @@ -0,0 +1,6 @@ +CREATE INDEX IF NOT EXISTS idx_audit_media_id ON audit_log(media_id); +CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp); +CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action); +CREATE INDEX IF NOT EXISTS idx_media_content_hash ON media_items(content_hash); +CREATE INDEX IF NOT EXISTS idx_media_media_type ON media_items(media_type); +CREATE INDEX IF NOT EXISTS idx_media_created_at ON media_items(created_at); diff --git a/migrations/sqlite/V4__thumbnail_path.sql b/migrations/sqlite/V4__thumbnail_path.sql new file mode 100644 index 0000000..9021884 --- /dev/null +++ b/migrations/sqlite/V4__thumbnail_path.sql @@ -0,0 +1 @@ +ALTER TABLE media_items ADD COLUMN thumbnail_path TEXT; diff --git a/migrations/sqlite/V5__integrity_and_saved_searches.sql b/migrations/sqlite/V5__integrity_and_saved_searches.sql new file mode 100644 index 0000000..650da16 --- /dev/null +++ b/migrations/sqlite/V5__integrity_and_saved_searches.sql @@ -0,0 +1,12 @@ +-- Integrity tracking columns +ALTER TABLE media_items ADD COLUMN last_verified_at TEXT; +ALTER TABLE media_items ADD COLUMN integrity_status TEXT DEFAULT 'unverified'; + +-- Saved searches +CREATE TABLE IF NOT EXISTS saved_searches ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + query TEXT NOT NULL, + sort_order TEXT, + created_at TEXT NOT NULL +); diff --git a/migrations/sqlite/V6__plugin_system.sql b/migrations/sqlite/V6__plugin_system.sql new file mode 100644 index 0000000..f4e7790 --- /dev/null +++ b/migrations/sqlite/V6__plugin_system.sql @@ -0,0 +1,15 @@ +-- Plugin registry table +CREATE TABLE plugin_registry ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + version TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + config_json TEXT, + manifest_json TEXT, + installed_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- Index for quick lookups +CREATE INDEX idx_plugin_registry_enabled ON plugin_registry(enabled); +CREATE INDEX idx_plugin_registry_name ON plugin_registry(name); diff --git a/migrations/sqlite/V7__user_management.sql b/migrations/sqlite/V7__user_management.sql new file mode 100644 index 0000000..6584f03 --- /dev/null +++ b/migrations/sqlite/V7__user_management.sql @@ -0,0 +1,35 @@ +-- Users table +CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- User profiles table +CREATE TABLE user_profiles ( + user_id TEXT PRIMARY KEY, + avatar_path TEXT, + bio TEXT, + preferences_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) +); + +-- User library access table +CREATE TABLE user_libraries ( + user_id TEXT NOT NULL, + root_path TEXT NOT NULL, + permission TEXT NOT NULL, + granted_at TEXT NOT NULL, + PRIMARY KEY (user_id, root_path), + FOREIGN KEY (user_id) REFERENCES users(id) +); + +-- Indexes for efficient lookups +CREATE INDEX idx_users_username ON users(username); +CREATE INDEX idx_user_libraries_user_id ON user_libraries(user_id); +CREATE INDEX idx_user_libraries_root_path ON user_libraries(root_path); diff --git a/migrations/sqlite/V8__media_server_features.sql b/migrations/sqlite/V8__media_server_features.sql new file mode 100644 index 0000000..50040c3 --- /dev/null +++ b/migrations/sqlite/V8__media_server_features.sql @@ -0,0 +1,143 @@ +-- Ratings +CREATE TABLE IF NOT EXISTS ratings ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + media_id TEXT NOT NULL, + stars INTEGER NOT NULL CHECK (stars >= 1 AND stars <= 5), + review_text TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, media_id), + FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE CASCADE +); + +-- Comments +CREATE TABLE IF NOT EXISTS comments ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + media_id TEXT NOT NULL, + parent_comment_id TEXT, + text TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE CASCADE, + FOREIGN KEY (parent_comment_id) REFERENCES comments(id) ON DELETE CASCADE +); + +-- Favorites +CREATE TABLE IF NOT EXISTS favorites ( + user_id TEXT NOT NULL, + media_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (user_id, media_id), + FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE CASCADE +); + +-- Share links +CREATE TABLE IF NOT EXISTS share_links ( + id TEXT PRIMARY KEY, + media_id TEXT NOT NULL, + created_by TEXT NOT NULL, + token TEXT NOT NULL UNIQUE, + password_hash TEXT, + expires_at TEXT, + view_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE CASCADE +); + +-- Playlists +CREATE TABLE IF NOT EXISTS playlists ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + is_public INTEGER NOT NULL DEFAULT 0, + is_smart INTEGER NOT NULL DEFAULT 0, + filter_query TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Playlist items +CREATE TABLE IF NOT EXISTS playlist_items ( + playlist_id TEXT NOT NULL, + media_id TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + added_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (playlist_id, media_id), + FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE, + FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE CASCADE +); + +-- Usage events +CREATE TABLE IF NOT EXISTS usage_events ( + id TEXT PRIMARY KEY, + media_id TEXT, + user_id TEXT, + event_type TEXT NOT NULL, + timestamp TEXT NOT NULL DEFAULT (datetime('now')), + duration_secs REAL, + context_json TEXT, + FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_usage_events_media ON usage_events(media_id); +CREATE INDEX IF NOT EXISTS idx_usage_events_user ON usage_events(user_id); +CREATE INDEX IF NOT EXISTS idx_usage_events_timestamp ON usage_events(timestamp); + +-- Watch history / progress +CREATE TABLE IF NOT EXISTS watch_history ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + media_id TEXT NOT NULL, + progress_secs REAL NOT NULL DEFAULT 0, + last_watched TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, media_id), + FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE CASCADE +); + +-- Subtitles +CREATE TABLE IF NOT EXISTS subtitles ( + id TEXT PRIMARY KEY, + media_id TEXT NOT NULL, + language TEXT, + format TEXT NOT NULL, + file_path TEXT, + is_embedded INTEGER NOT NULL DEFAULT 0, + track_index INTEGER, + offset_ms INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_subtitles_media ON subtitles(media_id); + +-- External metadata (enrichment) +CREATE TABLE IF NOT EXISTS external_metadata ( + id TEXT PRIMARY KEY, + media_id TEXT NOT NULL, + source TEXT NOT NULL, + external_id TEXT, + metadata_json TEXT NOT NULL DEFAULT '{}', + confidence REAL NOT NULL DEFAULT 0.0, + last_updated TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_external_metadata_media ON external_metadata(media_id); + +-- Transcode sessions +CREATE TABLE IF NOT EXISTS transcode_sessions ( + id TEXT PRIMARY KEY, + media_id TEXT NOT NULL, + user_id TEXT, + profile TEXT NOT NULL, + cache_path TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + progress REAL NOT NULL DEFAULT 0.0, + error_message TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT, + FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_transcode_sessions_media ON transcode_sessions(media_id); diff --git a/crates/pinakes-migrations/migrations/sqlite/V9__fix_indexes_and_constraints.sql b/migrations/sqlite/V9__fix_indexes_and_constraints.sql similarity index 57% rename from crates/pinakes-migrations/migrations/sqlite/V9__fix_indexes_and_constraints.sql rename to migrations/sqlite/V9__fix_indexes_and_constraints.sql index dd1bfa0..432f35a 100644 --- a/crates/pinakes-migrations/migrations/sqlite/V9__fix_indexes_and_constraints.sql +++ b/migrations/sqlite/V9__fix_indexes_and_constraints.sql @@ -1,25 +1,18 @@ -- Drop redundant indexes (already covered by UNIQUE constraints) DROP INDEX IF EXISTS idx_users_username; - DROP INDEX IF EXISTS idx_user_libraries_user_id; -- Add missing indexes for comments table -CREATE INDEX IF NOT EXISTS idx_comments_media ON comments (media_id); - -CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments (parent_comment_id); +CREATE INDEX IF NOT EXISTS idx_comments_media ON comments(media_id); +CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_comment_id); -- Remove duplicates before adding unique index (keep the first row) DELETE FROM external_metadata -WHERE - rowid NOT IN ( - SELECT - MIN(rowid) - FROM - external_metadata - GROUP BY - media_id, - source - ); +WHERE rowid NOT IN ( + SELECT MIN(rowid) + FROM external_metadata + GROUP BY media_id, source +); -- Add unique index for external_metadata to prevent duplicates -CREATE UNIQUE INDEX IF NOT EXISTS uq_external_metadata_source ON external_metadata (media_id, source); +CREATE UNIQUE INDEX IF NOT EXISTS uq_external_metadata_source ON external_metadata(media_id, source); diff --git a/nix/shell.nix b/nix/shell.nix index a2a53c9..dcff21d 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -26,22 +26,21 @@ in name = "pinakes-dev"; packages = [ + # Build tools # We use the rust-overlay to get the stable Rust toolchain for various targets. # This is not exactly necessary, but it allows for compiling for various targets - # with the least amount of friction. The extensions are to make sure all tooling - # uses the same Rust version and the general surrounding tooling. + # with the least amount of friction. (rust-bin.nightly.latest.default.override { extensions = ["rustfmt" "rust-src" "rust-analyzer" "clippy" "rust-analyzer"]; targets = ["wasm32-unknown-unknown" "wasm32-wasip1"]; # web + plugins }) - # Modern, LLVM based linking pipeline. Kind of sucks on Windows, though. + # Modern, LLVM based linking pipeline llvmPackages.lld llvmPackages.clang - # CLI helpers - pkgs.dioxus-cli # for packaging Dioxus apps and such - pkgs.just # general command runner for everything + # Handy CLI for packaging Dioxus apps and such + pkgs.dioxus-cli # Additional Cargo Tooling pkgs.cargo-nextest @@ -50,9 +49,29 @@ in # Other tools pkgs.taplo # TOML formatter pkgs.lldb # debugger + pkgs.sccache # distributed Rust cache ] ++ runtimeDeps; + # We could do this in the NixOS configuration via ~/.config/cargo.toml + # or something, but this is better because it lets everyone benefit + # from sccache while working with Pinakes. The reason those variables + # are not in 'env' are that we have to use Bash, which doesn't mix well + # with Nix strings. + shellHook = '' + if [ -n "$SCCACHE_BIN" ]; then + export RUSTC_WRAPPER="$SCCACHE_BIN" + export SCCACHE_DIR="''${HOME}/.cache/sccache" + export SCCACHE_CACHE_SIZE="50G" + mkdir -p "$SCCACHE_DIR" + + echo "sccache setup complete!" + fi + + # Start the daemon early for slightly faster startup. + "$SCCACHE_BIN" --start-server >/dev/null 2>&1 || true + ''; + env = { # Allow Cargo to use lld and clang properly LIBCLANG_PATH = "${llvmPackages.libclang.lib}/lib"; @@ -65,5 +84,8 @@ in # Runtime library path for GTK/WebKit/xdotool LD_LIBRARY_PATH = "${lib.makeLibraryPath runtimeDeps}"; + + # Enable sccache for local nix develop shell + SCCACHE_BIN = "${pkgs.sccache}/bin/sccache"; }; } diff --git a/packages/pinakes-ui/assets/css/main.css b/packages/pinakes-ui/assets/css/main.css deleted file mode 100644 index 149dd65..0000000 --- a/packages/pinakes-ui/assets/css/main.css +++ /dev/null @@ -1,4627 +0,0 @@ -@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; - scrollbar-width: thin; - scrollbar-color: rgba(255, 255, 255, 0.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, 0.06); - border-radius: 3px; -} -*::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.14); -} -: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:focus-visible { - outline: 2px solid #7c7ef5; - outline-offset: 2px; -} -::selection { - background: rgba(124, 126, 245, 0.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; -} -.mt-16 { - margin-top: 16px; -} -.mt-8 { - margin-top: 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: 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%); - } -} -.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, 0.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 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, -.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: -0.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, 0.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: 0.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 0.1s, - background 0.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, 0.03); -} -.nav-item.active { - color: #9698f7; - border-left-color: #7c7ef5; - background: rgba(124, 126, 245, 0.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: 0.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, 0.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, 0.06) rgba(0, 0, 0, 0); -} -.sidebar-import-progress { - padding: 10px 12px; - background: #1f1f28; - border-top: 1px solid rgba(255, 255, 255, 0.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, 0.1); - color: #9d8be0; -} -.role-badge.role-editor { - background: rgba(34, 160, 80, 0.1); - color: #5cb97a; -} -.role-badge.role-viewer { - background: rgba(59, 120, 200, 0.1); - color: #6ca0d4; -} -.btn { - padding: 5px 12px; - border-radius: 3px; - 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: #7c7ef5; - color: #fff; -} -.btn-primary:hover { - background: #8b8df7; -} -.btn-secondary { - background: #26263a; - color: #dcdce4; - border: 1px solid rgba(255, 255, 255, 0.09); -} -.btn-secondary:hover { - border-color: rgba(255, 255, 255, 0.14); - background: rgba(255, 255, 255, 0.06); -} -.btn-danger { - background: rgba(0, 0, 0, 0); - color: #e45858; - border: 1px solid rgba(228, 88, 88, 0.25); -} -.btn-danger:hover { - background: rgba(228, 88, 88, 0.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, 0.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 0.1s; - font-size: 13px; -} -.btn-icon:hover { - color: #dcdce4; -} -.btn:disabled, -.btn[disabled] { - opacity: 0.4; - cursor: not-allowed; - pointer-events: none; -} -.btn.btn-disabled-hint:disabled { - opacity: 0.6; - border-style: dashed; - pointer-events: auto; - cursor: help; -} -.card { - background: #1f1f28; - border: 1px solid rgba(255, 255, 255, 0.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; -} -.card-body { - padding-top: 8px; -} -.data-table { - width: 100%; - border-collapse: collapse; - background: #1f1f28; - border: 1px solid rgba(255, 255, 255, 0.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: 0.04em; - color: #6c6c84; - border-bottom: 1px solid rgba(255, 255, 255, 0.09); - background: #26263a; -} -.data-table tbody td { - padding: 8px 14px; - font-size: 13px; - border-bottom: 1px solid rgba(255, 255, 255, 0.06); - max-width: 300px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.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; -} -.sortable-header { - cursor: pointer; - user-select: none; - transition: color 0.1s; -} -.sortable-header:hover { - color: #9698f7; -} -input[type="text"], -textarea, -select { - padding: 6px 10px; - border-radius: 3px; - border: 1px solid rgba(255, 255, 255, 0.09); - background: #111118; - color: #dcdce4; - font-size: 13px; - outline: none; - transition: border-color 0.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: 0.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; -} -.badge { - display: inline-flex; - align-items: center; - padding: 2px 8px; - border-radius: 50%; - font-size: 9px; - font-weight: 600; - letter-spacing: 0.5px; - text-transform: uppercase; -} -.badge-neutral { - background: rgba(255, 255, 255, 0.04); - color: #a0a0b8; -} -.badge-success { - background: rgba(62, 201, 122, 0.08); - color: #3ec97a; -} -.badge-warning { - background: rgba(212, 160, 55, 0.06); - color: #d4a037; -} -.badge-danger { - background: rgba(228, 88, 88, 0.06); - color: #d47070; -} -.tab-bar { - display: flex; - flex-direction: row; - justify-content: flex-start; - align-items: center; - gap: 4px; - border-bottom: 1px solid rgba(255, 255, 255, 0.09); - margin-bottom: 12px; -} -.tab-btn { - background: rgba(0, 0, 0, 0); - border: none; - padding: 6px 8px; - font-size: 13px; - color: #a0a0b8; - border-bottom: 2px solid rgba(0, 0, 0, 0); - margin-bottom: -1px; - cursor: pointer; - transition: - color 0.1s, - border-color 0.1s; -} -.tab-btn:hover { - color: #dcdce4; -} -.tab-btn.active { - color: #9698f7; - border-bottom-color: #7c7ef5; -} -.input-sm { - padding: 6px 10px; - border-radius: 3px; - border: 1px solid rgba(255, 255, 255, 0.09); - background: #111118; - color: #dcdce4; - font-size: 13px; - outline: none; - transition: border-color 0.15s; - font-family: inherit; - padding: 3px 8px; - font-size: 12px; -} -.input-sm::placeholder { - color: #6c6c84; -} -.input-sm:focus { - border-color: #7c7ef5; -} -.input-suffix { - display: flex; - flex-direction: row; - justify-content: flex-start; - align-items: stretch; -} -.input-suffix input { - border-radius: 3px 0 0 3px; - border-right: none; - flex: 1; -} -.input-suffix .btn { - border-radius: 0 3px 3px 0; -} -.field-error { - color: #d47070; - font-size: 11px; - margin-top: 2px; -} -.comments-list { - display: flex; - flex-direction: column; - justify-content: flex-start; - align-items: stretch; - gap: 8px; -} -.comment-item { - padding: 8px; - background: #18181f; - border-radius: 5px; - border: 1px solid rgba(255, 255, 255, 0.06); -} -.comment-text { - font-size: 13px; - color: #dcdce4; - line-height: 1.5; - margin-top: 4px; -} -input[type="checkbox"] { - appearance: none; - -webkit-appearance: none; - width: 16px; - height: 16px; - border: 1px solid rgba(255, 255, 255, 0.14); - border-radius: 3px; - background: #1f1f28; - cursor: pointer; - position: relative; - flex-shrink: 0; - transition: all 0.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: 0.4; - cursor: not-allowed; -} -.toggle-track { - width: 32px; - height: 18px; - border-radius: 9px; - background: #26263a; - border: 1px solid rgba(255, 255, 255, 0.09); - position: relative; - transition: background 0.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 0.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, 0.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: 0.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, 0.09); - border-radius: 14px; - cursor: pointer; - font-size: 11px; - color: #a0a0b8; - transition: all 0.15s ease; - user-select: none; -} -.filter-chip:hover { - background: #26263a; - border-color: rgba(255, 255, 255, 0.14); - color: #dcdce4; -} -.filter-chip.active { - background: rgba(124, 126, 245, 0.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, 0.09); - flex-shrink: 0; -} -.view-toggle { - display: flex; - border: 1px solid rgba(255, 255, 255, 0.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 0.1s, - color 0.1s; -} -.view-btn:first-child { - border-right: 1px solid rgba(255, 255, 255, 0.09); -} -.view-btn:hover { - color: #dcdce4; - background: #26263a; -} -.view-btn.active { - background: rgba(124, 126, 245, 0.15); - color: #9698f7; -} -.breadcrumb { - display: flex; - flex-direction: row; - justify-content: flex-start; - align-items: center; - gap: 4px; - padding: 10px 16px; - font-size: 0.85rem; - color: #6c6c84; -} -.breadcrumb-sep { - color: #6c6c84; - opacity: 0.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 0.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, 0.09); - border-top-color: #7c7ef5; - border-radius: 50%; - animation: spin 0.7s linear infinite; -} -.spinner-small { - width: 14px; - height: 14px; - border: 2px solid rgba(255, 255, 255, 0.09); - border-top-color: #7c7ef5; - border-radius: 50%; - animation: spin 0.7s linear infinite; -} -.spinner-tiny { - width: 10px; - height: 10px; - border: 1.5px solid rgba(255, 255, 255, 0.09); - border-top-color: #7c7ef5; - border-radius: 50%; - animation: spin 0.7s linear infinite; -} -.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; -} -.modal { - background: #1f1f28; - border: 1px solid rgba(255, 255, 255, 0.09); - border-radius: 7px; - padding: 20px; - min-width: 360px; - max-width: 480px; - box-shadow: 0 4px 20px rgba(0, 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, 0.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, 0.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, 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-native-video { - width: 100%; - display: block; -} -.player-native-audio { - display: none; -} -.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: 0.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, 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: #dcdce4; - cursor: pointer; - font-size: 18px; - padding: 4px; - line-height: 1; - transition: color 0.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, 0.92); - z-index: 150; - display: flex; - flex-direction: column; - justify-content: flex-start; - align-items: stretch; - animation: fade-in 0.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, 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; - flex-direction: row; - justify-content: flex-start; - align-items: center; - gap: 6px; -} -.iv-btn { - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.1); - color: #dcdce4; - border-radius: 3px; - padding: 4px 10px; - font-size: 12px; - cursor: pointer; - transition: background 0.1s; -} -.iv-btn:hover { - background: rgba(255, 255, 255, 0.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, 0.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, 0.09); - border-radius: 3px; - color: #a0a0b8; - font-size: 14px; - cursor: pointer; - transition: all 0.15s; -} -.pdf-toolbar-btn:hover:not(:disabled) { - background: #26263a; - color: #dcdce4; -} -.pdf-toolbar-btn:disabled { - opacity: 0.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, 0.09); -} -.toolbar-btn { - padding: 6px 12px; - border: 1px solid rgba(255, 255, 255, 0.09); - border-radius: 3px; - background: #18181f; - color: #a0a0b8; - font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: all 0.15s; -} -.toolbar-btn:hover { - background: #26263a; - border-color: rgba(255, 255, 255, 0.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, 0.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 0.5em; - border-bottom: 1px solid rgba(255, 255, 255, 0.06); - padding-bottom: 0.3em; -} -.markdown-content h2 { - font-size: 1.5em; - font-weight: 600; - margin: 0.8em 0 0.4em; - border-bottom: 1px solid rgba(255, 255, 255, 0.06); - 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: #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, 0.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: 0.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, 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 rgba(255, 255, 255, 0.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, 0.09); - margin: 1.5em 0; -} -.markdown-content img { - max-width: 100%; - border-radius: 5px; -} -.markdown-content .footnote-definition { - font-size: 0.85em; - color: #a0a0b8; - margin-top: 0.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: 0.8em; -} -.wikilink { - color: #9698f7; - text-decoration: none; - border-bottom: 1px dashed #7c7ef5; - cursor: pointer; - transition: - border-color 0.1s, - color 0.1s; -} -.wikilink:hover { - color: #7c7ef5; - 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: 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, 0.09); - border-radius: 5px; - overflow: hidden; - cursor: pointer; - transition: - border-color 0.12s, - box-shadow 0.12s; - position: relative; -} -.media-card:hover { - border-color: rgba(255, 255, 255, 0.14); - box-shadow: 0 1px 3px rgba(0, 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 0.1s; -} -.card-checkbox input[type="checkbox"] { - width: 16px; - height: 16px; - cursor: pointer; - filter: drop-shadow(0 1px 2px rgba(0, 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: 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 { - 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: 0.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: 0.04em; -} -.type-badge.type-audio { - background: rgba(139, 92, 246, 0.1); - color: #9d8be0; -} -.type-badge.type-video { - background: rgba(200, 72, 130, 0.1); - color: #d07eaa; -} -.type-badge.type-image { - background: rgba(34, 160, 80, 0.1); - color: #5cb97a; -} -.type-badge.type-document { - background: rgba(59, 120, 200, 0.1); - color: #6ca0d4; -} -.type-badge.type-text { - background: rgba(200, 160, 36, 0.1); - color: #c4a840; -} -.type-badge.type-other { - background: rgba(128, 128, 160, 0.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, 0.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: 0.4; - font-size: 13px; - line-height: 1; - transition: opacity 0.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, 0.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: 0.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, 0.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, 0.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: 0.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, 0.09); - color: #dcdce4; - font-size: 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); - z-index: 300; - animation: slide-up 0.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, 0.06); - border: 1px solid rgba(228, 88, 88, 0.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, 0.06); - border: 1px solid rgba(212, 160, 55, 0.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, 0.15); - border: 1px solid rgba(124, 126, 245, 0.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, 0.08); - border-radius: 6px; - margin-bottom: 8px; - font-size: 0.85rem; - color: #a0a0b8; -} -.select-all-banner button { - background: none; - border: none; - color: #7c7ef5; - cursor: pointer; - font-weight: 600; - text-decoration: underline; - font-size: 0.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, 0.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, 0.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 0.1s, - border-color 0.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, 0.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, 0.06); -} -.queue-header h3 { - margin: 0; - font-size: 0.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, 0.06); - transition: background 0.15s; -} -.queue-item:hover { - background: #1f1f28; -} -.queue-item:hover .queue-item-remove { - opacity: 1; -} -.queue-item-active { - background: rgba(124, 126, 245, 0.15); - border-left: 3px solid #7c7ef5; -} -.queue-item-info { - flex: 1; - min-width: 0; -} -.queue-item-title { - display: block; - font-size: 0.85rem; - color: #dcdce4; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.queue-item-artist { - display: block; - font-size: 0.75rem; - color: #6c6c84; -} -.queue-item-remove { - opacity: 0; - transition: opacity 0.15s; -} -.queue-empty { - padding: 16px 16px; - text-align: center; - color: #6c6c84; - font-size: 0.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, 0.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, 0.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, 0.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 0.6s cubic-bezier(0.4, 0, 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, 0.09); - border-radius: 7px; - padding: 20px; - margin-bottom: 16px; -} -.settings-card.danger-card { - border: 1px solid rgba(228, 88, 88, 0.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, 0.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, 0.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, 0.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, 0.1); - color: #3ec97a; -} -.config-status.readonly { - background: rgba(228, 88, 88, 0.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, 0.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, 0.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, 0.09); - border-radius: 5px; - overflow: hidden; - transition: all 0.2s; -} -.task-card:hover { - border-color: rgba(255, 255, 255, 0.14); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); - transform: translateY(-2px); -} -.task-card-enabled { - border-left: 3px solid #3ec97a; -} -.task-card-disabled { - border-left: 3px solid #4a4a5e; - opacity: 0.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, 0.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: 0.03em; -} -.status-badge.status-enabled { - background: rgba(76, 175, 80, 0.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: 0.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, 0.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, 0.06); -} -.db-action-info { - flex: 1; -} -.db-action-info h4 { - font-size: 0.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, 0.1); - color: #d47070; -} -.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: #6c6c84; -} -.clickable { - cursor: pointer; - color: #9698f7; -} -.clickable:hover { - text-decoration: underline; -} -.clickable-row { - cursor: pointer; -} -.clickable-row:hover { - background: rgba(255, 255, 255, 0.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, 0.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, 0.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, 0.06); -} -.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: 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, 0.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 0.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, 0.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 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: #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, 0.09); - border-radius: 3px; - color: #6c6c84; - font-size: 12px; - cursor: pointer; - transition: - background 0.1s, - color 0.1s, - border-color 0.1s; -} -.backlinks-reindex-btn:hover:not(:disabled) { - background: #1f1f28; - color: #dcdce4; - border-color: rgba(255, 255, 255, 0.14); -} -.backlinks-reindex-btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} -.backlinks-content, -.outgoing-links-content { - padding: 12px; - border-top: 1px solid rgba(255, 255, 255, 0.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, 0.06); - border: 1px solid rgba(228, 88, 88, 0.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, 0.06); - border-radius: 3px; - cursor: pointer; - transition: - background 0.1s, - border-color 0.1s; -} -.backlink-item:hover, -.outgoing-link-item:hover { - background: #18181f; - border-color: rgba(255, 255, 255, 0.09); -} -.backlink-item.unresolved, -.outgoing-link-item.unresolved { - opacity: 0.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: 0.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, 0.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, 0.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, 0.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, 0.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, 0.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, 0.06); - border: 1px solid rgba(212, 160, 55, 0.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, 0.08); - border: 1px solid rgba(62, 201, 122, 0.2); - color: #3ec97a; -} -.backlinks-message.error { - background: rgba(228, 88, 88, 0.06); - border: 1px solid rgba(228, 88, 88, 0.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, 0.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, 0.09); - color: #dcdce4; - font-size: 18px; - font-weight: bold; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all 0.15s; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); -} -.zoom-btn:hover { - background: #26263a; - border-color: rgba(255, 255, 255, 0.14); - transform: scale(1.05); -} -.zoom-btn:active { - transform: scale(0.95); -} -.graph-edges line { - stroke: rgba(255, 255, 255, 0.14); - stroke-width: 1; - opacity: 0.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 0.15s, - stroke 0.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, 0.09); - border-radius: 5px; - box-shadow: 0 2px 8px rgba(0, 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, 0.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, 0.09); - border-radius: 5px; - box-shadow: 0 2px 8px rgba(0, 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, 0.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: 0.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 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: #7c7ef5; - 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: 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, 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.12); -} -.theme-light ::-webkit-scrollbar-thumb:hover { - background: rgba(0, 0, 0, 0.08); -} -.theme-light ::-webkit-scrollbar-track { - background: rgba(0, 0, 0, 0.06); -} -.theme-light .graph-nodes .graph-node text { - fill: #1a1a2e; -} -.theme-light .graph-edges line { - stroke: rgba(0, 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, 0.3); - z-index: 100; - border-radius: 8px; -} -.loading-spinner { - width: 32px; - height: 32px; - border: 3px solid rgba(255, 255, 255, 0.09); - border-top-color: #7c7ef5; - border-radius: 50%; - animation: spin 0.8s linear infinite; -} -.loading-message { - color: #a0a0b8; - font-size: 0.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, 0.09); - border-radius: 7px; - padding: 24px; - width: 360px; - box-shadow: 0 4px 20px rgba(0, 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, 0.08); - border: 1px solid rgba(228, 88, 88, 0.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, 0.5); - display: flex; - align-items: center; - justify-content: center; - z-index: 200; - animation: fade-in 0.1s ease-out; -} -.help-dialog { - background: #1f1f28; - border: 1px solid rgba(255, 255, 255, 0.09); - border-radius: 7px; - padding: 16px; - min-width: 300px; - max-width: 400px; - box-shadow: 0 4px 20px rgba(0, 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, 0.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, 0.09); - border-radius: 3px; - color: #dcdce4; - font-size: 12px; - cursor: pointer; - text-align: center; -} -.help-close:hover { - background: rgba(255, 255, 255, 0.06); -} -.plugin-page { - padding: 16px 24px; - max-width: 100%; - overflow-x: hidden; -} -.plugin-page-title { - font-size: 14px; - font-weight: 600; - color: #dcdce4; - margin: 0 0 16px; -} -.plugin-container { - display: flex; - flex-direction: column; - gap: var(--plugin-gap, 0px); - padding: var(--plugin-padding, 0); -} -.plugin-grid { - display: grid; - grid-template-columns: repeat(var(--plugin-columns, 1), 1fr); - gap: var(--plugin-gap, 0px); -} -.plugin-flex { - display: flex; - gap: var(--plugin-gap, 0px); -} -.plugin-flex[data-direction="row"] { - flex-direction: row; -} -.plugin-flex[data-direction="column"] { - flex-direction: column; -} -.plugin-flex[data-justify="flex-start"] { - justify-content: flex-start; -} -.plugin-flex[data-justify="flex-end"] { - justify-content: flex-end; -} -.plugin-flex[data-justify="center"] { - justify-content: center; -} -.plugin-flex[data-justify="space-between"] { - justify-content: space-between; -} -.plugin-flex[data-justify="space-around"] { - justify-content: space-around; -} -.plugin-flex[data-justify="space-evenly"] { - justify-content: space-evenly; -} -.plugin-flex[data-align="flex-start"] { - align-items: flex-start; -} -.plugin-flex[data-align="flex-end"] { - align-items: flex-end; -} -.plugin-flex[data-align="center"] { - align-items: center; -} -.plugin-flex[data-align="stretch"] { - align-items: stretch; -} -.plugin-flex[data-align="baseline"] { - align-items: baseline; -} -.plugin-flex[data-wrap="wrap"] { - flex-wrap: wrap; -} -.plugin-flex[data-wrap="nowrap"] { - flex-wrap: nowrap; -} -.plugin-split { - display: flex; -} -.plugin-split-sidebar { - width: var(--plugin-sidebar-width, 200px); - flex-shrink: 0; -} -.plugin-split-main { - flex: 1; - min-width: 0; -} -.plugin-card { - background: #1f1f28; - border: 1px solid rgba(255, 255, 255, 0.09); - border-radius: 7px; - overflow: hidden; -} -.plugin-card-header { - padding: 12px 16px; - font-size: 12px; - font-weight: 600; - color: #dcdce4; - border-bottom: 1px solid rgba(255, 255, 255, 0.09); - background: #26263a; -} -.plugin-card-content { - padding: 16px; -} -.plugin-card-footer { - padding: 12px 16px; - border-top: 1px solid rgba(255, 255, 255, 0.09); - background: #18181f; -} -.plugin-heading { - color: #dcdce4; - margin: 0; - line-height: 1.2; -} -.plugin-heading.level-1 { - font-size: 28px; - font-weight: 700; -} -.plugin-heading.level-2 { - font-size: 18px; - font-weight: 600; -} -.plugin-heading.level-3 { - font-size: 16px; - font-weight: 600; -} -.plugin-heading.level-4 { - font-size: 14px; - font-weight: 500; -} -.plugin-heading.level-5 { - font-size: 13px; - font-weight: 500; -} -.plugin-heading.level-6 { - font-size: 12px; - font-weight: 500; -} -.plugin-text { - margin: 0; - font-size: 12px; - color: #dcdce4; - line-height: 1.4; -} -.plugin-text.text-secondary { - color: #a0a0b8; -} -.plugin-text.text-error { - color: #d47070; -} -.plugin-text.text-success { - color: #3ec97a; -} -.plugin-text.text-warning { - color: #d4a037; -} -.plugin-text.text-bold { - font-weight: 600; -} -.plugin-text.text-italic { - font-style: italic; -} -.plugin-text.text-small { - font-size: 10px; -} -.plugin-text.text-large { - font-size: 15px; -} -.plugin-code { - background: #18181f; - border: 1px solid rgba(255, 255, 255, 0.09); - border-radius: 5px; - padding: 16px 24px; - font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace; - font-size: 12px; - color: #dcdce4; - overflow-x: auto; - white-space: pre; -} -.plugin-code code { - font-family: inherit; - font-size: inherit; - color: inherit; -} -.plugin-tabs { - display: flex; - flex-direction: column; -} -.plugin-tab-list { - display: flex; - gap: 2px; - border-bottom: 1px solid rgba(255, 255, 255, 0.09); - margin-bottom: 16px; -} -.plugin-tab { - padding: 8px 20px; - font-size: 12px; - font-weight: 500; - color: #a0a0b8; - background: rgba(0, 0, 0, 0); - border: none; - border-bottom: 2px solid rgba(0, 0, 0, 0); - cursor: pointer; - transition: - color 0.1s, - border-color 0.1s; -} -.plugin-tab:hover { - color: #dcdce4; -} -.plugin-tab.active { - color: #9698f7; - border-bottom-color: #7c7ef5; -} -.plugin-tab .tab-icon { - margin-right: 4px; -} -.plugin-tab-panel:not(.active) { - display: none; -} -.plugin-description-list-wrapper { - width: 100%; -} -.plugin-description-list { - display: grid; - grid-template-columns: max-content 1fr; - gap: 4px 16px; - margin: 0; - padding: 0; -} -.plugin-description-list dt { - font-size: 10px; - font-weight: 500; - color: #a0a0b8; - text-transform: uppercase; - letter-spacing: 0.5px; - padding: 6px 0; - white-space: nowrap; -} -.plugin-description-list dd { - font-size: 12px; - color: #dcdce4; - padding: 6px 0; - margin: 0; - word-break: break-word; -} -.plugin-description-list.horizontal { - display: flex; - flex-wrap: wrap; - gap: 16px 24px; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); -} -.plugin-description-list.horizontal dt { - width: auto; - padding: 0; -} -.plugin-description-list.horizontal dd { - width: auto; - padding: 0; -} -.plugin-description-list.horizontal dt, -.plugin-description-list.horizontal dd { - display: inline; -} -.plugin-description-list.horizontal dt { - font-size: 9px; - text-transform: uppercase; - letter-spacing: 0.5px; - color: #6c6c84; - margin-bottom: 2px; -} -.plugin-description-list.horizontal dd { - font-size: 13px; - font-weight: 600; - color: #dcdce4; -} -.plugin-data-table-wrapper { - overflow-x: auto; -} -.plugin-data-table { - width: 100%; - border-collapse: collapse; - font-size: 12px; -} -.plugin-data-table thead tr { - border-bottom: 1px solid rgba(255, 255, 255, 0.14); -} -.plugin-data-table thead th { - padding: 8px 12px; - text-align: left; - font-size: 10px; - font-weight: 600; - color: #a0a0b8; - text-transform: uppercase; - letter-spacing: 0.5px; - white-space: nowrap; -} -.plugin-data-table tbody tr { - border-bottom: 1px solid rgba(255, 255, 255, 0.06); - transition: background 0.08s; -} -.plugin-data-table tbody tr:hover { - background: rgba(255, 255, 255, 0.03); -} -.plugin-data-table tbody tr:last-child { - border-bottom: none; -} -.plugin-data-table tbody td { - padding: 8px 12px; - color: #dcdce4; - vertical-align: middle; -} -.plugin-col-constrained { - width: var(--plugin-col-width); -} -.table-filter { - margin-bottom: 12px; -} -.table-filter input { - width: 240px; - padding: 6px 12px; - background: #18181f; - border: 1px solid rgba(255, 255, 255, 0.09); - border-radius: 5px; - color: #dcdce4; - font-size: 12px; -} -.table-filter input::placeholder { - color: #6c6c84; -} -.table-filter input:focus { - outline: none; - border-color: #7c7ef5; -} -.table-pagination { - display: flex; - align-items: center; - gap: 12px; - padding: 8px 0; - font-size: 12px; - color: #a0a0b8; -} -.row-actions { - white-space: nowrap; - width: 1%; -} -.row-actions .plugin-button { - padding: 4px 8px; - font-size: 10px; - margin-right: 4px; -} -.plugin-media-grid { - display: grid; - grid-template-columns: repeat(var(--plugin-columns, 2), 1fr); - gap: var(--plugin-gap, 8px); -} -.media-grid-item { - background: #1f1f28; - border: 1px solid rgba(255, 255, 255, 0.09); - border-radius: 7px; - overflow: hidden; - display: flex; - flex-direction: column; -} -.media-grid-img { - width: 100%; - aspect-ratio: 16/9; - object-fit: cover; - display: block; -} -.media-grid-no-img { - width: 100%; - aspect-ratio: 16/9; - background: #26263a; - display: flex; - align-items: center; - justify-content: center; - font-size: 10px; - color: #6c6c84; -} -.media-grid-caption { - padding: 8px 12px; - font-size: 10px; - color: #dcdce4; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.plugin-list { - list-style: none; - margin: 0; - padding: 0; -} -.plugin-list-item { - padding: 8px 0; -} -.plugin-list-divider { - border: none; - border-top: 1px solid rgba(255, 255, 255, 0.06); - margin: 0; -} -.plugin-list-empty { - padding: 16px; - text-align: center; - color: #6c6c84; - font-size: 12px; -} -.plugin-button { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 8px 16px; - border: 1px solid rgba(255, 255, 255, 0.09); - border-radius: 5px; - font-size: 12px; - font-weight: 500; - cursor: pointer; - transition: - background 0.08s, - border-color 0.08s, - color 0.08s; - background: #1f1f28; - color: #dcdce4; -} -.plugin-button:disabled { - opacity: 0.45; - cursor: not-allowed; -} -.plugin-button.btn-primary { - background: #7c7ef5; - border-color: #7c7ef5; - color: #fff; -} -.plugin-button.btn-primary:hover:not(:disabled) { - background: #8b8df7; -} -.plugin-button.btn-secondary { - background: #26263a; - border-color: rgba(255, 255, 255, 0.14); - color: #dcdce4; -} -.plugin-button.btn-secondary:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.04); -} -.plugin-button.btn-tertiary { - background: rgba(0, 0, 0, 0); - border-color: rgba(0, 0, 0, 0); - color: #9698f7; -} -.plugin-button.btn-tertiary:hover:not(:disabled) { - background: rgba(124, 126, 245, 0.15); -} -.plugin-button.btn-danger { - background: rgba(0, 0, 0, 0); - border-color: rgba(228, 88, 88, 0.2); - color: #d47070; -} -.plugin-button.btn-danger:hover:not(:disabled) { - background: rgba(228, 88, 88, 0.06); -} -.plugin-button.btn-success { - background: rgba(0, 0, 0, 0); - border-color: rgba(62, 201, 122, 0.2); - color: #3ec97a; -} -.plugin-button.btn-success:hover:not(:disabled) { - background: rgba(62, 201, 122, 0.08); -} -.plugin-button.btn-ghost { - background: rgba(0, 0, 0, 0); - border-color: rgba(0, 0, 0, 0); - color: #a0a0b8; -} -.plugin-button.btn-ghost:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.04); -} -.plugin-badge { - display: inline-flex; - align-items: center; - padding: 2px 8px; - border-radius: 50%; - font-size: 9px; - font-weight: 600; - letter-spacing: 0.5px; - text-transform: uppercase; -} -.plugin-badge.badge-default, -.plugin-badge.badge-neutral { - background: rgba(255, 255, 255, 0.04); - color: #a0a0b8; -} -.plugin-badge.badge-primary { - background: rgba(124, 126, 245, 0.15); - color: #9698f7; -} -.plugin-badge.badge-secondary { - background: rgba(255, 255, 255, 0.03); - color: #dcdce4; -} -.plugin-badge.badge-success { - background: rgba(62, 201, 122, 0.08); - color: #3ec97a; -} -.plugin-badge.badge-warning { - background: rgba(212, 160, 55, 0.06); - color: #d4a037; -} -.plugin-badge.badge-error { - background: rgba(228, 88, 88, 0.06); - color: #d47070; -} -.plugin-badge.badge-info { - background: rgba(99, 102, 241, 0.08); - color: #9698f7; -} -.plugin-form { - display: flex; - flex-direction: column; - gap: 16px; -} -.form-field { - display: flex; - flex-direction: column; - gap: 6px; -} -.form-field label { - font-size: 12px; - font-weight: 500; - color: #dcdce4; -} -.form-field input, -.form-field textarea, -.form-field select { - padding: 8px 12px; - background: #18181f; - border: 1px solid rgba(255, 255, 255, 0.09); - border-radius: 5px; - color: #dcdce4; - font-size: 12px; - font-family: inherit; -} -.form-field input::placeholder, -.form-field textarea::placeholder, -.form-field select::placeholder { - color: #6c6c84; -} -.form-field input:focus, -.form-field textarea:focus, -.form-field select:focus { - outline: none; - border-color: #7c7ef5; - box-shadow: 0 0 0 2px rgba(124, 126, 245, 0.15); -} -.form-field textarea { - min-height: 80px; - resize: vertical; -} -.form-field select { - appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23a0a0b8' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 12px center; - padding-right: 32px; -} -.form-help { - margin: 0; - font-size: 10px; - color: #6c6c84; -} -.form-actions { - display: flex; - gap: 12px; - padding-top: 8px; -} -.required { - color: #e45858; -} -.plugin-link { - color: #9698f7; - text-decoration: none; -} -.plugin-link:hover { - text-decoration: underline; -} -.plugin-link-blocked { - color: #6c6c84; - text-decoration: line-through; - cursor: not-allowed; -} -.plugin-progress { - background: #18181f; - border: 1px solid rgba(255, 255, 255, 0.09); - border-radius: 5px; - height: 8px; - overflow: hidden; - display: flex; - align-items: center; - gap: 8px; -} -.plugin-progress-bar { - height: 100%; - background: #7c7ef5; - border-radius: 4px; - transition: width 0.3s ease; - width: var(--plugin-progress, 0%); -} -.plugin-progress-label { - font-size: 10px; - color: #a0a0b8; - white-space: nowrap; - flex-shrink: 0; -} -.plugin-chart { - overflow: auto; - height: var(--plugin-chart-height, 200px); -} -.plugin-chart .chart-title { - font-size: 13px; - font-weight: 600; - color: #dcdce4; - margin-bottom: 8px; -} -.plugin-chart .chart-x-label, -.plugin-chart .chart-y-label { - font-size: 10px; - color: #6c6c84; - margin-bottom: 4px; -} -.plugin-chart .chart-data-table { - overflow-x: auto; -} -.plugin-chart .chart-no-data { - padding: 24px; - text-align: center; - color: #6c6c84; - font-size: 12px; -} -.plugin-loading { - padding: 16px; - color: #a0a0b8; - font-size: 12px; - font-style: italic; -} -.plugin-error { - padding: 12px 16px; - background: rgba(228, 88, 88, 0.06); - border: 1px solid rgba(228, 88, 88, 0.2); - border-radius: 5px; - color: #d47070; - font-size: 12px; -} -.plugin-feedback { - position: sticky; - bottom: 16px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 12px 16px; - border-radius: 7px; - font-size: 12px; - z-index: 300; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.45); -} -.plugin-feedback.success { - background: rgba(62, 201, 122, 0.08); - border: 1px solid rgba(62, 201, 122, 0.2); - color: #3ec97a; -} -.plugin-feedback.error { - background: rgba(228, 88, 88, 0.06); - border: 1px solid rgba(228, 88, 88, 0.2); - color: #d47070; -} -.plugin-feedback-dismiss { - background: rgba(0, 0, 0, 0); - border: none; - color: inherit; - font-size: 14px; - cursor: pointer; - line-height: 1; - padding: 0; - opacity: 0.7; -} -.plugin-feedback-dismiss:hover { - opacity: 1; -} -.plugin-modal-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.65); - display: flex; - align-items: center; - justify-content: center; - z-index: 100; -} -.plugin-modal { - position: relative; - background: #1f1f28; - border: 1px solid rgba(255, 255, 255, 0.14); - border-radius: 12px; - padding: 32px; - min-width: 380px; - max-width: 640px; - max-height: 80vh; - overflow-y: auto; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.45); - z-index: 200; -} -.plugin-modal-close { - position: absolute; - top: 16px; - right: 16px; - background: rgba(0, 0, 0, 0); - border: none; - color: #a0a0b8; - font-size: 14px; - cursor: pointer; - line-height: 1; - padding: 4px; - border-radius: 5px; -} -.plugin-modal-close:hover { - background: rgba(255, 255, 255, 0.04); - color: #dcdce4; -} diff --git a/xtask/src/docs.rs b/xtask/src/docs.rs index bcf373a..5f9872c 100644 --- a/xtask/src/docs.rs +++ b/xtask/src/docs.rs @@ -95,45 +95,8 @@ pub fn run() { files_written += 1; } - // Generate docs/api.md index - let index_path = std::path::Path::new("docs/api.md"); - let mut index = String::new(); - index.push_str("# API Documentation\n\n"); - index.push_str( - "This is the index of all generated REST API documentation for \ - Pinakes.\n\n", - ); - index.push_str( - "Documentation is generated from OpenAPI annotations via `cargo xtask \ - docs`\n", - ); - index.push_str("(or `just docs`). Do not edit generated files by hand.\n\n"); - index.push_str("## Reference\n\n"); - index.push_str( - "- [openapi.json](api/openapi.json) - Full OpenAPI 3.0 specification\n\n", - ); - index.push_str("## Endpoints by Tag\n\n"); - for tag_name in tag_ops.keys() { - let file_name = format!("{}.md", tag_name.replace('/', "_")); - let description = tag_descriptions.get(tag_name).map_or("", String::as_str); - if description.is_empty() { - writeln!(index, "- [{}](api/{file_name})", title_case(tag_name)) - .expect("write to String"); - } else { - writeln!( - index, - "- [{}](api/{file_name}) - {description}", - title_case(tag_name) - ) - .expect("write to String"); - } - } - std::fs::write(index_path, &index).expect("write docs/api.md"); - println!("Written docs/api.md"); - println!( - "Done: wrote docs/api/openapi.json, docs/api.md, and {files_written} \ - markdown files." + "Done: wrote docs/api/openapi.json and {files_written} markdown files." ); }