Compare commits
No commits in common. "main" and "notashelf/push-wsulzmvymvxq" have entirely different histories.
main
...
notashelf/
366 changed files with 9484 additions and 17993 deletions
|
|
@ -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
|
|
||||||
3
.envrc
3
.envrc
|
|
@ -1,4 +1 @@
|
||||||
watch_file nix/shell.nix
|
|
||||||
watch_file flake.lock
|
|
||||||
|
|
||||||
use flake
|
use flake
|
||||||
|
|
|
||||||
12
.taplo.toml
12
.taplo.toml
|
|
@ -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
|
|
||||||
BIN
Cargo.lock
generated
BIN
Cargo.lock
generated
Binary file not shown.
264
Cargo.toml
264
Cargo.toml
|
|
@ -1,67 +1,73 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
exclude = [
|
members = ["crates/*", "xtask"]
|
||||||
"crates/pinakes-core/tests/fixtures/test-plugin",
|
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" ]
|
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2024" # keep in sync with .rustfmt.toml
|
edition = "2024" # keep in sync with .rustfmt.toml
|
||||||
license = "EUPL-1.2"
|
version = "0.3.0-dev"
|
||||||
rust-version = "1.95.0"
|
license = "EUPL-1.2"
|
||||||
version = "0.4.0-dev"
|
readme = true
|
||||||
readme = true
|
rust-version = "1.95.0" # follows nightly Rust
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
# Crate components for Pinakes. Those are the internal dependencies that are built
|
# Crate components for Pinakes.
|
||||||
# while building any package.
|
pinakes-core = { path = "./crates/pinakes-core" }
|
||||||
pinakes-core = { path = "./crates/pinakes-core" }
|
pinakes-server = { path = "./crates/pinakes-server" }
|
||||||
pinakes-enrichment = { path = "./crates/pinakes-enrichment" }
|
|
||||||
pinakes-metadata = { path = "./crates/pinakes-metadata" }
|
|
||||||
pinakes-migrations = { path = "./crates/pinakes-migrations" }
|
|
||||||
pinakes-plugin = { path = "./crates/pinakes-plugin" }
|
|
||||||
pinakes-plugin-api = { path = "./crates/pinakes-plugin-api" }
|
pinakes-plugin-api = { path = "./crates/pinakes-plugin-api" }
|
||||||
pinakes-sync = { path = "./crates/pinakes-sync" }
|
pinakes-ui = { path = "./crates/pinakes-ui" }
|
||||||
pinakes-types = { path = "./crates/pinakes-types" }
|
pinakes-tui = { path = "./crates/pinakes-tui" }
|
||||||
|
|
||||||
# Pinakes itself is a REST API server. UI and TUI are official visual components
|
tokio = { version = "1.49.0", features = ["full"] }
|
||||||
# that connect to the server. Using the API documentation, the user can write
|
tokio-util = { version = "0.7.18", features = ["rt"] }
|
||||||
# their own clients, but we separate "crates" and "packages" to establish the
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
# distinction properly.
|
serde_json = "1.0.149"
|
||||||
pinakes-server = { path = "./packages/pinakes-server" }
|
toml = "1.0.3"
|
||||||
pinakes-tui = { path = "./packages/pinakes-tui" }
|
clap = { version = "4.5.60", features = ["derive", "env"] }
|
||||||
pinakes-ui = { path = "./packages/pinakes-ui" }
|
chrono = { version = "0.4.44", features = ["serde"] }
|
||||||
|
uuid = { version = "1.21.0", features = ["v7", "serde"] }
|
||||||
# Other dependencies. Declaring them in the virtual manifests lets use reuse the crates
|
thiserror = "2.0.18"
|
||||||
# without having to track individual crate version across different types of crates. This
|
|
||||||
# also includes *dev* dependencies.
|
|
||||||
ammonia = "4.1.2"
|
|
||||||
anyhow = "1.0.102"
|
anyhow = "1.0.102"
|
||||||
argon2 = { version = "0.5.3", features = [ "std" ] }
|
tracing = "0.1.44"
|
||||||
async-trait = "0.1.89"
|
tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] }
|
||||||
axum = { version = "0.8.9", features = [ "macros", "multipart" ] }
|
blake3 = "1.8.3"
|
||||||
axum-server = { version = "0.8.0" }
|
rustc-hash = "2.1.1"
|
||||||
blake3 = "1.8.5"
|
ed25519-dalek = { version = "2.1.1", features = ["std"] }
|
||||||
chrono = { version = "0.4.44", features = [ "serde" ] }
|
lofty = "0.23.2"
|
||||||
clap = { version = "4.6.1", features = [ "derive", "env" ] }
|
lopdf = "0.39.0"
|
||||||
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" ] }
|
|
||||||
epub = "2.1.5"
|
epub = "2.1.5"
|
||||||
futures = "0.3.32"
|
matroska = "0.30.0"
|
||||||
gloo-timers = { version = "0.4.0", features = [ "futures" ] }
|
|
||||||
governor = "0.10.4"
|
|
||||||
gray_matter = "0.3.2"
|
gray_matter = "0.3.2"
|
||||||
http = "1.4.0"
|
kamadak-exif = "0.6.1"
|
||||||
http-body-util = "0.1.3"
|
rusqlite = { version = "=0.37.0", features = ["bundled", "column_decltype"] }
|
||||||
image = { version = "0.25.10", default-features = false, features = [
|
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",
|
"jpeg",
|
||||||
"png",
|
"png",
|
||||||
"webp",
|
"webp",
|
||||||
|
|
@ -69,97 +75,71 @@ image = { version = "0.25.10", default-features = false, features = [
|
||||||
"tiff",
|
"tiff",
|
||||||
"bmp",
|
"bmp",
|
||||||
] }
|
] }
|
||||||
image_hasher = "3.1.1"
|
pulldown-cmark = "0.13.1"
|
||||||
kamadak-exif = "0.6.1"
|
ammonia = "4.1.2"
|
||||||
lofty = "0.24.0"
|
argon2 = { version = "0.5.3", features = ["std"] }
|
||||||
lopdf = "0.40.0"
|
|
||||||
matroska = "0.30.1"
|
|
||||||
mime_guess = "2.0.5"
|
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"
|
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"
|
rfd = "0.17.2"
|
||||||
rusqlite = { version = "0.39.0", features = [ "bundled", "column_decltype" ] }
|
gloo-timers = { version = "0.3.0", features = ["futures"] }
|
||||||
rusqlite_migration = "2.5.0"
|
rand = "0.10.0"
|
||||||
rustc-hash = "2.1.2"
|
moka = { version = "0.12.14", features = ["future"] }
|
||||||
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"
|
|
||||||
urlencoding = "2.1.3"
|
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-axum = { version = "0.2.0" }
|
||||||
utoipa-swagger-ui = { version = "9.0.2", features = [ "axum" ] }
|
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"
|
|
||||||
|
|
||||||
# See:
|
# See:
|
||||||
# <https://doc.rust-lang.org/rustc/lints/listing/allowed-by-default.html>
|
# <https://doc.rust-lang.org/rustc/lints/listing/allowed-by-default.html>
|
||||||
[workspace.lints.clippy]
|
[workspace.lints.clippy]
|
||||||
cargo = { level = "warn", priority = -1 }
|
cargo = { level = "warn", priority = -1 }
|
||||||
complexity = { level = "warn", priority = -1 }
|
complexity = { level = "warn", priority = -1 }
|
||||||
nursery = { level = "warn", priority = -1 }
|
nursery = { level = "warn", priority = -1 }
|
||||||
pedantic = { level = "warn", priority = -1 }
|
pedantic = { level = "warn", priority = -1 }
|
||||||
perf = { level = "warn", priority = -1 }
|
perf = { level = "warn", priority = -1 }
|
||||||
style = { level = "warn", priority = -1 }
|
style = { level = "warn", priority = -1 }
|
||||||
|
|
||||||
# The lint groups above enable some less-than-desirable rules, we should manually
|
# The lint groups above enable some less-than-desirable rules, we should manually
|
||||||
# enable those to keep our sanity.
|
# enable those to keep our sanity.
|
||||||
absolute_paths = "allow"
|
absolute_paths = "allow"
|
||||||
arbitrary_source_item_ordering = "allow"
|
arbitrary_source_item_ordering = "allow"
|
||||||
clone_on_ref_ptr = "warn"
|
clone_on_ref_ptr = "warn"
|
||||||
dbg_macro = "warn"
|
dbg_macro = "warn"
|
||||||
empty_drop = "warn"
|
empty_drop = "warn"
|
||||||
empty_structs_with_brackets = "warn"
|
empty_structs_with_brackets = "warn"
|
||||||
exit = "warn"
|
exit = "warn"
|
||||||
filetype_is_file = "warn"
|
filetype_is_file = "warn"
|
||||||
get_unwrap = "warn"
|
get_unwrap = "warn"
|
||||||
implicit_return = "allow"
|
implicit_return = "allow"
|
||||||
infinite_loop = "warn"
|
infinite_loop = "warn"
|
||||||
map_with_unused_argument_over_ranges = "warn"
|
map_with_unused_argument_over_ranges = "warn"
|
||||||
missing_docs_in_private_items = "allow"
|
missing_docs_in_private_items = "allow"
|
||||||
multiple_crate_versions = "allow" # :(
|
multiple_crate_versions = "allow" # :(
|
||||||
non_ascii_literal = "allow"
|
non_ascii_literal = "allow"
|
||||||
non_std_lazy_statics = "warn"
|
non_std_lazy_statics = "warn"
|
||||||
pathbuf_init_then_push = "warn"
|
pathbuf_init_then_push = "warn"
|
||||||
pattern_type_mismatch = "allow"
|
pattern_type_mismatch = "allow"
|
||||||
question_mark_used = "allow"
|
question_mark_used = "allow"
|
||||||
rc_buffer = "warn"
|
rc_buffer = "warn"
|
||||||
rc_mutex = "warn"
|
rc_mutex = "warn"
|
||||||
rest_pat_in_fully_bound_structs = "warn"
|
rest_pat_in_fully_bound_structs = "warn"
|
||||||
significant_drop_tightening = "allow" # rusqlite Statement<'conn> borrows the guard; cannot drop early
|
similar_names = "allow"
|
||||||
similar_names = "allow"
|
single_call_fn = "allow"
|
||||||
single_call_fn = "allow"
|
std_instead_of_core = "allow"
|
||||||
std_instead_of_core = "allow"
|
too_long_first_doc_paragraph = "allow"
|
||||||
too_long_first_doc_paragraph = "allow"
|
too_many_lines = "allow"
|
||||||
too_many_arguments = "allow"
|
undocumented_unsafe_blocks = "warn"
|
||||||
too_many_lines = "allow"
|
unnecessary_safety_comment = "warn"
|
||||||
undocumented_unsafe_blocks = "warn"
|
unused_result_ok = "warn"
|
||||||
unnecessary_safety_comment = "warn"
|
unused_trait_names = "allow"
|
||||||
unused_result_ok = "warn"
|
too_many_arguments = "allow"
|
||||||
unused_trait_names = "allow"
|
|
||||||
|
|
||||||
# False positive:
|
# False positive:
|
||||||
# clippy's build script check doesn't recognize workspace-inherited metadata
|
# clippy's build script check doesn't recognize workspace-inherited metadata
|
||||||
|
|
@ -167,23 +147,23 @@ unused_trait_names = "allow"
|
||||||
cargo_common_metadata = "allow"
|
cargo_common_metadata = "allow"
|
||||||
|
|
||||||
# In the honor of a recent Cloudflare regression
|
# In the honor of a recent Cloudflare regression
|
||||||
panic = "deny"
|
panic = "deny"
|
||||||
unwrap_used = "deny"
|
unwrap_used = "deny"
|
||||||
|
|
||||||
# Less dangerous, but we'd like to know
|
# Less dangerous, but we'd like to know
|
||||||
# Those must be opt-in, and are fine ONLY in tests and examples.
|
# Those must be opt-in, and are fine ONLY in tests and examples.
|
||||||
expect_used = "warn"
|
expect_used = "warn"
|
||||||
print_stderr = "warn"
|
print_stderr = "warn"
|
||||||
print_stdout = "warn"
|
print_stdout = "warn"
|
||||||
todo = "warn"
|
todo = "warn"
|
||||||
unimplemented = "warn"
|
unimplemented = "warn"
|
||||||
unreachable = "warn"
|
unreachable = "warn"
|
||||||
|
|
||||||
[profile.dev.package]
|
[profile.dev.package]
|
||||||
argon2 = { opt-level = 3 }
|
blake3 = { opt-level = 3 }
|
||||||
blake3 = { opt-level = 3 }
|
image = { opt-level = 3 }
|
||||||
image = { opt-level = 3 }
|
regex = { opt-level = 3 }
|
||||||
lofty = { opt-level = 3 }
|
argon2 = { opt-level = 3 }
|
||||||
lopdf = { opt-level = 3 }
|
|
||||||
matroska = { opt-level = 3 }
|
matroska = { opt-level = 3 }
|
||||||
regex = { opt-level = 3 }
|
lopdf = { opt-level = 3 }
|
||||||
|
lofty = { opt-level = 3 }
|
||||||
|
|
|
||||||
32
HACKING.md
Normal file
32
HACKING.md
Normal file
|
|
@ -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 <component>`. 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.
|
||||||
BIN
LICENSE
BIN
LICENSE
Binary file not shown.
|
|
@ -4,18 +4,6 @@ edition.workspace = true
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
license.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]
|
[dependencies]
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
|
@ -32,18 +20,13 @@ lopdf = { workspace = true }
|
||||||
epub = { workspace = true }
|
epub = { workspace = true }
|
||||||
matroska = { workspace = true }
|
matroska = { workspace = true }
|
||||||
gray_matter = { workspace = true }
|
gray_matter = { workspace = true }
|
||||||
rusqlite = { workspace = true, optional = true }
|
rusqlite = { workspace = true }
|
||||||
tokio-postgres = { workspace = true, optional = true }
|
tokio-postgres = { workspace = true }
|
||||||
deadpool-postgres = { workspace = true, optional = true }
|
deadpool-postgres = { workspace = true }
|
||||||
postgres-types = { workspace = true, optional = true }
|
postgres-types = { workspace = true }
|
||||||
postgres-native-tls = { workspace = true, optional = true }
|
postgres-native-tls = { workspace = true }
|
||||||
native-tls = { workspace = true, optional = true }
|
native-tls = { workspace = true }
|
||||||
pinakes-migrations = { workspace = true }
|
refinery = { workspace = true }
|
||||||
pinakes-types = { workspace = true }
|
|
||||||
pinakes-metadata = { workspace = true }
|
|
||||||
pinakes-plugin = { workspace = true }
|
|
||||||
pinakes-enrichment = { workspace = true }
|
|
||||||
pinakes-sync = { workspace = true }
|
|
||||||
walkdir = { workspace = true }
|
walkdir = { workspace = true }
|
||||||
notify = { workspace = true }
|
notify = { workspace = true }
|
||||||
winnow = { workspace = true }
|
winnow = { workspace = true }
|
||||||
|
|
@ -60,13 +43,18 @@ moka = { workspace = true }
|
||||||
urlencoding = { workspace = true }
|
urlencoding = { workspace = true }
|
||||||
image_hasher = { workspace = true }
|
image_hasher = { workspace = true }
|
||||||
rustc-hash = { workspace = true }
|
rustc-hash = { workspace = true }
|
||||||
pinakes-plugin-api = { workspace = true }
|
|
||||||
wasmtime = { workspace = true }
|
# Plugin system
|
||||||
ed25519-dalek = { workspace = true }
|
pinakes-plugin-api.workspace = true
|
||||||
|
wasmtime.workspace = true
|
||||||
|
ed25519-dalek.workspace = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
ffmpeg-tests = []
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
rand = { workspace = true }
|
rand = { workspace = true }
|
||||||
|
|
||||||
[lints]
|
|
||||||
workspace = true
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,10 +1,4 @@
|
||||||
use std::sync::LazyLock;
|
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use pinakes_types::{
|
|
||||||
error::{PinakesError, Result},
|
|
||||||
model::MediaItem,
|
|
||||||
};
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
|
|
@ -14,33 +8,10 @@ use super::{
|
||||||
googlebooks::GoogleBooksClient,
|
googlebooks::GoogleBooksClient,
|
||||||
openlibrary::OpenLibraryClient,
|
openlibrary::OpenLibraryClient,
|
||||||
};
|
};
|
||||||
|
use crate::{
|
||||||
// --- ISBN helper (duplicated from pinakes-core::books to avoid circular dep)
|
error::{PinakesError, Result},
|
||||||
// ---
|
model::MediaItem,
|
||||||
static ISBN_PATTERNS: LazyLock<Vec<regex::Regex>> = 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<String> {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Book enricher that tries `OpenLibrary` first, then falls back to Google
|
/// Book enricher that tries `OpenLibrary` first, then falls back to Google
|
||||||
/// Books
|
/// Books
|
||||||
|
|
@ -74,8 +45,8 @@ impl BookEnricher {
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(Some(ExternalMetadata {
|
Ok(Some(ExternalMetadata {
|
||||||
id: Uuid::now_v7(),
|
id: Uuid::new_v4(),
|
||||||
media_id: pinakes_types::model::MediaId(Uuid::nil()), /* Will be set by caller */
|
media_id: crate::model::MediaId(Uuid::nil()), // Will be set by caller
|
||||||
source: EnrichmentSourceType::OpenLibrary,
|
source: EnrichmentSourceType::OpenLibrary,
|
||||||
external_id: None,
|
external_id: None,
|
||||||
metadata_json,
|
metadata_json,
|
||||||
|
|
@ -104,8 +75,8 @@ impl BookEnricher {
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(Some(ExternalMetadata {
|
Ok(Some(ExternalMetadata {
|
||||||
id: Uuid::now_v7(),
|
id: Uuid::new_v4(),
|
||||||
media_id: pinakes_types::model::MediaId(Uuid::nil()), /* Will be set by caller */
|
media_id: crate::model::MediaId(Uuid::nil()), // Will be set by caller
|
||||||
source: EnrichmentSourceType::GoogleBooks,
|
source: EnrichmentSourceType::GoogleBooks,
|
||||||
external_id: Some(book.id.clone()),
|
external_id: Some(book.id.clone()),
|
||||||
metadata_json,
|
metadata_json,
|
||||||
|
|
@ -136,8 +107,8 @@ impl BookEnricher {
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
return Ok(Some(ExternalMetadata {
|
return Ok(Some(ExternalMetadata {
|
||||||
id: Uuid::now_v7(),
|
id: Uuid::new_v4(),
|
||||||
media_id: pinakes_types::model::MediaId(Uuid::nil()),
|
media_id: crate::model::MediaId(Uuid::nil()),
|
||||||
source: EnrichmentSourceType::OpenLibrary,
|
source: EnrichmentSourceType::OpenLibrary,
|
||||||
external_id: result.key.clone(),
|
external_id: result.key.clone(),
|
||||||
metadata_json,
|
metadata_json,
|
||||||
|
|
@ -155,8 +126,8 @@ impl BookEnricher {
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
return Ok(Some(ExternalMetadata {
|
return Ok(Some(ExternalMetadata {
|
||||||
id: Uuid::now_v7(),
|
id: Uuid::new_v4(),
|
||||||
media_id: pinakes_types::model::MediaId(Uuid::nil()),
|
media_id: crate::model::MediaId(Uuid::nil()),
|
||||||
source: EnrichmentSourceType::GoogleBooks,
|
source: EnrichmentSourceType::GoogleBooks,
|
||||||
external_id: Some(book.id.clone()),
|
external_id: Some(book.id.clone()),
|
||||||
metadata_json,
|
metadata_json,
|
||||||
|
|
@ -180,7 +151,7 @@ impl MetadataEnricher for BookEnricher {
|
||||||
// Try ISBN-based enrichment first by checking title/description for ISBN
|
// Try ISBN-based enrichment first by checking title/description for ISBN
|
||||||
// patterns
|
// patterns
|
||||||
if let Some(ref title) = item.title {
|
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? {
|
if let Some(mut metadata) = self.try_openlibrary(&isbn).await? {
|
||||||
metadata.media_id = item.id;
|
metadata.media_id = item.id;
|
||||||
return Ok(Some(metadata));
|
return Ok(Some(metadata));
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
use pinakes_types::error::{PinakesError, Result};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::{PinakesError, Result};
|
||||||
|
|
||||||
/// Google Books API client for book metadata enrichment
|
/// Google Books API client for book metadata enrichment
|
||||||
pub struct GoogleBooksClient {
|
pub struct GoogleBooksClient {
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
|
|
@ -3,13 +3,13 @@
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use pinakes_types::{
|
|
||||||
error::{PinakesError, Result},
|
|
||||||
model::MediaItem,
|
|
||||||
};
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::{EnrichmentSourceType, ExternalMetadata, MetadataEnricher};
|
use super::{EnrichmentSourceType, ExternalMetadata, MetadataEnricher};
|
||||||
|
use crate::{
|
||||||
|
error::{PinakesError, Result},
|
||||||
|
model::MediaItem,
|
||||||
|
};
|
||||||
|
|
||||||
pub struct LastFmEnricher {
|
pub struct LastFmEnricher {
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
|
|
@ -11,7 +11,7 @@ use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use pinakes_types::{
|
use crate::{
|
||||||
error::Result,
|
error::Result,
|
||||||
model::{MediaId, MediaItem},
|
model::{MediaId, MediaItem},
|
||||||
};
|
};
|
||||||
|
|
@ -3,13 +3,13 @@
|
||||||
use std::{fmt::Write as _, time::Duration};
|
use std::{fmt::Write as _, time::Duration};
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use pinakes_types::{
|
|
||||||
error::{PinakesError, Result},
|
|
||||||
model::MediaItem,
|
|
||||||
};
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::{EnrichmentSourceType, ExternalMetadata, MetadataEnricher};
|
use super::{EnrichmentSourceType, ExternalMetadata, MetadataEnricher};
|
||||||
|
use crate::{
|
||||||
|
error::{PinakesError, Result},
|
||||||
|
model::MediaItem,
|
||||||
|
};
|
||||||
|
|
||||||
pub struct MusicBrainzEnricher {
|
pub struct MusicBrainzEnricher {
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
use pinakes_types::error::{PinakesError, Result};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::{PinakesError, Result};
|
||||||
|
|
||||||
/// `OpenLibrary` API client for book metadata enrichment
|
/// `OpenLibrary` API client for book metadata enrichment
|
||||||
pub struct OpenLibraryClient {
|
pub struct OpenLibraryClient {
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
|
|
@ -3,13 +3,13 @@
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use pinakes_types::{
|
|
||||||
error::{PinakesError, Result},
|
|
||||||
model::MediaItem,
|
|
||||||
};
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::{EnrichmentSourceType, ExternalMetadata, MetadataEnricher};
|
use super::{EnrichmentSourceType, ExternalMetadata, MetadataEnricher};
|
||||||
|
use crate::{
|
||||||
|
error::{PinakesError, Result},
|
||||||
|
model::MediaItem,
|
||||||
|
};
|
||||||
|
|
||||||
pub struct TmdbEnricher {
|
pub struct TmdbEnricher {
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
|
|
@ -20,21 +20,21 @@ pub struct TmdbEnricher {
|
||||||
impl TmdbEnricher {
|
impl TmdbEnricher {
|
||||||
/// Create a new `TMDb` enricher.
|
/// Create a new `TMDb` enricher.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Panics
|
||||||
///
|
///
|
||||||
/// Returns an error if the HTTP client cannot be built (e.g. TLS
|
/// Panics if the HTTP client cannot be built (programming error in client
|
||||||
/// initialisation failure).
|
/// configuration).
|
||||||
pub fn new(api_key: String) -> Result<Self> {
|
#[must_use]
|
||||||
let client = reqwest::Client::builder()
|
pub fn new(api_key: String) -> Self {
|
||||||
.timeout(Duration::from_secs(10))
|
Self {
|
||||||
.connect_timeout(Duration::from_secs(5))
|
client: reqwest::Client::builder()
|
||||||
.build()
|
.timeout(Duration::from_secs(10))
|
||||||
.map_err(|e| PinakesError::External(e.to_string()))?;
|
.connect_timeout(Duration::from_secs(5))
|
||||||
Ok(Self {
|
.build()
|
||||||
client,
|
.expect("failed to build HTTP client with configured timeouts"),
|
||||||
api_key,
|
api_key,
|
||||||
base_url: "https://api.themoviedb.org/3".to_string(),
|
base_url: "https://api.themoviedb.org/3".to_string(),
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1,9 +1,146 @@
|
||||||
//! Error types for pinakes-core.
|
use std::path::PathBuf;
|
||||||
//!
|
|
||||||
//! Re-exports from [`pinakes_types::error`] for use within this crate.
|
|
||||||
pub use pinakes_types::error::{PinakesError, Result};
|
|
||||||
|
|
||||||
/// 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<rusqlite::Error> for PinakesError {
|
||||||
|
fn from(e: rusqlite::Error) -> Self {
|
||||||
|
Self::Database(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<tokio_postgres::Error> for PinakesError {
|
||||||
|
fn from(e: tokio_postgres::Error) -> Self {
|
||||||
|
Self::Database(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<serde_json::Error> 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))?;`
|
/// Usage: `stmt.execute(params).map_err(db_ctx("insert_media", media_id))?;`
|
||||||
pub fn db_ctx<E: std::fmt::Display>(
|
pub fn db_ctx<E: std::fmt::Display>(
|
||||||
|
|
@ -13,3 +150,5 @@ pub fn db_ctx<E: std::fmt::Display>(
|
||||||
let context = format!("{operation} [{entity}]");
|
let context = format!("{operation} [{entity}]");
|
||||||
move |e| PinakesError::Database(format!("{context}: {e}"))
|
move |e| PinakesError::Database(format!("{context}: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, PinakesError>;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,10 @@
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
use crate::model::{MediaId, MediaItem};
|
use crate::{
|
||||||
|
error::Result,
|
||||||
|
model::{MediaId, MediaItem},
|
||||||
|
};
|
||||||
|
|
||||||
/// Configuration for event detection
|
/// Configuration for event detection
|
||||||
#[derive(Debug, Clone)]
|
#[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
|
/// Detect photo events from a list of media items
|
||||||
#[must_use]
|
|
||||||
pub fn detect_events(
|
pub fn detect_events(
|
||||||
mut items: Vec<MediaItem>,
|
mut items: Vec<MediaItem>,
|
||||||
config: &EventDetectionConfig,
|
config: &EventDetectionConfig,
|
||||||
) -> Vec<DetectedEvent> {
|
) -> Result<Vec<DetectedEvent>> {
|
||||||
// Filter to only photos with date_taken
|
// Filter to only photos with date_taken
|
||||||
items.retain(|item| item.date_taken.is_some());
|
items.retain(|item| item.date_taken.is_some());
|
||||||
|
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
return Vec::new();
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by date_taken (None < Some, but all are Some after retain)
|
// Sort by date_taken (None < Some, but all are Some after retain)
|
||||||
|
|
@ -82,7 +84,7 @@ pub fn detect_events(
|
||||||
|
|
||||||
let mut events: Vec<DetectedEvent> = Vec::new();
|
let mut events: Vec<DetectedEvent> = Vec::new();
|
||||||
let Some(first_date) = items[0].date_taken else {
|
let Some(first_date) = items[0].date_taken else {
|
||||||
return Vec::new();
|
return Ok(Vec::new());
|
||||||
};
|
};
|
||||||
let mut current_event_items: Vec<MediaId> = vec![items[0].id];
|
let mut current_event_items: Vec<MediaId> = vec![items[0].id];
|
||||||
let mut current_start_time = first_date;
|
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)
|
/// Detect photo bursts (rapid sequences of photos)
|
||||||
/// Returns groups of media IDs that are likely burst sequences
|
/// Returns groups of media IDs that are likely burst sequences
|
||||||
#[must_use]
|
|
||||||
pub fn detect_bursts(
|
pub fn detect_bursts(
|
||||||
mut items: Vec<MediaItem>,
|
mut items: Vec<MediaItem>,
|
||||||
max_gap_secs: i64,
|
max_gap_secs: i64,
|
||||||
min_burst_size: usize,
|
min_burst_size: usize,
|
||||||
) -> Vec<Vec<MediaId>> {
|
) -> Result<Vec<Vec<MediaId>>> {
|
||||||
// Filter to only photos with date_taken
|
// Filter to only photos with date_taken
|
||||||
items.retain(|item| item.date_taken.is_some());
|
items.retain(|item| item.date_taken.is_some());
|
||||||
|
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
return Vec::new();
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by date_taken (None < Some, but all are Some after retain)
|
// 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<MediaId>> = Vec::new();
|
let mut bursts: Vec<Vec<MediaId>> = Vec::new();
|
||||||
let Some(first_date) = items[0].date_taken else {
|
let Some(first_date) = items[0].date_taken else {
|
||||||
return Vec::new();
|
return Ok(Vec::new());
|
||||||
};
|
};
|
||||||
let mut current_burst: Vec<MediaId> = vec![items[0].id];
|
let mut current_burst: Vec<MediaId> = vec![items[0].id];
|
||||||
let mut last_time = first_date;
|
let mut last_time = first_date;
|
||||||
|
|
@ -220,5 +221,5 @@ pub fn detect_bursts(
|
||||||
bursts.push(current_burst);
|
bursts.push(current_burst);
|
||||||
}
|
}
|
||||||
|
|
||||||
bursts
|
Ok(bursts)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ use crate::{
|
||||||
hash::compute_file_hash,
|
hash::compute_file_hash,
|
||||||
links,
|
links,
|
||||||
media_type::{BuiltinMediaType, MediaType},
|
media_type::{BuiltinMediaType, MediaType},
|
||||||
|
metadata,
|
||||||
model::{
|
model::{
|
||||||
AuditAction,
|
AuditAction,
|
||||||
CustomField,
|
CustomField,
|
||||||
|
|
@ -182,7 +183,7 @@ pub async fn import_file_with_options(
|
||||||
let path_clone = path.clone();
|
let path_clone = path.clone();
|
||||||
let media_type_clone = media_type.clone();
|
let media_type_clone = media_type.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
pinakes_metadata::extract_metadata(&path_clone, &media_type_clone)
|
metadata::extract_metadata(&path_clone, &media_type_clone)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| PinakesError::MetadataExtraction(e.to_string()))??
|
.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()
|
let perceptual_hash = if options.photo_config.generate_perceptual_hash()
|
||||||
&& media_type.category() == crate::media_type::MediaCategory::Image
|
&& media_type.category() == crate::media_type::MediaCategory::Image
|
||||||
{
|
{
|
||||||
pinakes_metadata::image::generate_perceptual_hash(&path)
|
crate::metadata::image::generate_perceptual_hash(&path)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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.
|
// Prune old terminal jobs to prevent unbounded memory growth.
|
||||||
// Keep at most 500 completed/failed/cancelled entries, removing the
|
// Keep at most 500 completed/failed/cancelled entries, removing the
|
||||||
// oldest.
|
// oldest.
|
||||||
const MAX_TERMINAL_JOBS: usize = 500;
|
const MAX_TERMINAL_JOBS: usize = 500;
|
||||||
let mut map = self.jobs.write().await;
|
|
||||||
map.insert(id, job);
|
|
||||||
let mut terminal: Vec<(Uuid, chrono::DateTime<Utc>)> = map
|
let mut terminal: Vec<(Uuid, chrono::DateTime<Utc>)> = map
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, j)| {
|
.filter(|(_, j)| {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ pub mod books;
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod collections;
|
pub mod collections;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod enrichment;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod export;
|
pub mod export;
|
||||||
|
|
@ -13,7 +14,9 @@ pub mod integrity;
|
||||||
pub mod jobs;
|
pub mod jobs;
|
||||||
pub mod links;
|
pub mod links;
|
||||||
pub mod managed_storage;
|
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 opener;
|
||||||
pub mod path_validation;
|
pub mod path_validation;
|
||||||
pub mod playlists;
|
pub mod playlists;
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,13 @@ use lofty::{
|
||||||
file::{AudioFile, TaggedFileExt},
|
file::{AudioFile, TaggedFileExt},
|
||||||
tag::Accessor,
|
tag::Accessor,
|
||||||
};
|
};
|
||||||
use pinakes_types::{
|
|
||||||
|
use super::{ExtractedMetadata, MetadataExtractor};
|
||||||
|
use crate::{
|
||||||
error::{PinakesError, Result},
|
error::{PinakesError, Result},
|
||||||
media_type::{BuiltinMediaType, MediaType},
|
media_type::{BuiltinMediaType, MediaType},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{ExtractedMetadata, MetadataExtractor};
|
|
||||||
|
|
||||||
pub struct AudioExtractor;
|
pub struct AudioExtractor;
|
||||||
|
|
||||||
impl MetadataExtractor for AudioExtractor {
|
impl MetadataExtractor for AudioExtractor {
|
||||||
|
|
@ -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},
|
error::{PinakesError, Result},
|
||||||
media_type::{BuiltinMediaType, MediaType},
|
media_type::{BuiltinMediaType, MediaType},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{ExtractedMetadata, MetadataExtractor};
|
|
||||||
|
|
||||||
// --- ISBN helpers (duplicated from pinakes-core::books to avoid circular dep)
|
|
||||||
// ---
|
|
||||||
|
|
||||||
static ISBN_PATTERNS: LazyLock<Vec<regex::Regex>> = 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<String> {
|
|
||||||
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<String, ()> {
|
|
||||||
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<String, ()> {
|
|
||||||
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<u32> {
|
|
||||||
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;
|
pub struct DocumentExtractor;
|
||||||
|
|
||||||
impl MetadataExtractor for DocumentExtractor {
|
impl MetadataExtractor for DocumentExtractor {
|
||||||
|
|
@ -119,7 +32,7 @@ fn extract_pdf(path: &Path) -> Result<ExtractedMetadata> {
|
||||||
.map_err(|e| PinakesError::MetadataExtraction(format!("PDF load: {e}")))?;
|
.map_err(|e| PinakesError::MetadataExtraction(format!("PDF load: {e}")))?;
|
||||||
|
|
||||||
let mut meta = ExtractedMetadata::default();
|
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
|
// Find the Info dictionary via the trailer
|
||||||
if let Ok(info_ref) = doc.trailer.get(b"Info") {
|
if let Ok(info_ref) = doc.trailer.get(b"Info") {
|
||||||
|
|
@ -146,7 +59,7 @@ fn extract_pdf(path: &Path) -> Result<ExtractedMetadata> {
|
||||||
.filter(|name| !name.is_empty())
|
.filter(|name| !name.is_empty())
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(pos, name)| {
|
.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.position = i32::try_from(pos).unwrap_or(i32::MAX);
|
||||||
author
|
author
|
||||||
})
|
})
|
||||||
|
|
@ -194,8 +107,8 @@ fn extract_pdf(path: &Path) -> Result<ExtractedMetadata> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract ISBN from the text
|
// Extract ISBN from the text
|
||||||
if let Some(isbn) = extract_isbn_from_text(&extracted_text)
|
if let Some(isbn) = crate::books::extract_isbn_from_text(&extracted_text)
|
||||||
&& let Ok(normalized) = normalize_isbn(&isbn)
|
&& let Ok(normalized) = crate::books::normalize_isbn(&isbn)
|
||||||
{
|
{
|
||||||
book_meta.isbn13 = Some(normalized);
|
book_meta.isbn13 = Some(normalized);
|
||||||
book_meta.isbn = Some(isbn);
|
book_meta.isbn = Some(isbn);
|
||||||
|
|
@ -232,7 +145,7 @@ fn extract_epub(path: &Path) -> Result<ExtractedMetadata> {
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut book_meta = pinakes_types::model::BookMetadata::default();
|
let mut book_meta = crate::model::BookMetadata::default();
|
||||||
|
|
||||||
// Extract basic metadata
|
// Extract basic metadata
|
||||||
if let Some(lang) = doc.mdata("language") {
|
if let Some(lang) = doc.mdata("language") {
|
||||||
|
|
@ -257,8 +170,7 @@ fn extract_epub(path: &Path) -> Result<ExtractedMetadata> {
|
||||||
let mut position = 0;
|
let mut position = 0;
|
||||||
for item in &doc.metadata {
|
for item in &doc.metadata {
|
||||||
if item.property == "creator" || item.property == "dc:creator" {
|
if item.property == "creator" || item.property == "dc:creator" {
|
||||||
let mut author =
|
let mut author = crate::model::AuthorInfo::new(item.value.clone());
|
||||||
pinakes_types::model::AuthorInfo::new(item.value.clone());
|
|
||||||
author.position = position;
|
author.position = position;
|
||||||
position += 1;
|
position += 1;
|
||||||
|
|
||||||
|
|
@ -309,7 +221,7 @@ fn extract_epub(path: &Path) -> Result<ExtractedMetadata> {
|
||||||
|
|
||||||
// Try to normalize ISBN
|
// Try to normalize ISBN
|
||||||
if (id_type == "isbn" || id_type == "isbn13")
|
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.isbn13 = Some(normalized.clone());
|
||||||
book_meta.isbn = Some(item.value.clone());
|
book_meta.isbn = Some(item.value.clone());
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use pinakes_types::{
|
use super::{ExtractedMetadata, MetadataExtractor};
|
||||||
|
use crate::{
|
||||||
error::Result,
|
error::Result,
|
||||||
media_type::{BuiltinMediaType, MediaType},
|
media_type::{BuiltinMediaType, MediaType},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{ExtractedMetadata, MetadataExtractor};
|
|
||||||
|
|
||||||
pub struct ImageExtractor;
|
pub struct ImageExtractor;
|
||||||
|
|
||||||
impl MetadataExtractor for ImageExtractor {
|
impl MetadataExtractor for ImageExtractor {
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use pinakes_types::{
|
use super::{ExtractedMetadata, MetadataExtractor};
|
||||||
|
use crate::{
|
||||||
error::Result,
|
error::Result,
|
||||||
media_type::{BuiltinMediaType, MediaType},
|
media_type::{BuiltinMediaType, MediaType},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{ExtractedMetadata, MetadataExtractor};
|
|
||||||
|
|
||||||
pub struct MarkdownExtractor;
|
pub struct MarkdownExtractor;
|
||||||
|
|
||||||
impl MetadataExtractor for MarkdownExtractor {
|
impl MetadataExtractor for MarkdownExtractor {
|
||||||
|
|
@ -8,7 +8,7 @@ use std::path::Path;
|
||||||
|
|
||||||
use rustc_hash::FxHashMap;
|
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)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct ExtractedMetadata {
|
pub struct ExtractedMetadata {
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use pinakes_types::{
|
use super::{ExtractedMetadata, MetadataExtractor};
|
||||||
|
use crate::{
|
||||||
error::{PinakesError, Result},
|
error::{PinakesError, Result},
|
||||||
media_type::{BuiltinMediaType, MediaType},
|
media_type::{BuiltinMediaType, MediaType},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{ExtractedMetadata, MetadataExtractor};
|
|
||||||
|
|
||||||
pub struct VideoExtractor;
|
pub struct VideoExtractor;
|
||||||
|
|
||||||
impl MetadataExtractor for VideoExtractor {
|
impl MetadataExtractor for VideoExtractor {
|
||||||
|
|
@ -7,35 +7,6 @@ use uuid::Uuid;
|
||||||
|
|
||||||
use crate::media_type::MediaType;
|
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<Uuid> for UserId {
|
|
||||||
fn from(id: Uuid) -> Self {
|
|
||||||
Self(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unique identifier for a media item.
|
/// Unique identifier for a media item.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
pub struct MediaId(pub Uuid);
|
pub struct MediaId(pub Uuid);
|
||||||
|
|
@ -215,7 +186,7 @@ pub enum CustomFieldType {
|
||||||
|
|
||||||
impl CustomFieldType {
|
impl CustomFieldType {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn as_str(self) -> &'static str {
|
pub const fn as_str(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Text => "text",
|
Self::Text => "text",
|
||||||
Self::Number => "number",
|
Self::Number => "number",
|
||||||
|
|
@ -262,7 +233,7 @@ pub enum CollectionKind {
|
||||||
|
|
||||||
impl CollectionKind {
|
impl CollectionKind {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn as_str(self) -> &'static str {
|
pub const fn as_str(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Manual => "manual",
|
Self::Manual => "manual",
|
||||||
Self::Virtual => "virtual",
|
Self::Virtual => "virtual",
|
||||||
|
|
@ -3,11 +3,6 @@ use std::{path::Path, process::Command};
|
||||||
use crate::error::{PinakesError, Result};
|
use crate::error::{PinakesError, Result};
|
||||||
|
|
||||||
pub trait Opener: Send + Sync {
|
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<()>;
|
fn open(&self, path: &Path) -> Result<()>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,11 @@ impl PluginLoader {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Discover all plugins in configured directories
|
/// Discover all plugins in configured directories
|
||||||
pub fn discover_plugins(&self) -> Vec<PluginManifest> {
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if a plugin directory cannot be searched.
|
||||||
|
pub fn discover_plugins(&self) -> Result<Vec<PluginManifest>> {
|
||||||
let mut manifests = Vec::new();
|
let mut manifests = Vec::new();
|
||||||
|
|
||||||
for dir in &self.plugin_dirs {
|
for dir in &self.plugin_dirs {
|
||||||
|
|
@ -37,7 +41,7 @@ impl PluginLoader {
|
||||||
manifests.extend(found);
|
manifests.extend(found);
|
||||||
}
|
}
|
||||||
|
|
||||||
manifests
|
Ok(manifests)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Discover plugins in a specific directory
|
/// 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`,
|
/// 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.
|
/// 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
|
// Check that the path exists
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Err(anyhow!("Plugin path does not exist: {}", path.display()));
|
return Err(anyhow!("Plugin path does not exist: {}", path.display()));
|
||||||
|
|
@ -335,7 +339,7 @@ mod tests {
|
||||||
let temp_dir = TempDir::new().unwrap();
|
let temp_dir = TempDir::new().unwrap();
|
||||||
let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]);
|
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);
|
assert_eq!(manifests.len(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -363,7 +367,7 @@ wasm = "plugin.wasm"
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]);
|
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.len(), 1);
|
||||||
assert_eq!(manifests[0].plugin.name, "test-plugin");
|
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();
|
std::fs::write(plugin_dir.join("plugin.toml"), manifest_content).unwrap();
|
||||||
|
|
||||||
|
let loader = PluginLoader::new(vec![]);
|
||||||
|
|
||||||
// Should fail without WASM file
|
// 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)
|
// Create valid WASM file (magic number only)
|
||||||
std::fs::write(plugin_dir.join("plugin.wasm"), b"\0asm\x01\x00\x00\x00")
|
std::fs::write(plugin_dir.join("plugin.wasm"), b"\0asm\x01\x00\x00\x00")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Should succeed now
|
// Should succeed now
|
||||||
assert!(PluginLoader::validate_plugin_package(&plugin_dir).is_ok());
|
assert!(loader.validate_plugin_package(&plugin_dir).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -420,6 +426,7 @@ wasm = "plugin.wasm"
|
||||||
// Create invalid WASM file
|
// Create invalid WASM file
|
||||||
std::fs::write(plugin_dir.join("plugin.wasm"), b"not wasm").unwrap();
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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 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 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<RwLock<PluginRegistry>>,
|
||||||
|
|
||||||
|
/// WASM runtime for executing plugins
|
||||||
|
runtime: Arc<WasmRuntime>,
|
||||||
|
|
||||||
|
/// 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<PathBuf>,
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<crate::config::PluginsConfig> 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<Self> {
|
||||||
|
// 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<Vec<String>> {
|
||||||
|
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<pinakes_plugin_api::PluginManifest> {
|
||||||
|
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<usize>> = 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<usize> = 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<String> {
|
||||||
|
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<ed25519_dalek::VerifyingKey> = 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<String> {
|
||||||
|
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<String> = {
|
||||||
|
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<PluginMetadata> {
|
||||||
|
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<PluginMetadata> {
|
||||||
|
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<String>, 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<String>)> {
|
||||||
|
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<String, String> {
|
||||||
|
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<String>,
|
||||||
|
) -> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,17 @@ use std::{
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
use pinakes_metadata::ExtractedMetadata;
|
use rustc_hash::FxHashMap;
|
||||||
use pinakes_plugin::{
|
use tokio::sync::RwLock;
|
||||||
CapabilityEnforcer,
|
use tracing::{debug, info, warn};
|
||||||
PluginManager,
|
|
||||||
rpc::{
|
use super::PluginManager;
|
||||||
|
use crate::{
|
||||||
|
config::PluginTimeoutConfig,
|
||||||
|
media_type::MediaType,
|
||||||
|
metadata::ExtractedMetadata,
|
||||||
|
model::MediaId,
|
||||||
|
plugin::rpc::{
|
||||||
CanHandleRequest,
|
CanHandleRequest,
|
||||||
CanHandleResponse,
|
CanHandleResponse,
|
||||||
ExtractMetadataRequest,
|
ExtractMetadataRequest,
|
||||||
|
|
@ -40,12 +46,6 @@ use pinakes_plugin::{
|
||||||
SearchResultItem,
|
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.
|
/// Built-in handlers run at this implicit priority.
|
||||||
const BUILTIN_PRIORITY: u16 = 100;
|
const BUILTIN_PRIORITY: u16 = 100;
|
||||||
|
|
@ -132,7 +132,7 @@ impl PluginPipeline {
|
||||||
pub async fn discover_capabilities(&self) -> crate::error::Result<()> {
|
pub async fn discover_capabilities(&self) -> crate::error::Result<()> {
|
||||||
info!("discovering plugin capabilities");
|
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();
|
let mut caps = CachedCapabilities::new();
|
||||||
|
|
||||||
// Discover metadata extractors
|
// Discover metadata extractors
|
||||||
|
|
@ -323,7 +323,7 @@ impl PluginPipeline {
|
||||||
/// Iterates `MediaTypeProvider` plugins in priority order, falling back to
|
/// Iterates `MediaTypeProvider` plugins in priority order, falling back to
|
||||||
/// the built-in resolver at implicit priority 100.
|
/// the built-in resolver at implicit priority 100.
|
||||||
pub async fn resolve_media_type(&self, path: &Path) -> Option<MediaType> {
|
pub async fn resolve_media_type(&self, path: &Path) -> Option<MediaType> {
|
||||||
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 plugins = self.manager.get_enabled_by_kind_sorted("media_type").await;
|
||||||
|
|
||||||
let mut builtin_ran = false;
|
let mut builtin_ran = false;
|
||||||
|
|
@ -342,7 +342,11 @@ impl PluginPipeline {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the call is allowed for this plugin kind
|
// 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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -438,7 +442,7 @@ impl PluginPipeline {
|
||||||
path: &Path,
|
path: &Path,
|
||||||
media_type: &MediaType,
|
media_type: &MediaType,
|
||||||
) -> crate::error::Result<ExtractedMetadata> {
|
) -> crate::error::Result<ExtractedMetadata> {
|
||||||
let timeout = Duration::from_secs(self.timeouts.processing);
|
let timeout = Duration::from_secs(self.timeouts.processing_secs);
|
||||||
let plugins = self
|
let plugins = self
|
||||||
.manager
|
.manager
|
||||||
.get_enabled_by_kind_sorted("metadata_extractor")
|
.get_enabled_by_kind_sorted("metadata_extractor")
|
||||||
|
|
@ -472,7 +476,10 @@ impl PluginPipeline {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !CapabilityEnforcer::validate_function_call(kinds, "extract_metadata")
|
if !self
|
||||||
|
.manager
|
||||||
|
.enforcer()
|
||||||
|
.validate_function_call(kinds, "extract_metadata")
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -522,7 +529,7 @@ impl PluginPipeline {
|
||||||
let path = path.to_path_buf();
|
let path = path.to_path_buf();
|
||||||
let mt = media_type.clone();
|
let mt = media_type.clone();
|
||||||
let builtin = tokio::task::spawn_blocking(move || {
|
let builtin = tokio::task::spawn_blocking(move || {
|
||||||
pinakes_metadata::extract_metadata(&path, &mt)
|
crate::metadata::extract_metadata(&path, &mt)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
|
|
@ -552,7 +559,7 @@ impl PluginPipeline {
|
||||||
media_type: &MediaType,
|
media_type: &MediaType,
|
||||||
thumb_dir: &Path,
|
thumb_dir: &Path,
|
||||||
) -> crate::error::Result<Option<PathBuf>> {
|
) -> crate::error::Result<Option<PathBuf>> {
|
||||||
let timeout = Duration::from_secs(self.timeouts.processing);
|
let timeout = Duration::from_secs(self.timeouts.processing_secs);
|
||||||
let plugins = self
|
let plugins = self
|
||||||
.manager
|
.manager
|
||||||
.get_enabled_by_kind_sorted("thumbnail_generator")
|
.get_enabled_by_kind_sorted("thumbnail_generator")
|
||||||
|
|
@ -590,10 +597,11 @@ impl PluginPipeline {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !CapabilityEnforcer::validate_function_call(
|
if !self
|
||||||
kinds,
|
.manager
|
||||||
"generate_thumbnail",
|
.enforcer()
|
||||||
) {
|
.validate_function_call(kinds, "generate_thumbnail")
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -682,11 +690,11 @@ impl PluginPipeline {
|
||||||
|
|
||||||
/// Internal dispatcher for events.
|
/// Internal dispatcher for events.
|
||||||
async fn dispatch_event(
|
async fn dispatch_event(
|
||||||
self: &Arc<Self>,
|
&self,
|
||||||
event_type: &str,
|
event_type: &str,
|
||||||
payload: &serde_json::Value,
|
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
|
// Collect plugin IDs interested in this event
|
||||||
let interested_ids: Vec<String> = {
|
let interested_ids: Vec<String> = {
|
||||||
|
|
@ -718,7 +726,11 @@ impl PluginPipeline {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !CapabilityEnforcer::validate_function_call(kinds, "handle_event") {
|
if !self
|
||||||
|
.manager
|
||||||
|
.enforcer()
|
||||||
|
.validate_function_call(kinds, "handle_event")
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -727,35 +739,23 @@ impl PluginPipeline {
|
||||||
payload: payload.clone(),
|
payload: payload.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Event handlers return nothing meaningful; we just care about
|
||||||
|
// success/failure.
|
||||||
match wasm
|
match wasm
|
||||||
.call_function_json_with_events::<HandleEventRequest, serde_json::Value>(
|
.call_function_json::<HandleEventRequest, serde_json::Value>(
|
||||||
"handle_event",
|
"handle_event",
|
||||||
&req,
|
&req,
|
||||||
timeout,
|
timeout,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok((_resp, emitted_events)) => {
|
Ok(_) => {
|
||||||
self.record_success(id).await;
|
self.record_success(id).await;
|
||||||
debug!(
|
debug!(
|
||||||
plugin_id = %id,
|
plugin_id = %id,
|
||||||
event_type = event_type,
|
event_type = event_type,
|
||||||
"event handled"
|
"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::<serde_json::Value>(&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) => {
|
Err(e) => {
|
||||||
warn!(
|
warn!(
|
||||||
|
|
@ -778,7 +778,7 @@ impl PluginPipeline {
|
||||||
limit: usize,
|
limit: usize,
|
||||||
offset: usize,
|
offset: usize,
|
||||||
) -> Vec<SearchResultItem> {
|
) -> Vec<SearchResultItem> {
|
||||||
let timeout = Duration::from_secs(self.timeouts.processing);
|
let timeout = Duration::from_secs(self.timeouts.processing_secs);
|
||||||
let plugins = self
|
let plugins = self
|
||||||
.manager
|
.manager
|
||||||
.get_enabled_by_kind_sorted("search_backend")
|
.get_enabled_by_kind_sorted("search_backend")
|
||||||
|
|
@ -790,7 +790,11 @@ impl PluginPipeline {
|
||||||
if !self.is_healthy(id).await {
|
if !self.is_healthy(id).await {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if !CapabilityEnforcer::validate_function_call(kinds, "search") {
|
if !self
|
||||||
|
.manager
|
||||||
|
.enforcer()
|
||||||
|
.validate_function_call(kinds, "search")
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -847,7 +851,7 @@ impl PluginPipeline {
|
||||||
|
|
||||||
/// Index a media item in all search backend plugins (fan-out).
|
/// Index a media item in all search backend plugins (fan-out).
|
||||||
pub async fn index_item(&self, req: &IndexItemRequest) {
|
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
|
let plugins = self
|
||||||
.manager
|
.manager
|
||||||
.get_enabled_by_kind_sorted("search_backend")
|
.get_enabled_by_kind_sorted("search_backend")
|
||||||
|
|
@ -857,7 +861,11 @@ impl PluginPipeline {
|
||||||
if !self.is_healthy(id).await {
|
if !self.is_healthy(id).await {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if !CapabilityEnforcer::validate_function_call(kinds, "index_item") {
|
if !self
|
||||||
|
.manager
|
||||||
|
.enforcer()
|
||||||
|
.validate_function_call(kinds, "index_item")
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -886,7 +894,7 @@ impl PluginPipeline {
|
||||||
|
|
||||||
/// Remove a media item from all search backend plugins (fan-out).
|
/// Remove a media item from all search backend plugins (fan-out).
|
||||||
pub async fn remove_item(&self, media_id: &str) {
|
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
|
let plugins = self
|
||||||
.manager
|
.manager
|
||||||
.get_enabled_by_kind_sorted("search_backend")
|
.get_enabled_by_kind_sorted("search_backend")
|
||||||
|
|
@ -900,7 +908,11 @@ impl PluginPipeline {
|
||||||
if !self.is_healthy(id).await {
|
if !self.is_healthy(id).await {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if !CapabilityEnforcer::validate_function_call(kinds, "remove_item") {
|
if !self
|
||||||
|
.manager
|
||||||
|
.enforcer()
|
||||||
|
.validate_function_call(kinds, "remove_item")
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -940,7 +952,7 @@ impl PluginPipeline {
|
||||||
|
|
||||||
/// Load a specific theme by ID from the provider that registered it.
|
/// Load a specific theme by ID from the provider that registered it.
|
||||||
pub async fn load_theme(&self, theme_id: &str) -> Option<LoadThemeResponse> {
|
pub async fn load_theme(&self, theme_id: &str) -> Option<LoadThemeResponse> {
|
||||||
let timeout = Duration::from_secs(self.timeouts.processing);
|
let timeout = Duration::from_secs(self.timeouts.processing_secs);
|
||||||
|
|
||||||
// Find which plugin owns this theme
|
// Find which plugin owns this theme
|
||||||
let owner_id = {
|
let owner_id = {
|
||||||
|
|
@ -967,7 +979,11 @@ impl PluginPipeline {
|
||||||
let plugin = plugins.iter().find(|(id, ..)| id == &owner_id)?;
|
let plugin = plugins.iter().find(|(id, ..)| id == &owner_id)?;
|
||||||
let (id, _priority, kinds, wasm) = plugin;
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1146,10 +1162,10 @@ fn merge_extracted(base: &mut ExtractedMetadata, source: ExtractedMetadata) {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use pinakes_plugin::{PluginManager, PluginManagerConfig};
|
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::plugin::{PluginManager, PluginManagerConfig};
|
||||||
|
|
||||||
/// Create a `PluginPipeline` backed by an empty `PluginManager`.
|
/// Create a `PluginPipeline` backed by an empty `PluginManager`.
|
||||||
fn create_test_pipeline() -> (TempDir, Arc<PluginPipeline>) {
|
fn create_test_pipeline() -> (TempDir, Arc<PluginPipeline>) {
|
||||||
|
|
|
||||||
|
|
@ -86,23 +86,20 @@ impl WasmPlugin {
|
||||||
&self.context
|
&self.context
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execute a plugin function, returning both the result bytes and any
|
/// Execute a plugin function
|
||||||
/// events the plugin queued via `host_emit_event`.
|
|
||||||
///
|
///
|
||||||
/// Creates a fresh store and instance per invocation with host functions
|
/// Creates a fresh store and instance per invocation with host functions
|
||||||
/// linked, calls the requested exported function, drains both the exchange
|
/// linked, calls the requested exported function, and returns the result.
|
||||||
/// buffer and the pending events list before the store is dropped, and
|
|
||||||
/// returns both.
|
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Returns an error if the function cannot be found, instantiation fails,
|
/// Returns an error if the function cannot be found, instantiation fails,
|
||||||
/// or the function call returns an error.
|
/// or the function call returns an error.
|
||||||
pub async fn call_function_with_events(
|
pub async fn call_function(
|
||||||
&self,
|
&self,
|
||||||
function_name: &str,
|
function_name: &str,
|
||||||
params: &[u8],
|
params: &[u8],
|
||||||
) -> Result<(Vec<u8>, Vec<(String, String)>)> {
|
) -> Result<Vec<u8>> {
|
||||||
let engine = self.module.engine();
|
let engine = self.module.engine();
|
||||||
|
|
||||||
// Build memory limiter from capabilities
|
// Build memory limiter from capabilities
|
||||||
|
|
@ -208,38 +205,18 @@ impl WasmPlugin {
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drain both buffers before the store is dropped.
|
// Prefer data written into the exchange buffer by host functions
|
||||||
let pending_events = std::mem::take(&mut store.data_mut().pending_events);
|
|
||||||
let exchange = std::mem::take(&mut store.data_mut().exchange_buffer);
|
let exchange = std::mem::take(&mut store.data_mut().exchange_buffer);
|
||||||
|
if !exchange.is_empty() {
|
||||||
|
return Ok(exchange);
|
||||||
|
}
|
||||||
|
|
||||||
let result = if !exchange.is_empty() {
|
// Fall back to serialising the WASM return value
|
||||||
exchange
|
if let Some(Val::I32(ret)) = results.first() {
|
||||||
} else if let Some(Val::I32(ret)) = results.first() {
|
Ok(ret.to_le_bytes().to_vec())
|
||||||
ret.to_le_bytes().to_vec()
|
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Ok(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<Vec<u8>> {
|
|
||||||
let (data, _events) = self
|
|
||||||
.call_function_with_events(function_name, params)
|
|
||||||
.await?;
|
|
||||||
Ok(data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Call a plugin function with JSON request/response serialization.
|
/// 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<Req, Resp>(
|
|
||||||
&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)]
|
#[cfg(test)]
|
||||||
|
|
@ -561,7 +493,9 @@ impl HostFunctions {
|
||||||
if let Some(ref allowed) =
|
if let Some(ref allowed) =
|
||||||
caller.data().context.capabilities.network.allowed_domains
|
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");
|
tracing::warn!(url = %url_str, "plugin provided invalid URL");
|
||||||
return -1;
|
return -1;
|
||||||
};
|
};
|
||||||
|
|
@ -715,12 +649,15 @@ impl HostFunctions {
|
||||||
return -2;
|
return -2;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::env::var(&key_str).map_or(-1, |value| {
|
match std::env::var(&key_str) {
|
||||||
let bytes = value.into_bytes();
|
Ok(value) => {
|
||||||
let len = i32::try_from(bytes.len()).unwrap_or(i32::MAX);
|
let bytes = value.into_bytes();
|
||||||
caller.data_mut().exchange_buffer = bytes;
|
let len = i32::try_from(bytes.len()).unwrap_or(i32::MAX);
|
||||||
len
|
caller.data_mut().exchange_buffer = bytes;
|
||||||
})
|
len
|
||||||
|
},
|
||||||
|
Err(_) => -1,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
|
@ -242,6 +242,7 @@ impl CapabilityEnforcer {
|
||||||
/// bugs from calling wrong functions on plugins. Returns `true` if allowed.
|
/// bugs from calling wrong functions on plugins. Returns `true` if allowed.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn validate_function_call(
|
pub fn validate_function_call(
|
||||||
|
&self,
|
||||||
plugin_kinds: &[String],
|
plugin_kinds: &[String],
|
||||||
function_name: &str,
|
function_name: &str,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
|
|
@ -422,91 +423,51 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_function_call_lifecycle_always_allowed() {
|
fn test_validate_function_call_lifecycle_always_allowed() {
|
||||||
|
let enforcer = CapabilityEnforcer::new();
|
||||||
let kinds = vec!["metadata_extractor".to_string()];
|
let kinds = vec!["metadata_extractor".to_string()];
|
||||||
assert!(CapabilityEnforcer::validate_function_call(
|
assert!(enforcer.validate_function_call(&kinds, "initialize"));
|
||||||
&kinds,
|
assert!(enforcer.validate_function_call(&kinds, "shutdown"));
|
||||||
"initialize"
|
assert!(enforcer.validate_function_call(&kinds, "health_check"));
|
||||||
));
|
|
||||||
assert!(CapabilityEnforcer::validate_function_call(
|
|
||||||
&kinds, "shutdown"
|
|
||||||
));
|
|
||||||
assert!(CapabilityEnforcer::validate_function_call(
|
|
||||||
&kinds,
|
|
||||||
"health_check"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_function_call_metadata_extractor() {
|
fn test_validate_function_call_metadata_extractor() {
|
||||||
|
let enforcer = CapabilityEnforcer::new();
|
||||||
let kinds = vec!["metadata_extractor".to_string()];
|
let kinds = vec!["metadata_extractor".to_string()];
|
||||||
assert!(CapabilityEnforcer::validate_function_call(
|
assert!(enforcer.validate_function_call(&kinds, "extract_metadata"));
|
||||||
&kinds,
|
assert!(enforcer.validate_function_call(&kinds, "supported_types"));
|
||||||
"extract_metadata"
|
assert!(!enforcer.validate_function_call(&kinds, "search"));
|
||||||
));
|
assert!(!enforcer.validate_function_call(&kinds, "generate_thumbnail"));
|
||||||
assert!(CapabilityEnforcer::validate_function_call(
|
assert!(!enforcer.validate_function_call(&kinds, "can_handle"));
|
||||||
&kinds,
|
|
||||||
"supported_types"
|
|
||||||
));
|
|
||||||
assert!(!CapabilityEnforcer::validate_function_call(
|
|
||||||
&kinds, "search"
|
|
||||||
));
|
|
||||||
assert!(!CapabilityEnforcer::validate_function_call(
|
|
||||||
&kinds,
|
|
||||||
"generate_thumbnail"
|
|
||||||
));
|
|
||||||
assert!(!CapabilityEnforcer::validate_function_call(
|
|
||||||
&kinds,
|
|
||||||
"can_handle"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_function_call_multi_kind() {
|
fn test_validate_function_call_multi_kind() {
|
||||||
|
let enforcer = CapabilityEnforcer::new();
|
||||||
let kinds =
|
let kinds =
|
||||||
vec!["media_type".to_string(), "metadata_extractor".to_string()];
|
vec!["media_type".to_string(), "metadata_extractor".to_string()];
|
||||||
assert!(CapabilityEnforcer::validate_function_call(
|
assert!(enforcer.validate_function_call(&kinds, "can_handle"));
|
||||||
&kinds,
|
assert!(enforcer.validate_function_call(&kinds, "supported_media_types"));
|
||||||
"can_handle"
|
assert!(enforcer.validate_function_call(&kinds, "extract_metadata"));
|
||||||
));
|
assert!(!enforcer.validate_function_call(&kinds, "search"));
|
||||||
assert!(CapabilityEnforcer::validate_function_call(
|
|
||||||
&kinds,
|
|
||||||
"supported_media_types"
|
|
||||||
));
|
|
||||||
assert!(CapabilityEnforcer::validate_function_call(
|
|
||||||
&kinds,
|
|
||||||
"extract_metadata"
|
|
||||||
));
|
|
||||||
assert!(!CapabilityEnforcer::validate_function_call(
|
|
||||||
&kinds, "search"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_function_call_unknown_function() {
|
fn test_validate_function_call_unknown_function() {
|
||||||
|
let enforcer = CapabilityEnforcer::new();
|
||||||
let kinds = vec!["metadata_extractor".to_string()];
|
let kinds = vec!["metadata_extractor".to_string()];
|
||||||
assert!(!CapabilityEnforcer::validate_function_call(
|
assert!(!enforcer.validate_function_call(&kinds, "unknown_func"));
|
||||||
&kinds,
|
assert!(!enforcer.validate_function_call(&kinds, ""));
|
||||||
"unknown_func"
|
|
||||||
));
|
|
||||||
assert!(!CapabilityEnforcer::validate_function_call(&kinds, ""));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_function_call_shared_supported_types() {
|
fn test_validate_function_call_shared_supported_types() {
|
||||||
|
let enforcer = CapabilityEnforcer::new();
|
||||||
let extractor = vec!["metadata_extractor".to_string()];
|
let extractor = vec!["metadata_extractor".to_string()];
|
||||||
let generator = vec!["thumbnail_generator".to_string()];
|
let generator = vec!["thumbnail_generator".to_string()];
|
||||||
let search = vec!["search_backend".to_string()];
|
let search = vec!["search_backend".to_string()];
|
||||||
assert!(CapabilityEnforcer::validate_function_call(
|
assert!(enforcer.validate_function_call(&extractor, "supported_types"));
|
||||||
&extractor,
|
assert!(enforcer.validate_function_call(&generator, "supported_types"));
|
||||||
"supported_types"
|
assert!(!enforcer.validate_function_call(&search, "supported_types"));
|
||||||
));
|
|
||||||
assert!(CapabilityEnforcer::validate_function_call(
|
|
||||||
&generator,
|
|
||||||
"supported_types"
|
|
||||||
));
|
|
||||||
assert!(!CapabilityEnforcer::validate_function_call(
|
|
||||||
&search,
|
|
||||||
"supported_types"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
use std::{path::PathBuf, sync::Arc};
|
use std::{path::PathBuf, sync::Arc};
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Datelike, Utc};
|
||||||
pub use pinakes_types::config::Schedule;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
@ -12,6 +11,102 @@ use crate::{
|
||||||
jobs::{JobKind, JobQueue},
|
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<Utc>) -> DateTime<Utc> {
|
||||||
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ScheduledTask {
|
pub struct ScheduledTask {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|
@ -156,7 +251,7 @@ impl TaskScheduler {
|
||||||
}
|
}
|
||||||
if task.enabled {
|
if task.enabled {
|
||||||
let from = task.last_run.unwrap_or_else(Utc::now);
|
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 {
|
} else {
|
||||||
task.next_run = None;
|
task.next_run = None;
|
||||||
}
|
}
|
||||||
|
|
@ -203,7 +298,7 @@ impl TaskScheduler {
|
||||||
if let Some(task) = tasks.iter_mut().find(|t| t.id == id) {
|
if let Some(task) = tasks.iter_mut().find(|t| t.id == id) {
|
||||||
task.enabled = !task.enabled;
|
task.enabled = !task.enabled;
|
||||||
if 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 {
|
} else {
|
||||||
task.next_run = None;
|
task.next_run = None;
|
||||||
}
|
}
|
||||||
|
|
@ -236,7 +331,7 @@ impl TaskScheduler {
|
||||||
task.running = true;
|
task.running = true;
|
||||||
task.last_job_id = Some(job_id);
|
task.last_job_id = Some(job_id);
|
||||||
if task.enabled {
|
if task.enabled {
|
||||||
task.next_run = task.schedule.next_run(Utc::now());
|
task.next_run = Some(task.schedule.next_run(Utc::now()));
|
||||||
}
|
}
|
||||||
drop(tasks);
|
drop(tasks);
|
||||||
}
|
}
|
||||||
|
|
@ -308,7 +403,7 @@ impl TaskScheduler {
|
||||||
task.last_run = Some(now);
|
task.last_run = Some(now);
|
||||||
task.last_status = Some("running".to_string());
|
task.last_status = Some("running".to_string());
|
||||||
task.running = true;
|
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() {
|
fn test_interval_next_run() {
|
||||||
let from = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
|
let from = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
|
||||||
let schedule = Schedule::Interval { secs: 3600 };
|
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());
|
assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 15, 13, 0, 0).unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -348,7 +443,7 @@ mod tests {
|
||||||
hour: 14,
|
hour: 14,
|
||||||
minute: 0,
|
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());
|
assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 15, 14, 0, 0).unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -360,7 +455,7 @@ mod tests {
|
||||||
hour: 14,
|
hour: 14,
|
||||||
minute: 0,
|
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());
|
assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 16, 14, 0, 0).unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -373,7 +468,7 @@ mod tests {
|
||||||
hour: 3,
|
hour: 3,
|
||||||
minute: 0,
|
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());
|
assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 16, 3, 0, 0).unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -387,7 +482,7 @@ mod tests {
|
||||||
hour: 14,
|
hour: 14,
|
||||||
minute: 0,
|
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());
|
assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 15, 14, 0, 0).unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -401,7 +496,7 @@ mod tests {
|
||||||
hour: 8,
|
hour: 8,
|
||||||
minute: 0,
|
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());
|
assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 22, 8, 0, 0).unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -204,7 +204,7 @@ impl SharePermissions {
|
||||||
|
|
||||||
/// Merges two permission sets, taking the most permissive values.
|
/// Merges two permission sets, taking the most permissive values.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn merge(self, other: Self) -> Self {
|
pub const fn merge(&self, other: &Self) -> Self {
|
||||||
Self {
|
Self {
|
||||||
view: ShareViewPermissions {
|
view: ShareViewPermissions {
|
||||||
can_view: self.view.can_view || other.view.can_view,
|
can_view: self.view.can_view || other.view.can_view,
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,28 @@
|
||||||
/// # Errors
|
use crate::error::{PinakesError, Result};
|
||||||
///
|
|
||||||
/// Returns an error if migrations fail to apply.
|
mod sqlite_migrations {
|
||||||
#[cfg(feature = "sqlite")]
|
use refinery::embed_migrations;
|
||||||
pub fn run_sqlite_migrations(
|
embed_migrations!("../../migrations/sqlite");
|
||||||
conn: &mut rusqlite::Connection,
|
}
|
||||||
) -> crate::error::Result<()> {
|
|
||||||
pinakes_migrations::sqlite_migrations()
|
mod postgres_migrations {
|
||||||
.to_latest(conn)
|
use refinery::embed_migrations;
|
||||||
.map_err(|e| crate::error::PinakesError::Migration(e.to_string()))
|
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(
|
pub async fn run_postgres_migrations(
|
||||||
client: &mut tokio_postgres::Client,
|
client: &mut tokio_postgres::Client,
|
||||||
) -> crate::error::Result<()> {
|
) -> Result<()> {
|
||||||
pinakes_migrations::postgres_runner()
|
postgres_migrations::migrations::runner()
|
||||||
.run_async(client)
|
.run_async(client)
|
||||||
.await
|
.await
|
||||||
.map(|_| ())
|
.map_err(|e| PinakesError::Migration(e.to_string()))?;
|
||||||
.map_err(|e| crate::error::PinakesError::Migration(e.to_string()))
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
pub mod migrations;
|
pub mod migrations;
|
||||||
#[cfg(feature = "postgres")] pub mod postgres;
|
pub mod postgres;
|
||||||
#[cfg(feature = "sqlite")] pub mod sqlite;
|
pub mod sqlite;
|
||||||
|
|
||||||
use std::{path::PathBuf, sync::Arc};
|
use std::{path::PathBuf, sync::Arc};
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use pinakes_enrichment::ExternalMetadata;
|
|
||||||
use rustc_hash::FxHashMap;
|
use rustc_hash::FxHashMap;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
analytics::UsageEvent,
|
analytics::UsageEvent,
|
||||||
|
enrichment::ExternalMetadata,
|
||||||
error::Result,
|
error::Result,
|
||||||
model::{
|
model::{
|
||||||
AuditEntry,
|
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}"
|
"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,
|
user_id: crate::users::UserId,
|
||||||
media_id: crate::model::MediaId,
|
media_id: crate::model::MediaId,
|
||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
Ok(
|
match self.check_library_access(user_id, media_id).await {
|
||||||
self
|
Ok(perm) => Ok(perm.can_read()),
|
||||||
.check_library_access(user_id, media_id)
|
Err(_) => Ok(false),
|
||||||
.await
|
}
|
||||||
.is_ok_and(|_perm| crate::users::LibraryPermission::can_read()),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a user has write access to a media item
|
/// 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,
|
user_id: crate::users::UserId,
|
||||||
media_id: crate::model::MediaId,
|
media_id: crate::model::MediaId,
|
||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
Ok(
|
match self.check_library_access(user_id, media_id).await {
|
||||||
self
|
Ok(perm) => Ok(perm.can_write()),
|
||||||
.check_library_access(user_id, media_id)
|
Err(_) => Ok(false),
|
||||||
.await
|
}
|
||||||
.is_ok_and(crate::users::LibraryPermission::can_write),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rate a media item (1-5 stars) with an optional text review.
|
/// 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
|
/// Register a new sync device
|
||||||
async fn register_device(
|
async fn register_device(
|
||||||
&self,
|
&self,
|
||||||
device: &pinakes_sync::SyncDevice,
|
device: &crate::sync::SyncDevice,
|
||||||
token_hash: &str,
|
token_hash: &str,
|
||||||
) -> Result<pinakes_sync::SyncDevice>;
|
) -> Result<crate::sync::SyncDevice>;
|
||||||
|
|
||||||
/// Get a sync device by ID
|
/// Get a sync device by ID
|
||||||
async fn get_device(
|
async fn get_device(
|
||||||
&self,
|
&self,
|
||||||
id: pinakes_sync::DeviceId,
|
id: crate::sync::DeviceId,
|
||||||
) -> Result<pinakes_sync::SyncDevice>;
|
) -> Result<crate::sync::SyncDevice>;
|
||||||
|
|
||||||
/// Get a sync device by its token hash
|
/// Get a sync device by its token hash
|
||||||
async fn get_device_by_token(
|
async fn get_device_by_token(
|
||||||
&self,
|
&self,
|
||||||
token_hash: &str,
|
token_hash: &str,
|
||||||
) -> Result<Option<pinakes_sync::SyncDevice>>;
|
) -> Result<Option<crate::sync::SyncDevice>>;
|
||||||
|
|
||||||
/// List all devices for a user
|
/// List all devices for a user
|
||||||
async fn list_user_devices(
|
async fn list_user_devices(
|
||||||
&self,
|
&self,
|
||||||
user_id: UserId,
|
user_id: UserId,
|
||||||
) -> Result<Vec<pinakes_sync::SyncDevice>>;
|
) -> Result<Vec<crate::sync::SyncDevice>>;
|
||||||
|
|
||||||
/// Update a sync device
|
/// Update a sync device
|
||||||
async fn update_device(
|
async fn update_device(&self, device: &crate::sync::SyncDevice)
|
||||||
&self,
|
-> Result<()>;
|
||||||
device: &pinakes_sync::SyncDevice,
|
|
||||||
) -> Result<()>;
|
|
||||||
|
|
||||||
/// Delete a sync device
|
/// 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
|
/// 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
|
/// Record a change in the sync log
|
||||||
async fn record_sync_change(
|
async fn record_sync_change(
|
||||||
&self,
|
&self,
|
||||||
change: &pinakes_sync::SyncLogEntry,
|
change: &crate::sync::SyncLogEntry,
|
||||||
) -> Result<()>;
|
) -> Result<()>;
|
||||||
|
|
||||||
/// Get changes since a cursor position
|
/// Get changes since a cursor position
|
||||||
|
|
@ -890,7 +884,7 @@ pub trait StorageBackend: Send + Sync + 'static {
|
||||||
&self,
|
&self,
|
||||||
cursor: i64,
|
cursor: i64,
|
||||||
limit: u64,
|
limit: u64,
|
||||||
) -> Result<Vec<pinakes_sync::SyncLogEntry>>;
|
) -> Result<Vec<crate::sync::SyncLogEntry>>;
|
||||||
|
|
||||||
/// Get the current sync cursor (highest sequence number)
|
/// Get the current sync cursor (highest sequence number)
|
||||||
async fn get_current_sync_cursor(&self) -> Result<i64>;
|
async fn get_current_sync_cursor(&self) -> Result<i64>;
|
||||||
|
|
@ -901,52 +895,52 @@ pub trait StorageBackend: Send + Sync + 'static {
|
||||||
/// Get sync state for a device and path
|
/// Get sync state for a device and path
|
||||||
async fn get_device_sync_state(
|
async fn get_device_sync_state(
|
||||||
&self,
|
&self,
|
||||||
device_id: pinakes_sync::DeviceId,
|
device_id: crate::sync::DeviceId,
|
||||||
path: &str,
|
path: &str,
|
||||||
) -> Result<Option<pinakes_sync::DeviceSyncState>>;
|
) -> Result<Option<crate::sync::DeviceSyncState>>;
|
||||||
|
|
||||||
/// Insert or update device sync state
|
/// Insert or update device sync state
|
||||||
async fn upsert_device_sync_state(
|
async fn upsert_device_sync_state(
|
||||||
&self,
|
&self,
|
||||||
state: &pinakes_sync::DeviceSyncState,
|
state: &crate::sync::DeviceSyncState,
|
||||||
) -> Result<()>;
|
) -> Result<()>;
|
||||||
|
|
||||||
/// List all pending sync items for a device
|
/// List all pending sync items for a device
|
||||||
async fn list_pending_sync(
|
async fn list_pending_sync(
|
||||||
&self,
|
&self,
|
||||||
device_id: pinakes_sync::DeviceId,
|
device_id: crate::sync::DeviceId,
|
||||||
) -> Result<Vec<pinakes_sync::DeviceSyncState>>;
|
) -> Result<Vec<crate::sync::DeviceSyncState>>;
|
||||||
|
|
||||||
/// Create a new upload session
|
/// Create a new upload session
|
||||||
async fn create_upload_session(
|
async fn create_upload_session(
|
||||||
&self,
|
&self,
|
||||||
session: &pinakes_sync::UploadSession,
|
session: &crate::sync::UploadSession,
|
||||||
) -> Result<()>;
|
) -> Result<()>;
|
||||||
|
|
||||||
/// Get an upload session by ID
|
/// Get an upload session by ID
|
||||||
async fn get_upload_session(
|
async fn get_upload_session(
|
||||||
&self,
|
&self,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
) -> Result<pinakes_sync::UploadSession>;
|
) -> Result<crate::sync::UploadSession>;
|
||||||
|
|
||||||
/// Update an upload session
|
/// Update an upload session
|
||||||
async fn update_upload_session(
|
async fn update_upload_session(
|
||||||
&self,
|
&self,
|
||||||
session: &pinakes_sync::UploadSession,
|
session: &crate::sync::UploadSession,
|
||||||
) -> Result<()>;
|
) -> Result<()>;
|
||||||
|
|
||||||
/// Record a received chunk
|
/// Record a received chunk
|
||||||
async fn record_chunk(
|
async fn record_chunk(
|
||||||
&self,
|
&self,
|
||||||
upload_id: Uuid,
|
upload_id: Uuid,
|
||||||
chunk: &pinakes_sync::ChunkInfo,
|
chunk: &crate::sync::ChunkInfo,
|
||||||
) -> Result<()>;
|
) -> Result<()>;
|
||||||
|
|
||||||
/// Get all chunks for an upload
|
/// Get all chunks for an upload
|
||||||
async fn get_upload_chunks(
|
async fn get_upload_chunks(
|
||||||
&self,
|
&self,
|
||||||
upload_id: Uuid,
|
upload_id: Uuid,
|
||||||
) -> Result<Vec<pinakes_sync::ChunkInfo>>;
|
) -> Result<Vec<crate::sync::ChunkInfo>>;
|
||||||
|
|
||||||
/// Clean up expired upload sessions
|
/// Clean up expired upload sessions
|
||||||
async fn cleanup_expired_uploads(&self) -> Result<u64>;
|
async fn cleanup_expired_uploads(&self) -> Result<u64>;
|
||||||
|
|
@ -954,20 +948,20 @@ pub trait StorageBackend: Send + Sync + 'static {
|
||||||
/// Record a sync conflict
|
/// Record a sync conflict
|
||||||
async fn record_conflict(
|
async fn record_conflict(
|
||||||
&self,
|
&self,
|
||||||
conflict: &pinakes_sync::SyncConflict,
|
conflict: &crate::sync::SyncConflict,
|
||||||
) -> Result<()>;
|
) -> Result<()>;
|
||||||
|
|
||||||
/// Get unresolved conflicts for a device
|
/// Get unresolved conflicts for a device
|
||||||
async fn get_unresolved_conflicts(
|
async fn get_unresolved_conflicts(
|
||||||
&self,
|
&self,
|
||||||
device_id: pinakes_sync::DeviceId,
|
device_id: crate::sync::DeviceId,
|
||||||
) -> Result<Vec<pinakes_sync::SyncConflict>>;
|
) -> Result<Vec<crate::sync::SyncConflict>>;
|
||||||
|
|
||||||
/// Resolve a conflict
|
/// Resolve a conflict
|
||||||
async fn resolve_conflict(
|
async fn resolve_conflict(
|
||||||
&self,
|
&self,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
resolution: pinakes_types::config::ConflictResolution,
|
resolution: crate::config::ConflictResolution,
|
||||||
) -> Result<()>;
|
) -> Result<()>;
|
||||||
|
|
||||||
/// Create a new share
|
/// Create a new share
|
||||||
|
|
@ -1182,7 +1176,7 @@ pub trait StorageBackend: Send + Sync + 'static {
|
||||||
/// deployments should use `pg_dump` directly; this method returns
|
/// deployments should use `pg_dump` directly; this method returns
|
||||||
/// `PinakesError::InvalidOperation` for unsupported backends.
|
/// `PinakesError::InvalidOperation` for unsupported backends.
|
||||||
async fn backup(&self, _dest: &std::path::Path) -> Result<()> {
|
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 \
|
"backup not supported for this storage backend; use pg_dump for \
|
||||||
PostgreSQL"
|
PostgreSQL"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -172,8 +172,9 @@ pub async fn list_embedded_tracks(
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let Some(streams) = json.get("streams").and_then(|s| s.as_array()) else {
|
let streams = match json.get("streams").and_then(|s| s.as_array()) {
|
||||||
return Ok(vec![]);
|
Some(s) => s,
|
||||||
|
None => return Ok(vec![]),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut tracks = Vec::new();
|
let mut tracks = Vec::new();
|
||||||
|
|
@ -202,7 +203,7 @@ pub async fn list_embedded_tracks(
|
||||||
.map(str::to_owned);
|
.map(str::to_owned);
|
||||||
|
|
||||||
tracks.push(SubtitleTrackInfo {
|
tracks.push(SubtitleTrackInfo {
|
||||||
index: u32::try_from(idx).unwrap_or(u32::MAX),
|
index: idx as u32,
|
||||||
language,
|
language,
|
||||||
format,
|
format,
|
||||||
title,
|
title,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use pinakes_types::error::{PinakesError, Result};
|
|
||||||
use tokio::{
|
use tokio::{
|
||||||
fs,
|
fs,
|
||||||
io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt},
|
io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt},
|
||||||
|
|
@ -12,6 +11,7 @@ use tracing::{debug, info};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::{ChunkInfo, UploadSession};
|
use super::{ChunkInfo, UploadSession};
|
||||||
|
use crate::error::{PinakesError, Result};
|
||||||
|
|
||||||
/// Manager for chunked uploads.
|
/// Manager for chunked uploads.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
@ -271,11 +271,10 @@ async fn compute_file_hash(path: &Path) -> Result<String> {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use pinakes_types::model::ContentHash;
|
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::UploadStatus;
|
use crate::{model::ContentHash, sync::UploadStatus};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_chunked_upload() {
|
async fn test_chunked_upload() {
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
//! Conflict detection and resolution for sync.
|
//! Conflict detection and resolution for sync.
|
||||||
|
|
||||||
use pinakes_types::config::ConflictResolution;
|
|
||||||
|
|
||||||
use super::DeviceSyncState;
|
use super::DeviceSyncState;
|
||||||
|
use crate::config::ConflictResolution;
|
||||||
|
|
||||||
/// Detect if there's a conflict between local and server state.
|
/// Detect if there's a conflict between local and server state.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
|
|
@ -94,14 +93,15 @@ pub const fn resolve_by_mtime(conflict: &ConflictInfo) -> ConflictOutcome {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
(Some(_), None) => ConflictOutcome::UseLocal,
|
(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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::FileSyncStatus;
|
use crate::sync::FileSyncStatus;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_generate_conflict_path() {
|
fn test_generate_conflict_path() {
|
||||||
|
|
@ -2,10 +2,13 @@
|
||||||
//!
|
//!
|
||||||
//! Provides device registration, change tracking, and conflict resolution
|
//! Provides device registration, change tracking, and conflict resolution
|
||||||
//! for syncing media libraries across multiple devices.
|
//! 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;
|
mod protocol;
|
||||||
|
|
||||||
|
pub use chunked::*;
|
||||||
|
pub use conflict::*;
|
||||||
|
pub use models::*;
|
||||||
pub use protocol::*;
|
pub use protocol::*;
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,15 @@
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use pinakes_types::{
|
|
||||||
config::ConflictResolution,
|
|
||||||
model::{ContentHash, MediaId, UserId},
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
config::ConflictResolution,
|
||||||
|
model::{ContentHash, MediaId},
|
||||||
|
users::UserId,
|
||||||
|
};
|
||||||
|
|
||||||
/// Unique identifier for a sync device.
|
/// Unique identifier for a sync device.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
pub struct DeviceId(pub Uuid);
|
pub struct DeviceId(pub Uuid);
|
||||||
|
|
@ -364,7 +366,7 @@ impl UploadSession {
|
||||||
chunk_count,
|
chunk_count,
|
||||||
status: UploadStatus::Pending,
|
status: UploadStatus::Pending,
|
||||||
created_at: now,
|
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,
|
last_activity: now,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3,16 +3,16 @@
|
||||||
//! Handles the bidirectional sync protocol between clients and server.
|
//! Handles the bidirectional sync protocol between clients and server.
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use pinakes_sync::{
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::{
|
||||||
DeviceId,
|
DeviceId,
|
||||||
DeviceSyncState,
|
DeviceSyncState,
|
||||||
FileSyncStatus,
|
FileSyncStatus,
|
||||||
SyncChangeType,
|
SyncChangeType,
|
||||||
SyncLogEntry,
|
SyncLogEntry,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::Result,
|
error::Result,
|
||||||
model::{ContentHash, MediaId},
|
model::{ContentHash, MediaId},
|
||||||
|
|
|
||||||
|
|
@ -367,7 +367,7 @@ pub enum CoverSize {
|
||||||
|
|
||||||
impl CoverSize {
|
impl CoverSize {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn dimensions(self) -> Option<(u32, u32)> {
|
pub const fn dimensions(&self) -> Option<(u32, u32)> {
|
||||||
match self {
|
match self {
|
||||||
Self::Tiny => Some((64, 64)),
|
Self::Tiny => Some((64, 64)),
|
||||||
Self::Grid => Some((320, 320)),
|
Self::Grid => Some((320, 320)),
|
||||||
|
|
@ -377,7 +377,7 @@ impl CoverSize {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn filename(self) -> &'static str {
|
pub const fn filename(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Tiny => "tiny.jpg",
|
Self::Tiny => "tiny.jpg",
|
||||||
Self::Grid => "grid.jpg",
|
Self::Grid => "grid.jpg",
|
||||||
|
|
@ -541,7 +541,7 @@ pub enum ThumbnailSize {
|
||||||
impl ThumbnailSize {
|
impl ThumbnailSize {
|
||||||
/// Get the pixel size for this thumbnail variant
|
/// Get the pixel size for this thumbnail variant
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn pixels(self) -> u32 {
|
pub const fn pixels(&self) -> u32 {
|
||||||
match self {
|
match self {
|
||||||
Self::Tiny => 64,
|
Self::Tiny => 64,
|
||||||
Self::Grid => 320,
|
Self::Grid => 320,
|
||||||
|
|
@ -551,7 +551,7 @@ impl ThumbnailSize {
|
||||||
|
|
||||||
/// Get the subdirectory name for this size
|
/// Get the subdirectory name for this size
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn subdir_name(self) -> &'static str {
|
pub const fn subdir_name(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Tiny => "tiny",
|
Self::Tiny => "tiny",
|
||||||
Self::Grid => "grid",
|
Self::Grid => "grid",
|
||||||
|
|
|
||||||
|
|
@ -103,9 +103,9 @@ impl TranscodeService {
|
||||||
pub fn new(config: TranscodingConfig) -> Self {
|
pub fn new(config: TranscodingConfig) -> Self {
|
||||||
let max_concurrent = config.max_concurrent.max(1);
|
let max_concurrent = config.max_concurrent.max(1);
|
||||||
Self {
|
Self {
|
||||||
config,
|
|
||||||
sessions: Arc::new(RwLock::new(FxHashMap::default())),
|
sessions: Arc::new(RwLock::new(FxHashMap::default())),
|
||||||
semaphore: Arc::new(Semaphore::new(max_concurrent)),
|
semaphore: Arc::new(Semaphore::new(max_concurrent)),
|
||||||
|
config,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ use crate::{
|
||||||
error::{PinakesError, Result},
|
error::{PinakesError, Result},
|
||||||
managed_storage::ManagedStorageService,
|
managed_storage::ManagedStorageService,
|
||||||
media_type::MediaType,
|
media_type::MediaType,
|
||||||
|
metadata,
|
||||||
model::{MediaId, MediaItem, StorageMode, UploadResult},
|
model::{MediaId, MediaItem, StorageMode, UploadResult},
|
||||||
storage::DynStorageBackend,
|
storage::DynStorageBackend,
|
||||||
};
|
};
|
||||||
|
|
@ -57,8 +58,7 @@ pub async fn process_upload<R: AsyncRead + Unpin>(
|
||||||
let blob_path = managed.path(&content_hash);
|
let blob_path = managed.path(&content_hash);
|
||||||
|
|
||||||
// Extract metadata
|
// Extract metadata
|
||||||
let extracted =
|
let extracted = metadata::extract_metadata(&blob_path, &media_type).ok();
|
||||||
pinakes_metadata::extract_metadata(&blob_path, &media_type).ok();
|
|
||||||
|
|
||||||
// Create or get blob record
|
// Create or get blob record
|
||||||
let mime = mime_type.map_or_else(|| media_type.mime_type(), String::from);
|
let mime = mime_type.map_or_else(|| media_type.mime_type(), String::from);
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,45 @@
|
||||||
//! User management and authentication
|
//! User management and authentication
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
pub use pinakes_types::model::UserId;
|
|
||||||
use rustc_hash::FxHashMap;
|
use rustc_hash::FxHashMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::UserRole,
|
config::UserRole,
|
||||||
error::{PinakesError, Result},
|
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<Uuid> for UserId {
|
||||||
|
fn from(id: Uuid) -> Self {
|
||||||
|
Self(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// User account with profile information
|
/// User account with profile information
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
|
|
@ -67,24 +97,24 @@ pub enum LibraryPermission {
|
||||||
impl LibraryPermission {
|
impl LibraryPermission {
|
||||||
/// Checks if read permission is granted.
|
/// Checks if read permission is granted.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn can_read() -> bool {
|
pub const fn can_read(&self) -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks if write permission is granted.
|
/// Checks if write permission is granted.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn can_write(self) -> bool {
|
pub const fn can_write(&self) -> bool {
|
||||||
matches!(self, Self::Write | Self::Admin)
|
matches!(self, Self::Write | Self::Admin)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks if admin permission is granted.
|
/// Checks if admin permission is granted.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn can_admin(self) -> bool {
|
pub const fn can_admin(&self) -> bool {
|
||||||
matches!(self, Self::Admin)
|
matches!(self, Self::Admin)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn as_str(self) -> &'static str {
|
pub const fn as_str(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Read => "read",
|
Self::Read => "read",
|
||||||
Self::Write => "write",
|
Self::Write => "write",
|
||||||
|
|
@ -132,10 +162,6 @@ pub mod auth {
|
||||||
use super::{PinakesError, Result};
|
use super::{PinakesError, Result};
|
||||||
|
|
||||||
/// Hash a password using Argon2
|
/// Hash a password using Argon2
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns an error if password hashing fails.
|
|
||||||
pub fn hash_password(password: &str) -> Result<String> {
|
pub fn hash_password(password: &str) -> Result<String> {
|
||||||
use argon2::{
|
use argon2::{
|
||||||
Argon2,
|
Argon2,
|
||||||
|
|
@ -154,10 +180,6 @@ pub mod auth {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify a password against a hash
|
/// 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<bool> {
|
pub fn verify_password(password: &str, hash: &str) -> Result<bool> {
|
||||||
use argon2::{
|
use argon2::{
|
||||||
Argon2,
|
Argon2,
|
||||||
|
|
@ -201,17 +223,17 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn test_library_permission_levels() {
|
fn test_library_permission_levels() {
|
||||||
let read = LibraryPermission::Read;
|
let read = LibraryPermission::Read;
|
||||||
assert!(LibraryPermission::can_read());
|
assert!(read.can_read());
|
||||||
assert!(!read.can_write());
|
assert!(!read.can_write());
|
||||||
assert!(!read.can_admin());
|
assert!(!read.can_admin());
|
||||||
|
|
||||||
let write = LibraryPermission::Write;
|
let write = LibraryPermission::Write;
|
||||||
assert!(LibraryPermission::can_read());
|
assert!(write.can_read());
|
||||||
assert!(write.can_write());
|
assert!(write.can_write());
|
||||||
assert!(!write.can_admin());
|
assert!(!write.can_admin());
|
||||||
|
|
||||||
let admin = LibraryPermission::Admin;
|
let admin = LibraryPermission::Admin;
|
||||||
assert!(LibraryPermission::can_read());
|
assert!(admin.can_read());
|
||||||
assert!(admin.can_write());
|
assert!(admin.can_write());
|
||||||
assert!(admin.can_admin());
|
assert!(admin.can_admin());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ impl WebhookDispatcher {
|
||||||
/// Dispatch an event to all matching webhooks.
|
/// Dispatch an event to all matching webhooks.
|
||||||
/// This is fire-and-forget, errors are logged but not propagated.
|
/// This is fire-and-forget, errors are logged but not propagated.
|
||||||
pub fn dispatch(self: &Arc<Self>, event: WebhookEvent) {
|
pub fn dispatch(self: &Arc<Self>, event: WebhookEvent) {
|
||||||
let this = Arc::clone(self);
|
let this = self.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
this.dispatch_inner(&event).await;
|
this.dispatch_inner(&event).await;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
use pinakes_core::{
|
use pinakes_core::{
|
||||||
books::{extract_isbn_from_text, normalize_isbn, parse_author_file_as},
|
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},
|
thumbnail::{CoverSize, extract_epub_cover, generate_book_covers},
|
||||||
};
|
};
|
||||||
use pinakes_enrichment::{
|
|
||||||
books::BookEnricher,
|
|
||||||
googlebooks::GoogleBooksClient,
|
|
||||||
openlibrary::OpenLibraryClient,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_isbn_normalization() {
|
fn test_isbn_normalization() {
|
||||||
|
|
|
||||||
|
|
@ -841,10 +841,10 @@ async fn test_external_metadata() {
|
||||||
let item = make_test_media("enrich1");
|
let item = make_test_media("enrich1");
|
||||||
storage.insert_media(&item).await.unwrap();
|
storage.insert_media(&item).await.unwrap();
|
||||||
|
|
||||||
let meta = pinakes_enrichment::ExternalMetadata {
|
let meta = pinakes_core::enrichment::ExternalMetadata {
|
||||||
id: uuid::Uuid::now_v7(),
|
id: uuid::Uuid::now_v7(),
|
||||||
media_id: item.id,
|
media_id: item.id,
|
||||||
source: pinakes_enrichment::EnrichmentSourceType::MusicBrainz,
|
source: pinakes_core::enrichment::EnrichmentSourceType::MusicBrainz,
|
||||||
external_id: Some("mb-123".to_string()),
|
external_id: Some("mb-123".to_string()),
|
||||||
metadata_json: r#"{"title":"Test"}"#.to_string(),
|
metadata_json: r#"{"title":"Test"}"#.to_string(),
|
||||||
confidence: 0.85,
|
confidence: 0.85,
|
||||||
|
|
@ -857,7 +857,7 @@ async fn test_external_metadata() {
|
||||||
assert_eq!(metas.len(), 1);
|
assert_eq!(metas.len(), 1);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
metas[0].source,
|
metas[0].source,
|
||||||
pinakes_enrichment::EnrichmentSourceType::MusicBrainz
|
pinakes_core::enrichment::EnrichmentSourceType::MusicBrainz
|
||||||
);
|
);
|
||||||
assert_eq!(metas[0].external_id.as_deref(), Some("mb-123"));
|
assert_eq!(metas[0].external_id.as_deref(), Some("mb-123"));
|
||||||
assert!((metas[0].confidence - 0.85).abs() < 0.01);
|
assert!((metas[0].confidence - 0.85).abs() < 0.01);
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,10 @@
|
||||||
#![allow(clippy::print_stderr, reason = "Fine for tests")]
|
#![allow(clippy::print_stderr, reason = "Fine for tests")]
|
||||||
use std::{path::Path, sync::Arc};
|
use std::{path::Path, sync::Arc};
|
||||||
|
|
||||||
use pinakes_core::plugin::PluginPipeline;
|
use pinakes_core::{
|
||||||
use pinakes_plugin::{PluginManager, PluginManagerConfig};
|
config::PluginTimeoutConfig,
|
||||||
use pinakes_types::config::PluginTimeoutConfig;
|
plugin::{PluginManager, PluginManagerConfig, PluginPipeline},
|
||||||
|
};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
/// Path to the compiled test plugin fixture.
|
/// Path to the compiled test plugin fixture.
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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<String>,
|
|
||||||
pub metadata_json: String,
|
|
||||||
pub confidence: f64,
|
|
||||||
pub last_updated: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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<Self, Self::Err> {
|
|
||||||
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<Option<ExternalMetadata>>;
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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<String>,
|
|
||||||
pub artist: Option<String>,
|
|
||||||
pub album: Option<String>,
|
|
||||||
pub genre: Option<String>,
|
|
||||||
pub year: Option<i32>,
|
|
||||||
pub duration_secs: Option<f64>,
|
|
||||||
pub description: Option<String>,
|
|
||||||
pub extra: FxHashMap<String, String>,
|
|
||||||
pub book_metadata: Option<BookMetadata>,
|
|
||||||
|
|
||||||
// Photo-specific metadata
|
|
||||||
pub date_taken: Option<chrono::DateTime<chrono::Utc>>,
|
|
||||||
pub latitude: Option<f64>,
|
|
||||||
pub longitude: Option<f64>,
|
|
||||||
pub camera_make: Option<String>,
|
|
||||||
pub camera_model: Option<String>,
|
|
||||||
pub rating: Option<i32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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<ExtractedMetadata>;
|
|
||||||
fn supported_types(&self) -> Vec<MediaType>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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<ExtractedMetadata> {
|
|
||||||
let extractors: Vec<Box<dyn MetadataExtractor>> = 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())
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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 ();
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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 $$;
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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)
|
|
||||||
);
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
ALTER TABLE media_items
|
|
||||||
ADD COLUMN thumbnail_path TEXT;
|
|
||||||
|
|
@ -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()
|
|
||||||
);
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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'
|
|
||||||
);
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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
|
|
||||||
);
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
ALTER TABLE media_items
|
|
||||||
ADD COLUMN thumbnail_path TEXT;
|
|
||||||
|
|
@ -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
|
|
||||||
);
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
|
|
@ -4,25 +4,32 @@ version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[features]
|
|
||||||
default = []
|
|
||||||
wasm = ["wit-bindgen"]
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
# Core dependencies
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
|
||||||
|
# For plugin manifest parsing
|
||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
|
|
||||||
|
# For media types and identifiers
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
mime_guess = { workspace = true }
|
mime_guess = { workspace = true }
|
||||||
rustc-hash = { workspace = true }
|
rustc-hash = { workspace = true }
|
||||||
wit-bindgen = { workspace = true, optional = true }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
# WASM bridge types
|
||||||
tokio = { workspace = true, features = ["rt", "rt-multi-thread", "macros"] }
|
wit-bindgen = { workspace = true, optional = true }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
wasm = ["wit-bindgen"]
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true, features = ["rt", "rt-multi-thread", "macros"] }
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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};
|
|
||||||
|
|
@ -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<RwLock<PluginRegistry>>,
|
|
||||||
|
|
||||||
/// WASM runtime for executing plugins
|
|
||||||
runtime: Arc<WasmRuntime>,
|
|
||||||
|
|
||||||
/// 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<PathBuf>,
|
|
||||||
|
|
||||||
/// 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<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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<pinakes_types::config::PluginsConfig> 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<Self> {
|
|
||||||
// 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<Vec<String>> {
|
|
||||||
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<pinakes_plugin_api::PluginManifest> {
|
|
||||||
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<usize>> = 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<usize> = 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<String> {
|
|
||||||
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<ed25519_dalek::VerifyingKey> = 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<String> {
|
|
||||||
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<String> = {
|
|
||||||
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<PluginMetadata> {
|
|
||||||
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<PluginMetadata> {
|
|
||||||
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<String>, 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<String>)> {
|
|
||||||
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<String, String> {
|
|
||||||
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<String>,
|
|
||||||
) -> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -7,11 +7,6 @@ license.workspace = true
|
||||||
[dependencies]
|
[dependencies]
|
||||||
pinakes-core = { workspace = true }
|
pinakes-core = { workspace = true }
|
||||||
pinakes-plugin-api = { 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 }
|
tokio = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
|
@ -41,10 +36,10 @@ utoipa = { workspace = true }
|
||||||
utoipa-axum = { workspace = true }
|
utoipa-axum = { workspace = true }
|
||||||
utoipa-swagger-ui = { workspace = true }
|
utoipa-swagger-ui = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
http-body-util = { workspace = true }
|
|
||||||
reqwest = { workspace = true }
|
|
||||||
tempfile = { workspace = true }
|
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
http-body-util = "0.1.3"
|
||||||
|
reqwest = { workspace = true }
|
||||||
|
tempfile = { workspace = true }
|
||||||
|
|
@ -27,6 +27,8 @@ pub fn create_router(
|
||||||
create_router_with_tls(state, rate_limits, None)
|
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(
|
fn build_governor(
|
||||||
per_second: u64,
|
per_second: u64,
|
||||||
burst_size: u32,
|
burst_size: u32,
|
||||||
|
|
@ -36,18 +38,13 @@ fn build_governor(
|
||||||
governor::middleware::NoOpMiddleware,
|
governor::middleware::NoOpMiddleware,
|
||||||
>,
|
>,
|
||||||
> {
|
> {
|
||||||
// finish() returns None only when per_second=0; clamp to ensure it always
|
Arc::new(
|
||||||
// returns Some
|
GovernorConfigBuilder::default()
|
||||||
let per_second = per_second.max(1);
|
.per_second(per_second)
|
||||||
let burst_size = burst_size.max(1);
|
.burst_size(burst_size)
|
||||||
let Some(config) = GovernorConfigBuilder::default()
|
.finish()
|
||||||
.per_second(per_second)
|
.expect("rate limit config was validated at startup"),
|
||||||
.burst_size(burst_size)
|
)
|
||||||
.finish()
|
|
||||||
else {
|
|
||||||
return build_governor(1, 1);
|
|
||||||
};
|
|
||||||
Arc::new(config)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create the router with TLS configuration for security headers
|
/// 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,
|
// CORS configuration: use config-driven origins if specified,
|
||||||
// otherwise fall back to default localhost origins
|
// otherwise fall back to default localhost origins
|
||||||
let cors = {
|
let cors = {
|
||||||
let default_origins = || {
|
let origins: Vec<HeaderValue> =
|
||||||
vec![
|
if let Ok(config_read) = state.config.try_read() {
|
||||||
HeaderValue::from_static("http://localhost:3000"),
|
|
||||||
HeaderValue::from_static("http://127.0.0.1:3000"),
|
|
||||||
HeaderValue::from_static("tauri://localhost"),
|
|
||||||
]
|
|
||||||
};
|
|
||||||
let origins: Vec<HeaderValue> = state.config.try_read().map_or_else(
|
|
||||||
|_| default_origins(),
|
|
||||||
|config_read| {
|
|
||||||
if config_read.server.cors_enabled
|
if config_read.server.cors_enabled
|
||||||
&& !config_read.server.cors_origins.is_empty()
|
&& !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())
|
.filter_map(|o| HeaderValue::from_str(o).ok())
|
||||||
.collect()
|
.collect()
|
||||||
} else {
|
} 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()
|
CorsLayer::new()
|
||||||
.allow_origin(origins)
|
.allow_origin(origins)
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue