commit 6a73d11c4b3c0f8bdba3163d1a90aa5c2fb242f8 Author: NotAShelf Date: Fri Jan 30 22:05:46 2026 +0300 initial commit Signed-off-by: NotAShelf Change-Id: I4a6b498153eccd5407510dd541b7f4816a6a6964 diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..338d7e5 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,7931 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "base16" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27c3610c36aee21ce8ac510e6224498de4228ad772a171ed65643a24693a5a8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitstream-io" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680575de65ce8b916b82a447458b94a48776707d9c2681a9d8da351c06886a1f" +dependencies = [ + "core2", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "blake3" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.10.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.5.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75ca66430e33a14957acc24c5077b503e7d374151b2b4b3a10c83b4ceb4be0e" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793207c7fa6300a0608d1080b858e5fdbe713cdc1c8db9fb17777d8a13e63df0" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "cocoa" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad36507aeb7e16159dfe68db81ccc27571c3ccd4b76fb2fb72fc59e7a4b1b64c" +dependencies = [ + "bitflags 2.10.0", + "block", + "cocoa-foundation", + "core-foundation 0.10.1", + "core-graphics", + "foreign-types 0.5.0", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d" +dependencies = [ + "bitflags 2.10.0", + "block", + "core-foundation 0.10.1", + "core-graphics-types", + "objc", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "const-serialize" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad7154afa56de2f290e3c82c2c6dc4f5b282b6870903f56ef3509aba95866edc" +dependencies = [ + "const-serialize-macro 0.7.2", +] + +[[package]] +name = "const-serialize" +version = "0.8.0-alpha.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e42cd5aabba86f128b3763da1fec1491c0f728ce99245062cd49b6f9e6d235b" +dependencies = [ + "const-serialize 0.7.2", + "const-serialize-macro 0.8.0-alpha.0", + "serde", +] + +[[package]] +name = "const-serialize-macro" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f160aad86b4343e8d4e261fee9965c3005b2fd6bc117d172ab65948779e4acf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "const-serialize-macro" +version = "0.8.0-alpha.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42571ed01eb46d2e1adcf99c8ca576f081e46f2623d13500eba70d1d99a4c439" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "convert_case" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "derive_more 2.1.1", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf 0.11.3", +] + +[[package]] +name = "cssparser" +version = "0.29.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "matches", + "phf 0.10.1", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.114", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-postgres" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d697d376cbfa018c23eb4caab1fd1883dd9c906a8c034e8d9a3cb06a7e0bef9" +dependencies = [ + "async-trait", + "deadpool", + "getrandom 0.2.17", + "tokio", + "tokio-postgres", + "tracing", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +dependencies = [ + "tokio", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case 0.4.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.114", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.114", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dioxus" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92b583b48ac77158495e6678fe3a2b5954fc8866fc04cb9695dd146e88bc329d" +dependencies = [ + "dioxus-asset-resolver", + "dioxus-cli-config", + "dioxus-config-macro", + "dioxus-config-macros", + "dioxus-core", + "dioxus-core-macro", + "dioxus-desktop", + "dioxus-devtools", + "dioxus-document", + "dioxus-history", + "dioxus-hooks", + "dioxus-html", + "dioxus-logger", + "dioxus-router", + "dioxus-signals", + "dioxus-stores", + "dioxus-web", + "manganis", + "subsecond", + "warnings", +] + +[[package]] +name = "dioxus-asset-resolver" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0161af1d3cfc8ff31503ff1b7ee0068c97771fc38d0cc6566e23483142ddf4f" +dependencies = [ + "dioxus-cli-config", + "http", + "infer", + "jni", + "ndk", + "ndk-context", + "ndk-sys", + "percent-encoding", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "dioxus-cli-config" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccd67ab405e1915a47df9769cd5408545d1b559d5c01ce7a0f442caef520d1f3" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "dioxus-config-macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f040ec7c41aa5428283f56bb0670afba9631bfe3ffd885f4814807f12c8c9d91" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "dioxus-config-macros" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10c41b47b55a433b61f7c12327c85ba650572bacbcc42c342ba2e87a57975264" + +[[package]] +name = "dioxus-core" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b389b0e3cc01c7da292ad9b884b088835fdd1671d45fbd2f737506152b22eef0" +dependencies = [ + "anyhow", + "const_format", + "dioxus-core-types", + "futures-channel", + "futures-util", + "generational-box", + "longest-increasing-subsequence", + "rustc-hash 2.1.1", + "rustversion", + "serde", + "slab", + "slotmap", + "subsecond", + "tracing", +] + +[[package]] +name = "dioxus-core-macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a82d65f0024fc86f01911a16156d280eea583be5a82a3bed85e7e8e4194302d" +dependencies = [ + "convert_case 0.8.0", + "dioxus-rsx", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dioxus-core-types" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfc4b8cdc440a55c17355542fc2089d97949bba674255d84cac77805e1db8c9f" + +[[package]] +name = "dioxus-desktop" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e6ec66749d1556636c5b4f661495565c155a7f78a46d4d007d7478c6bdc288c" +dependencies = [ + "async-trait", + "base64", + "bytes", + "cocoa", + "core-foundation 0.10.1", + "dioxus-asset-resolver", + "dioxus-cli-config", + "dioxus-core", + "dioxus-devtools", + "dioxus-document", + "dioxus-history", + "dioxus-hooks", + "dioxus-html", + "dioxus-interpreter-js", + "dioxus-signals", + "dunce", + "futures-channel", + "futures-util", + "generational-box", + "global-hotkey", + "infer", + "jni", + "lazy-js-bundle", + "libc", + "muda", + "ndk", + "ndk-context", + "ndk-sys", + "objc", + "objc_id", + "percent-encoding", + "rand 0.9.2", + "rfd", + "rustc-hash 2.1.1", + "serde", + "serde_json", + "signal-hook", + "slab", + "subtle", + "tao", + "thiserror 2.0.18", + "tokio", + "tracing", + "tray-icon", + "tungstenite", + "webbrowser", + "wry", +] + +[[package]] +name = "dioxus-devtools" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf89488bad8fb0f18b9086ee2db01f95f709801c10c68be42691a36378a0f2d" +dependencies = [ + "dioxus-cli-config", + "dioxus-core", + "dioxus-devtools-types", + "dioxus-signals", + "serde", + "serde_json", + "subsecond", + "thiserror 2.0.18", + "tracing", + "tungstenite", +] + +[[package]] +name = "dioxus-devtools-types" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7381d9d7d0a0f66b9d5082d584853c3d53be21d34007073daca98ddf26fc4d" +dependencies = [ + "dioxus-core", + "serde", + "subsecond-types", +] + +[[package]] +name = "dioxus-document" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0aeeff26d9d06441f59fd8d7f4f76098ba30ca9728e047c94486161185ceb" +dependencies = [ + "dioxus-core", + "dioxus-core-macro", + "dioxus-core-types", + "dioxus-html", + "futures-channel", + "futures-util", + "generational-box", + "lazy-js-bundle", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "dioxus-history" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d00ba43bfe6e5ca226fef6128f240ca970bea73cac0462416188026360ccdcf" +dependencies = [ + "dioxus-core", + "tracing", +] + +[[package]] +name = "dioxus-hooks" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab2da4f038c33cb38caa37ffc3f5d6dfbc018f05da35b238210a533bb075823" +dependencies = [ + "dioxus-core", + "dioxus-signals", + "futures-channel", + "futures-util", + "generational-box", + "rustversion", + "slab", + "tracing", +] + +[[package]] +name = "dioxus-html" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded5fa6d2e677b7442a93f4228bf3c0ad2597a8bd3292cae50c869d015f3a99" +dependencies = [ + "async-trait", + "bytes", + "dioxus-core", + "dioxus-core-macro", + "dioxus-core-types", + "dioxus-hooks", + "dioxus-html-internal-macro", + "enumset", + "euclid", + "futures-channel", + "futures-util", + "generational-box", + "keyboard-types", + "lazy-js-bundle", + "rustversion", + "serde", + "serde_json", + "serde_repr", + "tracing", +] + +[[package]] +name = "dioxus-html-internal-macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45462ab85fe059a36841508d40545109fd0e25855012d22583a61908eb5cd02a" +dependencies = [ + "convert_case 0.8.0", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dioxus-interpreter-js" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a42a7f73ad32a5054bd8c1014f4ac78cca3b7f6889210ee2b57ea31b33b6d32f" +dependencies = [ + "dioxus-core", + "dioxus-core-types", + "dioxus-html", + "js-sys", + "lazy-js-bundle", + "rustc-hash 2.1.1", + "serde", + "sledgehammer_bindgen", + "sledgehammer_utils", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "dioxus-logger" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1eeab114cb009d9e6b85ea10639a18cfc54bb342f3b837770b004c4daeb89c2" +dependencies = [ + "dioxus-cli-config", + "tracing", + "tracing-subscriber", + "tracing-wasm", +] + +[[package]] +name = "dioxus-router" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d5b31f9e27231389bf5a117b7074d22d8c58358b484a2558e56fbab20e64ca4" +dependencies = [ + "dioxus-cli-config", + "dioxus-core", + "dioxus-core-macro", + "dioxus-history", + "dioxus-hooks", + "dioxus-html", + "dioxus-router-macro", + "dioxus-signals", + "percent-encoding", + "rustversion", + "tracing", + "url", +] + +[[package]] +name = "dioxus-router-macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "838b9b441a95da62b39cae4defd240b5ebb0ec9f2daea1126099e00a838dc86f" +dependencies = [ + "base16", + "digest", + "proc-macro2", + "quote", + "sha2", + "slab", + "syn 2.0.114", +] + +[[package]] +name = "dioxus-rsx" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53128858f0ccca9de54292a4d48409fda1df75fd5012c6243f664042f0225d68" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "rustversion", + "syn 2.0.114", +] + +[[package]] +name = "dioxus-signals" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f48020bc23bc9766e7cce986c0fd6de9af0b8cbfd432652ec6b1094439c1ec6" +dependencies = [ + "dioxus-core", + "futures-channel", + "futures-util", + "generational-box", + "parking_lot", + "rustc-hash 2.1.1", + "tracing", + "warnings", +] + +[[package]] +name = "dioxus-stores" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77aaa9ac56d781bb506cf3c0d23bea96b768064b89fe50d3b4d4659cc6bd8058" +dependencies = [ + "dioxus-core", + "dioxus-signals", + "dioxus-stores-macro", + "generational-box", +] + +[[package]] +name = "dioxus-stores-macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b1a728622e7b63db45774f75e71504335dd4e6115b235bbcff272980499493a" +dependencies = [ + "convert_case 0.8.0", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dioxus-web" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b33fe739fed4e8143dac222a9153593f8e2451662ce8fc4c9d167a9d6ec0923" +dependencies = [ + "dioxus-cli-config", + "dioxus-core", + "dioxus-core-types", + "dioxus-devtools", + "dioxus-document", + "dioxus-history", + "dioxus-html", + "dioxus-interpreter-js", + "dioxus-signals", + "futures-channel", + "futures-util", + "generational-box", + "gloo-timers", + "js-sys", + "lazy-js-bundle", + "rustc-hash 2.1.1", + "send_wrapper", + "serde", + "serde-wasm-bindgen", + "serde_json", + "tracing", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.10.0", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dlib" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +dependencies = [ + "libloading 0.8.9", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecb" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7" +dependencies = [ + "cipher", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enumset" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "epub" +version = "2.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95518004c0a638e03a17589d2d336b7c936d92184d81bf1e66d3b1555de89f2d" +dependencies = [ + "percent-encoding", + "regex", + "thiserror 2.0.18", + "xml-rs", + "zip", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "euclid" +version = "0.22.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" +dependencies = [ + "num-traits", + "serde", +] + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flate2" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "forwarded-header-value" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9" +dependencies = [ + "nonempty", + "thiserror 1.0.69", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generational-box" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4ed190b9de8e734d47a70be59b1e7588b9e8e0d0036e332f4c014e8aed1bc5" +dependencies = [ + "parking_lot", + "tracing", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "gif" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.10.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.0", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "global-hotkey" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2", + "objc2-app-kit", + "once_cell", + "thiserror 2.0.18", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "governor" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be93b4ec2e4710b04d9264c0c7350cdd62a8c20e5e4ac732552ebb8f0debe8eb" +dependencies = [ + "cfg-if", + "dashmap", + "futures-sink", + "futures-timer", + "futures-util", + "getrandom 0.3.4", + "no-std-compat", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand 0.9.2", + "smallvec", + "spinning_top", + "web-time", +] + +[[package]] +name = "gray_matter" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3563a3eb8bacf11a0a6d93de7885f2cca224dddff0114e4eb8053ca0f1918acd" +dependencies = [ + "serde", + "thiserror 2.0.18", + "yaml-rust2", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "html5ever" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +dependencies = [ + "log", + "mac", + "markup5ever", + "match_token", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.0", + "tiff", + "zune-core 0.5.1", + "zune-jpeg 0.5.12", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "inotify" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" +dependencies = [ + "bitflags 2.10.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "instability" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +dependencies = [ + "darling 0.23.0", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jiff" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" +dependencies = [ + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-sys 0.61.2", +] + +[[package]] +name = "jiff-static" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68971ebff725b9e2ca27a601c5eb38a4c5d64422c4cbab0c535f248087eda5c2" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kamadak-exif" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1130d80c7374efad55a117d715a3af9368f0fa7a2c54573afc15a188cd984837" +dependencies = [ + "mutate_once", +] + +[[package]] +name = "kasuari" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.10.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + +[[package]] +name = "kuchikiki" +version = "0.8.8-speedreader" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" +dependencies = [ + "cssparser", + "html5ever", + "indexmap", + "selectors", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy-js-bundle" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7b88b715ab1496c6e6b8f5e927be961c4235196121b6ae59bcb51077a21dd36" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading 0.7.4", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.10.0", + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libxdo" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00333b8756a3d28e78def82067a377de7fa61b24909000aeaa2b446a948d14db" +dependencies = [ + "libxdo-sys", +] + +[[package]] +name = "libxdo-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db23b9e7e2b7831bbd8aac0bbeeeb7b68cbebc162b227e7052e8e55829a09212" +dependencies = [ + "libc", + "x11", +] + +[[package]] +name = "line-clipping" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "lofty" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca260c51a9c71f823fbfd2e6fbc8eb2ee09834b98c00763d877ca8bfa85cde3e" +dependencies = [ + "byteorder", + "data-encoding", + "flate2", + "lofty_attr", + "log", + "ogg_pager", + "paste", +] + +[[package]] +name = "lofty_attr" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9983e64b2358522f745c1251924e3ab7252d55637e80f6a0a3de642d6a9efc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "longest-increasing-subsequence" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3bd0dd2cd90571056fdb71f6275fada10131182f84899f4b2a916e565d81d86" + +[[package]] +name = "lopdf" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f560f57dfb9142a02d673e137622fd515d4231e51feb8b4af28d92647d83f35b" +dependencies = [ + "aes", + "bitflags 2.10.0", + "cbc", + "chrono", + "ecb", + "encoding_rs", + "flate2", + "getrandom 0.3.4", + "indexmap", + "itoa", + "jiff", + "log", + "md-5", + "nom 8.0.0", + "nom_locate", + "rand 0.9.2", + "rangemap", + "rayon", + "sha2", + "stringprep", + "thiserror 2.0.18", + "time", + "ttf-parser", + "weezl", +] + +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + +[[package]] +name = "macro-string" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "manganis" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cce7d688848bf9d034168513b9a2ffbfe5f61df2ff14ae15e6cfc866efdd344" +dependencies = [ + "const-serialize 0.7.2", + "const-serialize 0.8.0-alpha.0", + "manganis-core", + "manganis-macro", +] + +[[package]] +name = "manganis-core" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84ce917b978268fe8a7db49e216343ec7c8f471f7e686feb70940d67293f19d4" +dependencies = [ + "const-serialize 0.7.2", + "const-serialize 0.8.0-alpha.0", + "dioxus-cli-config", + "dioxus-core-types", + "serde", + "winnow 0.7.14", +] + +[[package]] +name = "manganis-macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad513e990f7c0bca86aa68659a7a3dc4c705572ed4c22fd6af32ccf261334cc2" +dependencies = [ + "dunce", + "macro-string", + "manganis-core", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "markup5ever" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "matroska" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fde85cd7fb5cf875c4a46fac0cbd6567d413bea2538cef6788e3a0e52a902b45" +dependencies = [ + "bitstream-io", + "phf 0.11.3", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memfd" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix", +] + +[[package]] +name = "memmap2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +dependencies = [ + "libc", +] + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "muda" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "libxdo", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.17.16", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "mutate_once" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.1.6", + "openssl-sys", + "schannel", + "security-framework 2.11.1", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.10.0", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle 0.6.2", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom_locate" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" +dependencies = [ + "bytecount", + "memchr", + "nom 8.0.0", +] + +[[package]] +name = "nonempty" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7" + +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.10.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.10.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.10.0", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.10.0", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + +[[package]] +name = "ogg_pager" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e034c10fb5c1c012c1b327b85df89fb0ef98ae66ec28af30f0d1eed804a40c19" +dependencies = [ + "byteorder", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "pest_meta" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_shared 0.8.0", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_macros 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.2", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher 1.0.2", +] + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pinakes-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "blake3", + "chrono", + "deadpool-postgres", + "epub", + "gray_matter", + "image", + "kamadak-exif", + "lofty", + "lopdf", + "matroska", + "mime_guess", + "notify", + "postgres-types", + "refinery", + "reqwest", + "rusqlite", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-postgres", + "tokio-util", + "toml 0.9.11+spec-1.1.0", + "tracing", + "uuid", + "walkdir", + "winnow 0.7.14", +] + +[[package]] +name = "pinakes-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "argon2", + "axum", + "chrono", + "clap", + "governor", + "http-body-util", + "pinakes-core", + "rand 0.9.2", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "toml 0.9.11+spec-1.1.0", + "tower", + "tower-http", + "tower_governor", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "pinakes-tui" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "crossterm", + "ratatui", + "reqwest", + "serde", + "serde_json", + "tokio", + "toml 0.9.11+spec-1.1.0", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "pinakes-ui" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "dioxus", + "gray_matter", + "pulldown-cmark", + "reqwest", + "rfd", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags 2.10.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "postgres-derive" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56df96f5394370d1b20e49de146f9e6c25aa9ae750f449c9d665eafecb3ccae6" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "postgres-native-tls" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac73153d92e4bde922bd6f1dfba7f1ab8132266c031153b55e20a1521cd36d49" +dependencies = [ + "native-tls", + "tokio", + "tokio-native-tls", + "tokio-postgres", +] + +[[package]] +name = "postgres-protocol" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ee9dd5fe15055d2b6806f4736aa0c9637217074e224bbec46d4041b91bb9491" +dependencies = [ + "base64", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "hmac", + "md-5", + "memchr", + "rand 0.9.2", + "sha2", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b858f82211e84682fecd373f68e1ceae642d8d751a1ebd13f33de6257b3e20" +dependencies = [ + "bytes", + "chrono", + "fallible-iterator 0.2.0", + "postgres-derive", + "postgres-protocol", + "serde_core", + "serde_json", + "uuid", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8366a6159044a37876a2b9817124296703c586a5c92e2c53751fa06d8d43e8" +dependencies = [ + "toml_edit 0.20.7", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.10+spec-1.0.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "version_check", +] + +[[package]] +name = "pulldown-cmark" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" +dependencies = [ + "bitflags 2.10.0", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + +[[package]] +name = "pxfm" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi 0.11.1+wasi-snapshot-preview1", + "web-sys", + "winapi", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.1", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash 2.1.1", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + +[[package]] +name = "ratatui" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags 2.10.0", + "compact_str", + "hashbrown 0.16.1", + "indoc", + "itertools", + "kasuari", + "lru", + "strum", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.16.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "raw-window-handle" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "refinery" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c427f2572afe5c6cbfa2b1bf40071c89bf1a8539e958ea582842f6f38dcfae" +dependencies = [ + "refinery-core", + "refinery-macros", +] + +[[package]] +name = "refinery-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702655abfc67f93a6f735e9fa4ace7d2e580633f8961f28acbfd7583ddce936c" +dependencies = [ + "async-trait", + "cfg-if", + "log", + "native-tls", + "postgres-native-tls", + "regex", + "rusqlite", + "serde", + "siphasher 1.0.2", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-postgres", + "toml 0.8.23", + "url", + "walkdir", +] + +[[package]] +name = "refinery-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5145756cdf293b5089dc6b4f103f1a1229cc55d67082c866f8c8289531c4b983" +dependencies = [ + "proc-macro2", + "quote", + "refinery-core", + "regex", + "syn 2.0.114", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "reqwest" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2", + "dispatch2", + "js-sys", + "libc", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "percent-encoding", + "pollster", + "raw-window-handle 0.6.2", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags 2.10.0", + "fallible-iterator 0.3.0", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.5.1", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework 3.5.1", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" +dependencies = [ + "bitflags 1.3.2", + "cssparser", + "derive_more 0.99.20", + "fxhash", + "log", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +dependencies = [ + "futures-core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "servo_arc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "sledgehammer_bindgen" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e83e178d176459c92bc129cfd0958afac3ced925471b889b3a75546cfc4133" +dependencies = [ + "sledgehammer_bindgen_macro", + "wasm-bindgen", +] + +[[package]] +name = "sledgehammer_bindgen_macro" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb251b407f50028476a600541542b605bb864d35d9ee1de4f6cab45d88475e6d" +dependencies = [ + "quote", + "syn 2.0.114", +] + +[[package]] +name = "sledgehammer_utils" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debdd4b83524961983cea3c55383b3910fd2f24fd13a188f5b091d2d504a61ae" +dependencies = [ + "rustc-hash 1.1.0", +] + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "serde", + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "subsecond" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8438668e545834d795d04c4335aafc332ce046106521a29f0a5c6501de34187c" +dependencies = [ + "js-sys", + "libc", + "libloading 0.8.9", + "memfd", + "memmap2", + "serde", + "subsecond-types", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "subsecond-types" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e72f747606fc19fe81d6c59e491af93ed7dcbcb6aad9d1d18b05129914ec298" +dependencies = [ + "serde", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.23", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.34.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" +dependencies = [ + "bitflags 2.10.0", + "block2", + "core-foundation 0.10.1", + "core-graphics", + "crossbeam-channel", + "dispatch", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "lazy_static", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "once_cell", + "parking_lot", + "raw-window-handle 0.5.2", + "raw-window-handle 0.6.2", + "scopeguard", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom 7.1.3", + "phf 0.11.3", + "phf_codegen 0.11.3", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.10.0", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf 0.11.3", + "sha2", + "signal-hook", + "siphasher 1.0.2", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg 0.4.21", +] + +[[package]] +name = "time" +version = "0.3.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" +dependencies = [ + "deranged", + "itoa", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-postgres" +version = "0.7.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcea47c8f71744367793f16c2db1f11cb859d28f436bdb4ca9193eb1f787ee42" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf 0.13.1", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.9.2", + "socket2", + "tokio", + "tokio-util", + "whoami", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "0.9.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.0.4", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.14", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" +dependencies = [ + "indexmap", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.14", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow 0.7.14", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow 0.7.14", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tower_governor" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a2ccff6830fa835371af7541e561a90e4c07b84f72991ebac4b3cb6790dc0d" +dependencies = [ + "axum", + "forwarded-header-value", + "governor", + "http", + "pin-project", + "thiserror 2.0.18", + "tower", + "tracing", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "tracing-wasm" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4575c663a174420fa2d78f4108ff68f65bf2fbb7dd89f33749b6e826b3626e07" +dependencies = [ + "tracing", + "tracing-subscriber", + "wasm-bindgen", +] + +[[package]] +name = "tray-icon" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.17.16", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand 0.9.2", + "rustls", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "atomic", + "getrandom 0.3.4", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "warnings" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64f68998838dab65727c9b30465595c6f7c953313559371ca8bf31759b3680ad" +dependencies = [ + "pin-project", + "tracing", + "warnings-macro", +] + +[[package]] +name = "warnings-macro" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59195a1db0e95b920366d949ba5e0d3fc0e70b67c09be15ce5abb790106b0571" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.114", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wayland-backend" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" +dependencies = [ + "bitflags 2.10.0", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f1243ef785213e3a32fa0396093424a3a6ea566f9948497e5a2309261a4c97" +dependencies = [ + "core-foundation 0.10.1", + "jni", + "log", + "ndk-context", + "objc2", + "objc2-foundation", + "url", + "web-sys", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "whoami" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fae98cf96deed1b7572272dfc777713c249ae40aa1cf8862e091e8b745f5361" +dependencies = [ + "libredox", + "wasite", + "web-sys", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wry" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728b7d4c8ec8d81cab295e0b5b8a4c263c0d41a785fb8f8c4df284e5411140a2" +dependencies = [ + "base64", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dpi", + "dunce", + "gtk", + "html5ever", + "http", + "javascriptcore-rs", + "jni", + "kuchikiki", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle 0.6.2", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8aa498d22c9bbaf482329839bc5620c46be275a19a812e9a22a2b07529a642a" + +[[package]] +name = "xml-rs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3a56132a0d6ecbe77352edc10232f788fc4ceefefff4cab784a98e0e16b6b51" +dependencies = [ + "xml", +] + +[[package]] +name = "yaml-rust2" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2462ea039c445496d8793d052e13787f2b90e750b833afee748e601c17621ed9" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7456cf00f0685ad319c5b1693f291a650eaf345e941d082fc4e03df8a03996ac" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1328722bbf2115db7e19d69ebcc15e795719e2d66b60827c6a69a117365e37a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zip" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" + +[[package]] +name = "zmij" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core 0.4.12", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" +dependencies = [ + "zune-core 0.5.1", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..afd2b1b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,100 @@ +[workspace] +members = [ + "crates/pinakes-core", + "crates/pinakes-server", + "crates/pinakes-tui", + "crates/pinakes-ui", +] +resolver = "3" + +[workspace.package] +edition = "2024" +version = "0.1.0" +license = "MIT" + +[workspace.dependencies] +# Async runtime +tokio = { version = "1", features = ["full"] } + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.9" + +# CLI argument parsing +clap = { version = "4", features = ["derive", "env"] } + +# Date/time +chrono = { version = "0.4", features = ["serde"] } + +# IDs +uuid = { version = "1", features = ["v7", "serde"] } + +# Error handling +thiserror = "2" +anyhow = "1" + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } + +# Hashing +blake3 = "1" + +# Metadata extraction +lofty = "0.22" +lopdf = "0.39" +epub = "2" +matroska = "0.30" +gray_matter = "0.3" +kamadak-exif = "0.6" + +# Database - SQLite +rusqlite = { version = "0.37", features = ["bundled", "column_decltype"] } + +# Database - PostgreSQL +tokio-postgres = { version = "0.7", features = ["with-uuid-1", "with-chrono-0_4", "with-serde_json-1"] } +deadpool-postgres = "0.14" +postgres-types = { version = "0.2", features = ["derive"] } + +# Migrations +refinery = { version = "0.9", features = ["rusqlite", "tokio-postgres"] } + +# Filesystem +walkdir = "2" +notify = { version = "8", features = ["macos_fsevent"] } + +# Search parser +winnow = "0.7" + +# HTTP server +axum = { version = "0.8", features = ["macros"] } +tower = "0.5" +tower-http = { version = "0.6", features = ["cors", "trace"] } +governor = "0.8" +tower_governor = "0.6" + +# HTTP client +reqwest = { version = "0.13", features = ["json", "query"] } + +# TUI +ratatui = "0.30" +crossterm = "0.29" + +# Desktop/Web UI +dioxus = { version = "0.7", features = ["desktop", "router"] } + +# Async trait (dyn-compatible async methods) +async-trait = "0.1" + +# Image processing (thumbnails) +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp"] } + +# Markdown rendering +pulldown-cmark = "0.12" + +# Password hashing +argon2 = "0.5" + +# Misc +mime_guess = "2" diff --git a/README.md b/README.md new file mode 100644 index 0000000..bedc45b --- /dev/null +++ b/README.md @@ -0,0 +1,193 @@ +# Pinakes + +A media cataloging and library management system written in Rust. Pinakes +indexes files across configured directories, extracts metadata from audio, +video, document, and text files, and provides full-text search with tagging, +collections, and audit logging. It supports both SQLite and PostgreSQL backends. + +## Building + +```sh +# Build all compilable crates +cargo build -p pinakes-core -p pinakes-server -p pinakes-tui + +# The Dioxus UI requires GTK3 and libsoup system libraries: +# On Debian/Ubuntu: apt install libgtk-3-dev libsoup-3.0-dev libwebkit2gtk-4.1-dev +# On Fedora: dnf install gtk3-devel libsoup3-devel webkit2gtk4.1-devel +# On Nix: Use the dev shell, everything is provided :) +cargo build -p pinakes-ui +``` + +## Configuration + +Copy the example config and edit it: + +```sh +cp pinakes.toml.example pinakes.toml +``` + +Key settings: + +- `storage.backend` -- `"sqlite"` or `"postgres"` +- `storage.sqlite.path` -- Path to the SQLite database file +- `storage.postgres.*` -- PostgreSQL connection parameters +- `directories.roots` -- Directories to scan for media files +- `scanning.watch` -- Enable filesystem watching for automatic imports +- `scanning.ignore_patterns` -- Patterns to skip during scanning (e.g., `".*"`, + `"node_modules"`) +- `server.host` / `server.port` -- Server bind address + +## Running + +### Server + +```sh +cargo run -p pinakes-server -- pinakes.toml +# or +cargo run -p pinakes-server -- --config pinakes.toml +``` + +The server starts on the configured host:port (default `127.0.0.1:3000`). + +### TUI + +```sh +cargo run -p pinakes-tui +# or with a custom server URL: +cargo run -p pinakes-tui -- --server http://localhost:3000 +``` + +Keybindings: + + + +| Key | Action | +| --------------------- | -------------------------------------------------------- | +| `q` / `Ctrl-C` | Quit | +| `j` / `k` | Navigate down / up | +| `Enter` | Select / confirm | +| `Esc` | Back | +| `/` | Search | +| `i` | Import file | +| `o` | Open file | +| `d` | Delete (media in library, tag/collection in their views) | +| `t` | Tags view | +| `c` | Collections view | +| `a` | Audit log view | +| `s` | Trigger scan | +| `r` | Refresh current view | +| `n` | Create new tag (in tags view) | +| `+` | Tag selected media (in detail view) | +| `-` | Untag selected media (in detail view) | +| `Tab` / `Shift-Tab` | Next / previous tab | +| `PageUp` / `PageDown` | Paginate | + + + +### Desktop/Web UI + +```sh +cargo run -p pinakes-ui +``` + +Set `PINAKES_SERVER_URL` to point at the server if it is not on +`localhost:3000`. + +## API + +All endpoints are under `/api/v1`. + +### Media + +| Method | Path | Description | +| -------- | -------------------- | ------------------------------------- | +| `POST` | `/media/import` | Import a file (`{"path": "..."}`) | +| `GET` | `/media` | List media (query: `offset`, `limit`) | +| `GET` | `/media/{id}` | Get media item | +| `PATCH` | `/media/{id}` | Update metadata | +| `DELETE` | `/media/{id}` | Delete media item | +| `GET` | `/media/{id}/stream` | Stream file content | +| `POST` | `/media/{id}/open` | Open with system viewer | + +### Search + +| Method | Path | Description | +| ------ | --------------- | ---------------------------------------------- | +| `GET` | `/search?q=...` | Search (query: `q`, `sort`, `offset`, `limit`) | + +Search syntax: `term`, `"exact phrase"`, `field:value`, `type:pdf`, `tag:music`, +`prefix*`, `fuzzy~`, `-excluded`, `a b` (AND), `a OR b`, `(grouped)`. + +### Tags + + + +| Method | Path | Description | +| -------- | --------------------------- | ------------------------------------------------ | +| `POST` | `/tags` | Create tag (`{"name": "...", "parent_id": ...}`) | +| `GET` | `/tags` | List all tags | +| `GET` | `/tags/{id}` | Get tag | +| `DELETE` | `/tags/{id}` | Delete tag | +| `POST` | `/media/{id}/tags` | Tag media (`{"tag_id": "..."}`) | +| `GET` | `/media/{id}/tags` | List media's tags | +| `DELETE` | `/media/{id}/tags/{tag_id}` | Untag media | + + + +### Collections + +| Method | Path | Description | +| -------- | ---------------------------------- | ----------------- | +| `POST` | `/collections` | Create collection | +| `GET` | `/collections` | List collections | +| `GET` | `/collections/{id}` | Get collection | +| `DELETE` | `/collections/{id}` | Delete collection | +| `POST` | `/collections/{id}/members` | Add member | +| `GET` | `/collections/{id}/members` | List members | +| `DELETE` | `/collections/{cid}/members/{mid}` | Remove member | + +Virtual collections (kind `"virtual"`) evaluate their `filter_query` as a search +query when listing members, returning results dynamically. + +### Audit & Scanning + + + +| Method | Path | Description | +| ------ | -------- | ----------------------------------------------------------------------------- | +| `GET` | `/audit` | List audit log (query: `offset`, `limit`) | +| `POST` | `/scan` | Trigger directory scan (`{"path": "/..."}` or `{"path": null}` for all roots) | + + + +## Testing + +```sh +# Unit and integration tests for the core library (SQLite in-memory) +cargo test -p pinakes-core + +# API integration tests for the server +cargo test -p pinakes-server +``` + +## Supported Media Types + +| Category | Formats | +| -------- | ------------------------------- | +| Audio | MP3, FLAC, OGG, WAV, AAC, Opus | +| Video | MP4, MKV, AVI, WebM | +| Document | PDF, EPUB, DjVu | +| Text | Markdown, Plain text | +| Image | JPEG, PNG, GIF, WebP, SVG, AVIF | + +Metadata extraction uses lofty (audio, MP4), matroska (MKV), lopdf (PDF), epub +(EPUB), and gray_matter (Markdown frontmatter). + +## Storage Backends + +**SQLite** (default) -- Single-file database with WAL mode and FTS5 full-text +search. Bundled SQLite guarantees FTS5 availability. + +**PostgreSQL** -- Native async with connection pooling (deadpool-postgres). Uses +tsvector with weighted columns for full-text search and pg_trgm for fuzzy +matching. Requires the `pg_trgm` extension. diff --git a/crates/pinakes-core/Cargo.toml b/crates/pinakes-core/Cargo.toml new file mode 100644 index 0000000..60c5764 --- /dev/null +++ b/crates/pinakes-core/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "pinakes-core" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } +chrono = { workspace = true } +uuid = { workspace = true } +thiserror = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } +blake3 = { workspace = true } +lofty = { workspace = true } +lopdf = { workspace = true } +epub = { workspace = true } +matroska = { workspace = true } +gray_matter = { workspace = true } +rusqlite = { workspace = true } +tokio-postgres = { workspace = true } +deadpool-postgres = { workspace = true } +postgres-types = { workspace = true } +refinery = { workspace = true } +walkdir = { workspace = true } +notify = { workspace = true } +winnow = { workspace = true } +mime_guess = { workspace = true } +async-trait = { workspace = true } +kamadak-exif = { workspace = true } +image = { workspace = true } +tokio-util = { version = "0.7", features = ["rt"] } +reqwest = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/pinakes-core/src/audit.rs b/crates/pinakes-core/src/audit.rs new file mode 100644 index 0000000..6fe0f7d --- /dev/null +++ b/crates/pinakes-core/src/audit.rs @@ -0,0 +1,21 @@ +use uuid::Uuid; + +use crate::error::Result; +use crate::model::{AuditAction, AuditEntry, MediaId}; +use crate::storage::DynStorageBackend; + +pub async fn record_action( + storage: &DynStorageBackend, + media_id: Option, + action: AuditAction, + details: Option, +) -> Result<()> { + let entry = AuditEntry { + id: Uuid::now_v7(), + media_id, + action, + details, + timestamp: chrono::Utc::now(), + }; + storage.record_audit(&entry).await +} diff --git a/crates/pinakes-core/src/cache.rs b/crates/pinakes-core/src/cache.rs new file mode 100644 index 0000000..cff0d30 --- /dev/null +++ b/crates/pinakes-core/src/cache.rs @@ -0,0 +1,91 @@ +use std::collections::HashMap; +use std::hash::Hash; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use tokio::sync::RwLock; + +struct CacheEntry { + value: V, + inserted_at: Instant, +} + +/// A simple TTL-based in-memory cache with periodic eviction. +pub struct Cache { + entries: Arc>>>, + ttl: Duration, +} + +impl Cache +where + K: Eq + Hash + Clone + Send + Sync + 'static, + V: Clone + Send + Sync + 'static, +{ + pub fn new(ttl: Duration) -> Self { + let cache = Self { + entries: Arc::new(RwLock::new(HashMap::new())), + ttl, + }; + + // Spawn periodic eviction task + let entries = cache.entries.clone(); + let ttl = cache.ttl; + tokio::spawn(async move { + let mut interval = tokio::time::interval(ttl); + loop { + interval.tick().await; + let now = Instant::now(); + let mut map = entries.write().await; + map.retain(|_, entry| now.duration_since(entry.inserted_at) < ttl); + } + }); + + cache + } + + pub async fn get(&self, key: &K) -> Option { + let map = self.entries.read().await; + if let Some(entry) = map.get(key) { + if entry.inserted_at.elapsed() < self.ttl { + return Some(entry.value.clone()); + } + } + None + } + + pub async fn insert(&self, key: K, value: V) { + let mut map = self.entries.write().await; + map.insert( + key, + CacheEntry { + value, + inserted_at: Instant::now(), + }, + ); + } + + pub async fn invalidate(&self, key: &K) { + let mut map = self.entries.write().await; + map.remove(key); + } + + pub async fn invalidate_all(&self) { + let mut map = self.entries.write().await; + map.clear(); + } +} + +/// Application-level cache layer wrapping multiple caches for different data types. +pub struct CacheLayer { + /// Cache for serialized API responses, keyed by request path + query string. + pub responses: Cache, +} + +impl CacheLayer { + pub fn new(ttl_secs: u64) -> Self { + let ttl = Duration::from_secs(ttl_secs); + Self { + responses: Cache::new(ttl), + } + } +} diff --git a/crates/pinakes-core/src/collections.rs b/crates/pinakes-core/src/collections.rs new file mode 100644 index 0000000..bab7e70 --- /dev/null +++ b/crates/pinakes-core/src/collections.rs @@ -0,0 +1,78 @@ +use uuid::Uuid; + +use crate::error::Result; +use crate::model::*; +use crate::storage::DynStorageBackend; + +pub async fn create_collection( + storage: &DynStorageBackend, + name: &str, + kind: CollectionKind, + description: Option<&str>, + filter_query: Option<&str>, +) -> Result { + storage + .create_collection(name, kind, description, filter_query) + .await +} + +pub async fn add_member( + storage: &DynStorageBackend, + collection_id: Uuid, + media_id: MediaId, + position: i32, +) -> Result<()> { + storage + .add_to_collection(collection_id, media_id, position) + .await?; + crate::audit::record_action( + storage, + Some(media_id), + AuditAction::AddedToCollection, + Some(format!("collection_id={collection_id}")), + ) + .await +} + +pub async fn remove_member( + storage: &DynStorageBackend, + collection_id: Uuid, + media_id: MediaId, +) -> Result<()> { + storage + .remove_from_collection(collection_id, media_id) + .await?; + crate::audit::record_action( + storage, + Some(media_id), + AuditAction::RemovedFromCollection, + Some(format!("collection_id={collection_id}")), + ) + .await +} + +pub async fn get_members( + storage: &DynStorageBackend, + collection_id: Uuid, +) -> Result> { + let collection = storage.get_collection(collection_id).await?; + + match collection.kind { + CollectionKind::Virtual => { + // Virtual collections evaluate their filter_query dynamically + if let Some(ref query_str) = collection.filter_query { + let query = crate::search::parse_search_query(query_str)?; + let request = crate::search::SearchRequest { + query, + sort: crate::search::SortOrder::DateDesc, + pagination: Pagination::new(0, 10000, None), + }; + let results = storage.search(&request).await?; + Ok(results.items) + } else { + Ok(Vec::new()) + } + } + CollectionKind::Manual => storage.get_collection_members(collection_id).await, + } +} diff --git a/crates/pinakes-core/src/config.rs b/crates/pinakes-core/src/config.rs new file mode 100644 index 0000000..3d3d6b9 --- /dev/null +++ b/crates/pinakes-core/src/config.rs @@ -0,0 +1,437 @@ +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub storage: StorageConfig, + pub directories: DirectoryConfig, + pub scanning: ScanningConfig, + pub server: ServerConfig, + #[serde(default)] + pub ui: UiConfig, + #[serde(default)] + pub accounts: AccountsConfig, + #[serde(default)] + pub jobs: JobsConfig, + #[serde(default)] + pub thumbnails: ThumbnailConfig, + #[serde(default)] + pub webhooks: Vec, + #[serde(default)] + pub scheduled_tasks: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScheduledTaskConfig { + pub id: String, + pub enabled: bool, + pub schedule: crate::scheduler::Schedule, + pub last_run: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JobsConfig { + #[serde(default = "default_worker_count")] + pub worker_count: usize, + #[serde(default = "default_cache_ttl")] + pub cache_ttl_secs: u64, +} + +fn default_worker_count() -> usize { + 2 +} +fn default_cache_ttl() -> u64 { + 60 +} + +impl Default for JobsConfig { + fn default() -> Self { + Self { + worker_count: default_worker_count(), + cache_ttl_secs: default_cache_ttl(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ThumbnailConfig { + #[serde(default = "default_thumb_size")] + pub size: u32, + #[serde(default = "default_thumb_quality")] + pub quality: u8, + #[serde(default)] + pub ffmpeg_path: Option, + #[serde(default = "default_video_seek")] + pub video_seek_secs: u32, +} + +fn default_thumb_size() -> u32 { + 320 +} +fn default_thumb_quality() -> u8 { + 80 +} +fn default_video_seek() -> u32 { + 2 +} + +impl Default for ThumbnailConfig { + fn default() -> Self { + Self { + size: default_thumb_size(), + quality: default_thumb_quality(), + ffmpeg_path: None, + video_seek_secs: default_video_seek(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebhookConfig { + pub url: String, + pub events: Vec, + #[serde(default)] + pub secret: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UiConfig { + #[serde(default = "default_theme")] + pub theme: String, + #[serde(default = "default_view")] + pub default_view: String, + #[serde(default = "default_page_size")] + pub default_page_size: usize, + #[serde(default = "default_view_mode")] + pub default_view_mode: String, + #[serde(default)] + pub auto_play_media: bool, + #[serde(default = "default_true")] + pub show_thumbnails: bool, + #[serde(default)] + pub sidebar_collapsed: bool, +} + +fn default_theme() -> String { + "dark".to_string() +} +fn default_view() -> String { + "library".to_string() +} +fn default_page_size() -> usize { + 48 +} +fn default_view_mode() -> String { + "grid".to_string() +} +fn default_true() -> bool { + true +} + +impl Default for UiConfig { + fn default() -> Self { + Self { + theme: default_theme(), + default_view: default_view(), + default_page_size: default_page_size(), + default_view_mode: default_view_mode(), + auto_play_media: false, + show_thumbnails: true, + sidebar_collapsed: false, + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AccountsConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub users: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserAccount { + pub username: String, + pub password_hash: String, + #[serde(default)] + pub role: UserRole, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum UserRole { + Admin, + Editor, + #[default] + Viewer, +} + +impl UserRole { + pub fn can_read(self) -> bool { + true + } + + pub fn can_write(self) -> bool { + matches!(self, Self::Admin | Self::Editor) + } + + pub fn can_admin(self) -> bool { + matches!(self, Self::Admin) + } +} + +impl std::fmt::Display for UserRole { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Admin => write!(f, "admin"), + Self::Editor => write!(f, "editor"), + Self::Viewer => write!(f, "viewer"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StorageConfig { + pub backend: StorageBackendType, + pub sqlite: Option, + pub postgres: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum StorageBackendType { + Sqlite, + Postgres, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SqliteConfig { + pub path: PathBuf, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PostgresConfig { + pub host: String, + pub port: u16, + pub database: String, + pub username: String, + pub password: String, + pub max_connections: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DirectoryConfig { + pub roots: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScanningConfig { + pub watch: bool, + pub poll_interval_secs: u64, + pub ignore_patterns: Vec, + #[serde(default = "default_import_concurrency")] + pub import_concurrency: usize, +} + +fn default_import_concurrency() -> usize { + 8 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerConfig { + pub host: String, + pub port: u16, + /// Optional API key for bearer token authentication. + /// If set, all requests (except /health) must include `Authorization: Bearer `. + /// Can also be set via `PINAKES_API_KEY` environment variable. + pub api_key: Option, +} + +impl Config { + pub fn from_file(path: &Path) -> crate::error::Result { + let content = std::fs::read_to_string(path).map_err(|e| { + crate::error::PinakesError::Config(format!("failed to read config file: {e}")) + })?; + toml::from_str(&content) + .map_err(|e| crate::error::PinakesError::Config(format!("failed to parse config: {e}"))) + } + + /// Try loading from file, falling back to defaults if the file doesn't exist. + pub fn load_or_default(path: &Path) -> crate::error::Result { + if path.exists() { + Self::from_file(path) + } else { + let config = Self::default(); + // Ensure the data directory exists for the default SQLite database + config.ensure_dirs()?; + Ok(config) + } + } + + /// Save the current config to a TOML file. + pub fn save_to_file(&self, path: &Path) -> crate::error::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let content = toml::to_string_pretty(self).map_err(|e| { + crate::error::PinakesError::Config(format!("failed to serialize config: {e}")) + })?; + std::fs::write(path, content)?; + Ok(()) + } + + /// Ensure all directories needed by this config exist and are writable. + pub fn ensure_dirs(&self) -> crate::error::Result<()> { + if let Some(ref sqlite) = self.storage.sqlite + && let Some(parent) = sqlite.path.parent() + { + std::fs::create_dir_all(parent)?; + let metadata = std::fs::metadata(parent)?; + if metadata.permissions().readonly() { + return Err(crate::error::PinakesError::Config(format!( + "directory is not writable: {}", + parent.display() + ))); + } + } + Ok(()) + } + + /// Returns the default config file path following XDG conventions. + pub fn default_config_path() -> PathBuf { + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + PathBuf::from(xdg).join("pinakes").join("pinakes.toml") + } else if let Ok(home) = std::env::var("HOME") { + PathBuf::from(home) + .join(".config") + .join("pinakes") + .join("pinakes.toml") + } else { + PathBuf::from("pinakes.toml") + } + } + + /// Validate configuration values for correctness. + pub fn validate(&self) -> Result<(), String> { + if self.server.port == 0 { + return Err("server port cannot be 0".into()); + } + if self.server.host.is_empty() { + return Err("server host cannot be empty".into()); + } + if self.scanning.poll_interval_secs == 0 { + return Err("poll interval cannot be 0".into()); + } + if self.scanning.import_concurrency == 0 || self.scanning.import_concurrency > 256 { + return Err("import_concurrency must be between 1 and 256".into()); + } + Ok(()) + } + + /// Returns the default data directory following XDG conventions. + pub fn default_data_dir() -> PathBuf { + if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { + PathBuf::from(xdg).join("pinakes") + } else if let Ok(home) = std::env::var("HOME") { + PathBuf::from(home) + .join(".local") + .join("share") + .join("pinakes") + } else { + PathBuf::from("pinakes-data") + } + } +} + +impl Default for Config { + fn default() -> Self { + let data_dir = Self::default_data_dir(); + Self { + storage: StorageConfig { + backend: StorageBackendType::Sqlite, + sqlite: Some(SqliteConfig { + path: data_dir.join("pinakes.db"), + }), + postgres: None, + }, + directories: DirectoryConfig { roots: vec![] }, + scanning: ScanningConfig { + watch: false, + poll_interval_secs: 300, + ignore_patterns: vec![ + ".*".to_string(), + "node_modules".to_string(), + "__pycache__".to_string(), + "target".to_string(), + ], + import_concurrency: default_import_concurrency(), + }, + server: ServerConfig { + host: "127.0.0.1".to_string(), + port: 3000, + api_key: None, + }, + ui: UiConfig::default(), + accounts: AccountsConfig::default(), + jobs: JobsConfig::default(), + thumbnails: ThumbnailConfig::default(), + webhooks: vec![], + scheduled_tasks: vec![], + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_config_with_concurrency(concurrency: usize) -> Config { + let mut config = Config::default(); + config.scanning.import_concurrency = concurrency; + config + } + + #[test] + fn test_validate_import_concurrency_zero() { + let config = test_config_with_concurrency(0); + assert!(config.validate().is_err()); + assert!( + config + .validate() + .unwrap_err() + .contains("import_concurrency") + ); + } + + #[test] + fn test_validate_import_concurrency_too_high() { + let config = test_config_with_concurrency(257); + assert!(config.validate().is_err()); + assert!( + config + .validate() + .unwrap_err() + .contains("import_concurrency") + ); + } + + #[test] + fn test_validate_import_concurrency_valid() { + let config = test_config_with_concurrency(8); + assert!(config.validate().is_ok()); + } + + #[test] + fn test_validate_import_concurrency_boundary_low() { + let config = test_config_with_concurrency(1); + assert!(config.validate().is_ok()); + } + + #[test] + fn test_validate_import_concurrency_boundary_high() { + let config = test_config_with_concurrency(256); + assert!(config.validate().is_ok()); + } +} diff --git a/crates/pinakes-core/src/error.rs b/crates/pinakes-core/src/error.rs new file mode 100644 index 0000000..0ad2ccc --- /dev/null +++ b/crates/pinakes-core/src/error.rs @@ -0,0 +1,59 @@ +use std::path::PathBuf; + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum PinakesError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("database error: {0}")] + Database(String), + + #[error("migration error: {0}")] + Migration(String), + + #[error("configuration error: {0}")] + Config(String), + + #[error("media item not found: {0}")] + NotFound(String), + + #[error("duplicate content hash: {0}")] + DuplicateHash(String), + + #[error("unsupported media type for path: {0}")] + UnsupportedMediaType(PathBuf), + + #[error("metadata extraction failed: {0}")] + MetadataExtraction(String), + + #[error("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), +} + +impl From for PinakesError { + fn from(e: rusqlite::Error) -> Self { + PinakesError::Database(e.to_string()) + } +} + +impl From for PinakesError { + fn from(e: tokio_postgres::Error) -> Self { + PinakesError::Database(e.to_string()) + } +} + +pub type Result = std::result::Result; diff --git a/crates/pinakes-core/src/events.rs b/crates/pinakes-core/src/events.rs new file mode 100644 index 0000000..d0d72ca --- /dev/null +++ b/crates/pinakes-core/src/events.rs @@ -0,0 +1,106 @@ +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use tokio::sync::broadcast; +use tracing::warn; + +use crate::config::WebhookConfig; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PinakesEvent { + MediaImported { + media_id: String, + }, + MediaUpdated { + media_id: String, + }, + MediaDeleted { + media_id: String, + }, + ScanCompleted { + files_found: usize, + files_processed: usize, + }, + IntegrityMismatch { + media_id: String, + expected: String, + actual: String, + }, +} + +impl PinakesEvent { + pub fn event_name(&self) -> &'static str { + match self { + Self::MediaImported { .. } => "media_imported", + Self::MediaUpdated { .. } => "media_updated", + Self::MediaDeleted { .. } => "media_deleted", + Self::ScanCompleted { .. } => "scan_completed", + Self::IntegrityMismatch { .. } => "integrity_mismatch", + } + } +} + +pub struct EventBus { + tx: broadcast::Sender, +} + +impl EventBus { + pub fn new(webhooks: Vec) -> Arc { + let (tx, _) = broadcast::channel(256); + + // Spawn webhook delivery task + if !webhooks.is_empty() { + let mut rx: broadcast::Receiver = tx.subscribe(); + let webhooks = Arc::new(webhooks); + tokio::spawn(async move { + while let Ok(event) = rx.recv().await { + let event_name = event.event_name(); + for hook in webhooks.iter() { + if hook.events.iter().any(|e| e == event_name || e == "*") { + let url = hook.url.clone(); + let event_clone = event.clone(); + let secret = hook.secret.clone(); + tokio::spawn(async move { + deliver_webhook(&url, &event_clone, secret.as_deref()).await; + }); + } + } + } + }); + } + + Arc::new(Self { tx }) + } + + pub fn emit(&self, event: PinakesEvent) { + // Ignore send errors (no receivers) + let _ = self.tx.send(event); + } +} + +async fn deliver_webhook(url: &str, event: &PinakesEvent, _secret: Option<&str>) { + let client = reqwest::Client::new(); + let body = serde_json::to_string(event).unwrap_or_default(); + + for attempt in 0..3 { + match client + .post(url) + .header("Content-Type", "application/json") + .body(body.clone()) + .send() + .await + { + Ok(resp) if resp.status().is_success() => return, + Ok(resp) => { + warn!(url, status = %resp.status(), attempt, "webhook delivery failed"); + } + Err(e) => { + warn!(url, error = %e, attempt, "webhook delivery error"); + } + } + + // Exponential backoff + tokio::time::sleep(std::time::Duration::from_secs(1 << attempt)).await; + } +} diff --git a/crates/pinakes-core/src/export.rs b/crates/pinakes-core/src/export.rs new file mode 100644 index 0000000..9e2feac --- /dev/null +++ b/crates/pinakes-core/src/export.rs @@ -0,0 +1,68 @@ +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::error::Result; +use crate::jobs::ExportFormat; +use crate::storage::DynStorageBackend; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExportResult { + pub items_exported: usize, + pub output_path: String, +} + +/// Export library data to the specified format. +pub async fn export_library( + storage: &DynStorageBackend, + format: &ExportFormat, + destination: &Path, +) -> Result { + let pagination = crate::model::Pagination { + offset: 0, + limit: u64::MAX, + sort: None, + }; + let items = storage.list_media(&&pagination).await?; + let count = items.len(); + + match format { + ExportFormat::Json => { + let json = serde_json::to_string_pretty(&items) + .map_err(|e| crate::error::PinakesError::Config(format!("json serialize: {e}")))?; + std::fs::write(destination, json)?; + } + ExportFormat::Csv => { + let mut csv = String::new(); + csv.push_str("id,path,file_name,media_type,content_hash,file_size,title,artist,album,genre,year,duration_secs,description,created_at,updated_at\n"); + for item in &items { + csv.push_str(&format!( + "{},{},{},{:?},{},{},{},{},{},{},{},{},{},{},{}\n", + item.id, + item.path.display(), + item.file_name, + item.media_type, + item.content_hash, + item.file_size, + item.title.as_deref().unwrap_or(""), + item.artist.as_deref().unwrap_or(""), + item.album.as_deref().unwrap_or(""), + item.genre.as_deref().unwrap_or(""), + item.year.map(|y| y.to_string()).unwrap_or_default(), + item.duration_secs + .map(|d| d.to_string()) + .unwrap_or_default(), + item.description.as_deref().unwrap_or(""), + item.created_at, + item.updated_at, + )); + } + std::fs::write(destination, csv)?; + } + } + + Ok(ExportResult { + items_exported: count, + output_path: destination.to_string_lossy().to_string(), + }) +} diff --git a/crates/pinakes-core/src/hash.rs b/crates/pinakes-core/src/hash.rs new file mode 100644 index 0000000..8435084 --- /dev/null +++ b/crates/pinakes-core/src/hash.rs @@ -0,0 +1,31 @@ +use std::path::Path; + +use crate::error::Result; +use crate::model::ContentHash; + +const BUFFER_SIZE: usize = 65536; + +pub async fn compute_file_hash(path: &Path) -> Result { + let path = path.to_path_buf(); + let hash = tokio::task::spawn_blocking(move || -> Result { + let mut hasher = blake3::Hasher::new(); + let mut file = std::fs::File::open(&path)?; + let mut buf = vec![0u8; BUFFER_SIZE]; + loop { + let n = std::io::Read::read(&mut file, &mut buf)?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + Ok(ContentHash::new(hasher.finalize().to_hex().to_string())) + }) + .await + .map_err(|e| crate::error::PinakesError::Io(std::io::Error::other(e)))??; + Ok(hash) +} + +pub fn compute_hash_sync(data: &[u8]) -> ContentHash { + let hash = blake3::hash(data); + ContentHash::new(hash.to_hex().to_string()) +} diff --git a/crates/pinakes-core/src/import.rs b/crates/pinakes-core/src/import.rs new file mode 100644 index 0000000..ff29801 --- /dev/null +++ b/crates/pinakes-core/src/import.rs @@ -0,0 +1,250 @@ +use std::path::{Path, PathBuf}; + +use tracing::info; + +use crate::audit; +use crate::error::{PinakesError, Result}; +use crate::hash::compute_file_hash; +use crate::media_type::MediaType; +use crate::metadata; +use crate::model::*; +use crate::storage::DynStorageBackend; +use crate::thumbnail; + +pub struct ImportResult { + pub media_id: MediaId, + pub was_duplicate: bool, + pub path: PathBuf, +} + +/// Check that a canonicalized path falls under at least one configured root directory. +/// If no roots are configured, all paths are allowed (for ad-hoc imports). +pub async fn validate_path_in_roots(storage: &DynStorageBackend, path: &Path) -> Result<()> { + let roots = storage.list_root_dirs().await?; + if roots.is_empty() { + return Ok(()); + } + for root in &roots { + if let Ok(canonical_root) = root.canonicalize() + && path.starts_with(&canonical_root) + { + return Ok(()); + } + } + Err(PinakesError::InvalidOperation(format!( + "path {} is not within any configured root directory", + path.display() + ))) +} + +pub async fn import_file(storage: &DynStorageBackend, path: &Path) -> Result { + let path = path.canonicalize()?; + + if !path.exists() { + return Err(PinakesError::FileNotFound(path)); + } + + validate_path_in_roots(storage, &path).await?; + + let media_type = MediaType::from_path(&path) + .ok_or_else(|| PinakesError::UnsupportedMediaType(path.clone()))?; + + let content_hash = compute_file_hash(&path).await?; + + if let Some(existing) = storage.get_media_by_hash(&content_hash).await? { + return Ok(ImportResult { + media_id: existing.id, + was_duplicate: true, + path: path.clone(), + }); + } + + let file_meta = std::fs::metadata(&path)?; + let file_size = file_meta.len(); + + let extracted = { + let path_clone = path.clone(); + tokio::task::spawn_blocking(move || metadata::extract_metadata(&path_clone, media_type)) + .await + .map_err(|e| PinakesError::MetadataExtraction(e.to_string()))?? + }; + + let file_name = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + + let now = chrono::Utc::now(); + let media_id = MediaId::new(); + + // Generate thumbnail for image types + let thumb_path = { + let source = path.clone(); + let thumb_dir = thumbnail::default_thumbnail_dir(); + tokio::task::spawn_blocking(move || { + thumbnail::generate_thumbnail(media_id, &source, media_type, &thumb_dir) + }) + .await + .map_err(|e| PinakesError::MetadataExtraction(e.to_string()))?? + }; + + let item = MediaItem { + id: media_id, + path: path.clone(), + file_name, + media_type, + content_hash, + file_size, + title: extracted.title, + artist: extracted.artist, + album: extracted.album, + genre: extracted.genre, + year: extracted.year, + duration_secs: extracted.duration_secs, + description: extracted.description, + thumbnail_path: thumb_path, + custom_fields: std::collections::HashMap::new(), + created_at: now, + updated_at: now, + }; + + storage.insert_media(&item).await?; + + // Store extracted extra metadata as custom fields + for (key, value) in &extracted.extra { + let field = CustomField { + field_type: CustomFieldType::Text, + value: value.clone(), + }; + if let Err(e) = storage.set_custom_field(media_id, key, &field).await { + tracing::warn!( + media_id = %media_id, + field = %key, + error = %e, + "failed to store extracted metadata as custom field" + ); + } + } + + audit::record_action( + storage, + Some(media_id), + AuditAction::Imported, + Some(format!("path={}", path.display())), + ) + .await?; + + info!(media_id = %media_id, path = %path.display(), "imported media file"); + + Ok(ImportResult { + media_id, + was_duplicate: false, + path: path.clone(), + }) +} + +pub(crate) fn should_ignore(path: &std::path::Path, patterns: &[String]) -> bool { + for component in path.components() { + if let std::path::Component::Normal(name) = component { + let name_str = name.to_string_lossy(); + for pattern in patterns { + if pattern.starts_with('.') + && name_str.starts_with('.') + && pattern == name_str.as_ref() + { + return true; + } + // Simple glob: ".*" matches any dotfile + if pattern == ".*" && name_str.starts_with('.') { + return true; + } + if name_str == pattern.as_str() { + return true; + } + } + } + } + false +} + +/// Default number of concurrent import tasks. +const DEFAULT_IMPORT_CONCURRENCY: usize = 8; + +pub async fn import_directory( + storage: &DynStorageBackend, + dir: &Path, + ignore_patterns: &[String], +) -> Result>> { + import_directory_with_concurrency(storage, dir, ignore_patterns, DEFAULT_IMPORT_CONCURRENCY) + .await +} + +pub async fn import_directory_with_concurrency( + storage: &DynStorageBackend, + dir: &Path, + ignore_patterns: &[String], + concurrency: usize, +) -> Result>> { + let concurrency = concurrency.clamp(1, 256); + let dir = dir.to_path_buf(); + let patterns = ignore_patterns.to_vec(); + + let entries: Vec = { + let dir = dir.clone(); + tokio::task::spawn_blocking(move || { + walkdir::WalkDir::new(&dir) + .follow_links(true) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()) + .filter(|e| MediaType::from_path(e.path()).is_some()) + .filter(|e| !should_ignore(e.path(), &patterns)) + .map(|e| e.path().to_path_buf()) + .collect() + }) + .await + .map_err(|e| PinakesError::Io(std::io::Error::other(e)))? + }; + + let mut results = Vec::with_capacity(entries.len()); + let mut join_set = tokio::task::JoinSet::new(); + let mut pending_paths: Vec = Vec::new(); + + for entry_path in entries { + let storage = storage.clone(); + let path = entry_path.clone(); + pending_paths.push(entry_path); + + join_set.spawn(async move { + let result = import_file(&storage, &path).await; + (path, result) + }); + + // Limit concurrency by draining when we hit the cap + if join_set.len() >= concurrency + && let Some(Ok((path, result))) = join_set.join_next().await + { + match result { + Ok(r) => results.push(Ok(r)), + Err(e) => { + tracing::warn!(path = %path.display(), error = %e, "failed to import file"); + results.push(Err(e)); + } + } + } + } + + // Drain remaining tasks + while let Some(Ok((path, result))) = join_set.join_next().await { + match result { + Ok(r) => results.push(Ok(r)), + Err(e) => { + tracing::warn!(path = %path.display(), error = %e, "failed to import file"); + results.push(Err(e)); + } + } + } + + Ok(results) +} diff --git a/crates/pinakes-core/src/integrity.rs b/crates/pinakes-core/src/integrity.rs new file mode 100644 index 0000000..b5b3ecf --- /dev/null +++ b/crates/pinakes-core/src/integrity.rs @@ -0,0 +1,201 @@ +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use tracing::{info, warn}; + +use crate::error::Result; +use crate::hash::compute_file_hash; +use crate::model::{ContentHash, MediaId}; +use crate::storage::DynStorageBackend; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OrphanReport { + /// Media items whose files no longer exist on disk. + pub orphaned_ids: Vec, + /// Files on disk that are not tracked in the database. + pub untracked_paths: Vec, + /// Files that appear to have moved (same hash, different path). + pub moved_files: Vec<(MediaId, PathBuf, PathBuf)>, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OrphanAction { + Delete, + Ignore, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VerificationReport { + pub verified: usize, + pub mismatched: Vec<(MediaId, String, String)>, + pub missing: Vec, + pub errors: Vec<(MediaId, String)>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum IntegrityStatus { + Unverified, + Verified, + Mismatch, + Missing, +} + +impl std::fmt::Display for IntegrityStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unverified => write!(f, "unverified"), + Self::Verified => write!(f, "verified"), + Self::Mismatch => write!(f, "mismatch"), + Self::Missing => write!(f, "missing"), + } + } +} + +impl std::str::FromStr for IntegrityStatus { + type Err = String; + fn from_str(s: &str) -> std::result::Result { + match s { + "unverified" => Ok(Self::Unverified), + "verified" => Ok(Self::Verified), + "mismatch" => Ok(Self::Mismatch), + "missing" => Ok(Self::Missing), + _ => Err(format!("unknown integrity status: {s}")), + } + } +} + +/// Detect orphaned media items (files that no longer exist on disk). +pub async fn detect_orphans(storage: &DynStorageBackend) -> Result { + let media_paths = storage.list_media_paths().await?; + let mut orphaned_ids = Vec::new(); + let moved_files = Vec::new(); + + for (id, path, _hash) in &media_paths { + if !path.exists() { + orphaned_ids.push(*id); + } + } + + info!( + orphaned = orphaned_ids.len(), + total = media_paths.len(), + "orphan detection complete" + ); + + Ok(OrphanReport { + orphaned_ids, + untracked_paths: Vec::new(), + moved_files, + }) +} + +/// Resolve orphaned media items by deleting them from the database. +pub async fn resolve_orphans( + storage: &DynStorageBackend, + action: OrphanAction, + ids: &[MediaId], +) -> Result { + match action { + OrphanAction::Delete => { + let count = storage.batch_delete_media(ids).await?; + info!(count, "resolved orphans by deletion"); + Ok(count) + } + OrphanAction::Ignore => { + info!(count = ids.len(), "orphans ignored"); + Ok(0) + } + } +} + +/// Verify integrity of media files by recomputing hashes and comparing. +pub async fn verify_integrity( + storage: &DynStorageBackend, + media_ids: Option<&[MediaId]>, +) -> Result { + let all_paths = storage.list_media_paths().await?; + + let paths_to_check: Vec<(MediaId, PathBuf, ContentHash)> = if let Some(ids) = media_ids { + let id_set: std::collections::HashSet = ids.iter().copied().collect(); + all_paths + .into_iter() + .filter(|(id, _, _)| id_set.contains(id)) + .collect() + } else { + all_paths + }; + + let mut report = VerificationReport { + verified: 0, + mismatched: Vec::new(), + missing: Vec::new(), + errors: Vec::new(), + }; + + for (id, path, expected_hash) in paths_to_check { + if !path.exists() { + report.missing.push(id); + continue; + } + + match compute_file_hash(&path).await { + Ok(actual_hash) => { + if actual_hash.0 == expected_hash.0 { + report.verified += 1; + } else { + report + .mismatched + .push((id, expected_hash.0.clone(), actual_hash.0)); + } + } + Err(e) => { + report.errors.push((id, e.to_string())); + } + } + } + + info!( + verified = report.verified, + mismatched = report.mismatched.len(), + missing = report.missing.len(), + errors = report.errors.len(), + "integrity verification complete" + ); + + Ok(report) +} + +/// Clean up orphaned thumbnail files that don't correspond to any media item. +pub async fn cleanup_orphaned_thumbnails( + storage: &DynStorageBackend, + thumbnail_dir: &Path, +) -> Result { + let media_paths = storage.list_media_paths().await?; + let known_ids: std::collections::HashSet = media_paths + .iter() + .map(|(id, _, _)| id.0.to_string()) + .collect(); + + let mut removed = 0; + + if thumbnail_dir.exists() { + let entries = std::fs::read_dir(thumbnail_dir)?; + for entry in entries.flatten() { + let path = entry.path(); + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + if !known_ids.contains(stem) { + if let Err(e) = std::fs::remove_file(&path) { + warn!(path = %path.display(), error = %e, "failed to remove orphaned thumbnail"); + } else { + removed += 1; + } + } + } + } + } + + info!(removed, "orphaned thumbnail cleanup complete"); + Ok(removed) +} diff --git a/crates/pinakes-core/src/jobs.rs b/crates/pinakes-core/src/jobs.rs new file mode 100644 index 0000000..ed9f7db --- /dev/null +++ b/crates/pinakes-core/src/jobs.rs @@ -0,0 +1,226 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio::sync::{RwLock, mpsc}; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use crate::model::MediaId; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "type")] +pub enum JobKind { + Scan { + path: Option, + }, + GenerateThumbnails { + media_ids: Vec, + }, + VerifyIntegrity { + media_ids: Vec, + }, + OrphanDetection, + CleanupThumbnails, + Export { + format: ExportFormat, + destination: PathBuf, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExportFormat { + Json, + Csv, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "state")] +pub enum JobStatus { + Pending, + Running { progress: f32, message: String }, + Completed { result: Value }, + Failed { error: String }, + Cancelled, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Job { + pub id: Uuid, + pub kind: JobKind, + pub status: JobStatus, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +struct WorkerItem { + job_id: Uuid, + kind: JobKind, + cancel: CancellationToken, +} + +pub struct JobQueue { + jobs: Arc>>, + cancellations: Arc>>, + tx: mpsc::Sender, +} + +impl JobQueue { + /// Create a new job queue and spawn `worker_count` background workers. + /// + /// The `executor` callback is invoked for each job; it receives the job kind, + /// a progress-reporting callback, and a cancellation token. + pub fn new(worker_count: usize, executor: F) -> Arc + where + F: Fn( + Uuid, + JobKind, + CancellationToken, + Arc>>, + ) -> tokio::task::JoinHandle<()> + + Send + + Sync + + 'static, + { + let (tx, rx) = mpsc::channel::(256); + let rx = Arc::new(tokio::sync::Mutex::new(rx)); + let jobs: Arc>> = Arc::new(RwLock::new(HashMap::new())); + let cancellations: Arc>> = + Arc::new(RwLock::new(HashMap::new())); + + let executor = Arc::new(executor); + + for _ in 0..worker_count { + let rx = rx.clone(); + let jobs = jobs.clone(); + let cancellations = cancellations.clone(); + let executor = executor.clone(); + + tokio::spawn(async move { + loop { + let item = { + let mut guard = rx.lock().await; + guard.recv().await + }; + let Some(item) = item else { break }; + + // Mark as running + { + let mut map = jobs.write().await; + if let Some(job) = map.get_mut(&item.job_id) { + job.status = JobStatus::Running { + progress: 0.0, + message: "starting".to_string(), + }; + job.updated_at = Utc::now(); + } + } + + let handle = executor(item.job_id, item.kind, item.cancel, jobs.clone()); + let _ = handle.await; + + // Clean up cancellation token + cancellations.write().await.remove(&item.job_id); + } + }); + } + + Arc::new(Self { + jobs, + cancellations, + tx, + }) + } + + /// Submit a new job, returning its ID. + pub async fn submit(&self, kind: JobKind) -> Uuid { + let id = Uuid::now_v7(); + let now = Utc::now(); + let cancel = CancellationToken::new(); + + let job = Job { + id, + kind: kind.clone(), + status: JobStatus::Pending, + created_at: now, + updated_at: now, + }; + + self.jobs.write().await.insert(id, job); + self.cancellations.write().await.insert(id, cancel.clone()); + + let item = WorkerItem { + job_id: id, + kind, + cancel, + }; + + // If the channel is full we still record the job — it'll stay Pending + let _ = self.tx.send(item).await; + id + } + + /// Get the status of a job. + pub async fn status(&self, id: Uuid) -> Option { + self.jobs.read().await.get(&id).cloned() + } + + /// List all jobs, most recent first. + pub async fn list(&self) -> Vec { + let map = self.jobs.read().await; + let mut jobs: Vec = map.values().cloned().collect(); + jobs.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + jobs + } + + /// Cancel a running or pending job. + pub async fn cancel(&self, id: Uuid) -> bool { + if let Some(token) = self.cancellations.read().await.get(&id) { + token.cancel(); + let mut map = self.jobs.write().await; + if let Some(job) = map.get_mut(&id) { + job.status = JobStatus::Cancelled; + job.updated_at = Utc::now(); + } + true + } else { + false + } + } + + /// Update a job's progress. Called by executors. + pub async fn update_progress( + jobs: &Arc>>, + id: Uuid, + progress: f32, + message: String, + ) { + let mut map = jobs.write().await; + if let Some(job) = map.get_mut(&id) { + job.status = JobStatus::Running { progress, message }; + job.updated_at = Utc::now(); + } + } + + /// Mark a job as completed. + pub async fn complete(jobs: &Arc>>, id: Uuid, result: Value) { + let mut map = jobs.write().await; + if let Some(job) = map.get_mut(&id) { + job.status = JobStatus::Completed { result }; + job.updated_at = Utc::now(); + } + } + + /// Mark a job as failed. + pub async fn fail(jobs: &Arc>>, id: Uuid, error: String) { + let mut map = jobs.write().await; + if let Some(job) = map.get_mut(&id) { + job.status = JobStatus::Failed { error }; + job.updated_at = Utc::now(); + } + } +} diff --git a/crates/pinakes-core/src/lib.rs b/crates/pinakes-core/src/lib.rs new file mode 100644 index 0000000..34fee25 --- /dev/null +++ b/crates/pinakes-core/src/lib.rs @@ -0,0 +1,21 @@ +pub mod audit; +pub mod cache; +pub mod collections; +pub mod config; +pub mod error; +pub mod events; +pub mod export; +pub mod hash; +pub mod import; +pub mod integrity; +pub mod jobs; +pub mod media_type; +pub mod metadata; +pub mod model; +pub mod opener; +pub mod scan; +pub mod scheduler; +pub mod search; +pub mod storage; +pub mod tags; +pub mod thumbnail; diff --git a/crates/pinakes-core/src/media_type.rs b/crates/pinakes-core/src/media_type.rs new file mode 100644 index 0000000..483098d --- /dev/null +++ b/crates/pinakes-core/src/media_type.rs @@ -0,0 +1,209 @@ +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MediaType { + // Audio + Mp3, + Flac, + Ogg, + Wav, + Aac, + Opus, + + // Video + Mp4, + Mkv, + Avi, + Webm, + + // Documents + Pdf, + Epub, + Djvu, + + // Text + Markdown, + PlainText, + + // Images + Jpeg, + Png, + Gif, + Webp, + Svg, + Avif, + Tiff, + Bmp, + + // RAW Images + Cr2, + Nef, + Arw, + Dng, + Orf, + Rw2, + + // HEIC/HEIF + Heic, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MediaCategory { + Audio, + Video, + Document, + Text, + Image, +} + +impl MediaType { + pub fn from_extension(ext: &str) -> Option { + match ext.to_ascii_lowercase().as_str() { + "mp3" => Some(Self::Mp3), + "flac" => Some(Self::Flac), + "ogg" | "oga" => Some(Self::Ogg), + "wav" => Some(Self::Wav), + "aac" | "m4a" => Some(Self::Aac), + "opus" => Some(Self::Opus), + "mp4" | "m4v" => Some(Self::Mp4), + "mkv" => Some(Self::Mkv), + "avi" => Some(Self::Avi), + "webm" => Some(Self::Webm), + "pdf" => Some(Self::Pdf), + "epub" => Some(Self::Epub), + "djvu" => Some(Self::Djvu), + "md" | "markdown" => Some(Self::Markdown), + "txt" | "text" => Some(Self::PlainText), + "jpg" | "jpeg" => Some(Self::Jpeg), + "png" => Some(Self::Png), + "gif" => Some(Self::Gif), + "webp" => Some(Self::Webp), + "svg" => Some(Self::Svg), + "avif" => Some(Self::Avif), + "tiff" | "tif" => Some(Self::Tiff), + "bmp" => Some(Self::Bmp), + "cr2" => Some(Self::Cr2), + "nef" => Some(Self::Nef), + "arw" => Some(Self::Arw), + "dng" => Some(Self::Dng), + "orf" => Some(Self::Orf), + "rw2" => Some(Self::Rw2), + "heic" | "heif" => Some(Self::Heic), + _ => None, + } + } + + pub fn from_path(path: &Path) -> Option { + path.extension() + .and_then(|e| e.to_str()) + .and_then(Self::from_extension) + } + + pub fn mime_type(&self) -> &'static str { + match self { + Self::Mp3 => "audio/mpeg", + Self::Flac => "audio/flac", + Self::Ogg => "audio/ogg", + Self::Wav => "audio/wav", + Self::Aac => "audio/aac", + Self::Opus => "audio/opus", + Self::Mp4 => "video/mp4", + Self::Mkv => "video/x-matroska", + Self::Avi => "video/x-msvideo", + Self::Webm => "video/webm", + Self::Pdf => "application/pdf", + Self::Epub => "application/epub+zip", + Self::Djvu => "image/vnd.djvu", + Self::Markdown => "text/markdown", + Self::PlainText => "text/plain", + Self::Jpeg => "image/jpeg", + Self::Png => "image/png", + Self::Gif => "image/gif", + Self::Webp => "image/webp", + Self::Svg => "image/svg+xml", + Self::Avif => "image/avif", + Self::Tiff => "image/tiff", + Self::Bmp => "image/bmp", + Self::Cr2 => "image/x-canon-cr2", + Self::Nef => "image/x-nikon-nef", + Self::Arw => "image/x-sony-arw", + Self::Dng => "image/x-adobe-dng", + Self::Orf => "image/x-olympus-orf", + Self::Rw2 => "image/x-panasonic-rw2", + Self::Heic => "image/heic", + } + } + + pub fn category(&self) -> MediaCategory { + match self { + Self::Mp3 | Self::Flac | Self::Ogg | Self::Wav | Self::Aac | Self::Opus => { + MediaCategory::Audio + } + Self::Mp4 | Self::Mkv | Self::Avi | Self::Webm => MediaCategory::Video, + Self::Pdf | Self::Epub | Self::Djvu => MediaCategory::Document, + Self::Markdown | Self::PlainText => MediaCategory::Text, + Self::Jpeg + | Self::Png + | Self::Gif + | Self::Webp + | Self::Svg + | Self::Avif + | Self::Tiff + | Self::Bmp + | Self::Cr2 + | Self::Nef + | Self::Arw + | Self::Dng + | Self::Orf + | Self::Rw2 + | Self::Heic => MediaCategory::Image, + } + } + + pub fn extensions(&self) -> &'static [&'static str] { + match self { + Self::Mp3 => &["mp3"], + Self::Flac => &["flac"], + Self::Ogg => &["ogg", "oga"], + Self::Wav => &["wav"], + Self::Aac => &["aac", "m4a"], + Self::Opus => &["opus"], + Self::Mp4 => &["mp4", "m4v"], + Self::Mkv => &["mkv"], + Self::Avi => &["avi"], + Self::Webm => &["webm"], + Self::Pdf => &["pdf"], + Self::Epub => &["epub"], + Self::Djvu => &["djvu"], + Self::Markdown => &["md", "markdown"], + Self::PlainText => &["txt", "text"], + Self::Jpeg => &["jpg", "jpeg"], + Self::Png => &["png"], + Self::Gif => &["gif"], + Self::Webp => &["webp"], + Self::Svg => &["svg"], + Self::Avif => &["avif"], + Self::Tiff => &["tiff", "tif"], + Self::Bmp => &["bmp"], + Self::Cr2 => &["cr2"], + Self::Nef => &["nef"], + Self::Arw => &["arw"], + Self::Dng => &["dng"], + Self::Orf => &["orf"], + Self::Rw2 => &["rw2"], + Self::Heic => &["heic", "heif"], + } + } + + /// Returns true if this is a RAW image format. + pub fn is_raw(&self) -> bool { + matches!( + self, + Self::Cr2 | Self::Nef | Self::Arw | Self::Dng | Self::Orf | Self::Rw2 + ) + } +} diff --git a/crates/pinakes-core/src/metadata/audio.rs b/crates/pinakes-core/src/metadata/audio.rs new file mode 100644 index 0000000..e2a8b0a --- /dev/null +++ b/crates/pinakes-core/src/metadata/audio.rs @@ -0,0 +1,81 @@ +use std::path::Path; + +use lofty::file::{AudioFile, TaggedFileExt}; +use lofty::tag::Accessor; + +use crate::error::{PinakesError, Result}; +use crate::media_type::MediaType; + +use super::{ExtractedMetadata, MetadataExtractor}; + +pub struct AudioExtractor; + +impl MetadataExtractor for AudioExtractor { + fn extract(&self, path: &Path) -> Result { + let tagged_file = lofty::read_from_path(path) + .map_err(|e| PinakesError::MetadataExtraction(format!("audio metadata: {e}")))?; + + let mut meta = ExtractedMetadata::default(); + + if let Some(tag) = tagged_file + .primary_tag() + .or_else(|| tagged_file.first_tag()) + { + meta.title = tag.title().map(|s| s.to_string()); + meta.artist = tag.artist().map(|s| s.to_string()); + meta.album = tag.album().map(|s| s.to_string()); + meta.genre = tag.genre().map(|s| s.to_string()); + meta.year = tag.year().map(|y| y as i32); + } + + if let Some(tag) = tagged_file + .primary_tag() + .or_else(|| tagged_file.first_tag()) + { + if let Some(track) = tag.track() { + meta.extra + .insert("track_number".to_string(), track.to_string()); + } + if let Some(disc) = tag.disk() { + meta.extra + .insert("disc_number".to_string(), disc.to_string()); + } + if let Some(comment) = tag.comment() { + meta.extra + .insert("comment".to_string(), comment.to_string()); + } + } + + let properties = tagged_file.properties(); + let duration = properties.duration(); + if !duration.is_zero() { + meta.duration_secs = Some(duration.as_secs_f64()); + } + + if let Some(bitrate) = properties.audio_bitrate() { + meta.extra + .insert("bitrate".to_string(), format!("{bitrate} kbps")); + } + if let Some(sample_rate) = properties.sample_rate() { + meta.extra + .insert("sample_rate".to_string(), format!("{sample_rate} Hz")); + } + if let Some(channels) = properties.channels() { + meta.extra + .insert("channels".to_string(), channels.to_string()); + } + + Ok(meta) + } + + fn supported_types(&self) -> &[MediaType] { + &[ + MediaType::Mp3, + MediaType::Flac, + MediaType::Ogg, + MediaType::Wav, + MediaType::Aac, + MediaType::Opus, + ] + } +} diff --git a/crates/pinakes-core/src/metadata/document.rs b/crates/pinakes-core/src/metadata/document.rs new file mode 100644 index 0000000..4aa7817 --- /dev/null +++ b/crates/pinakes-core/src/metadata/document.rs @@ -0,0 +1,192 @@ +use std::path::Path; + +use crate::error::{PinakesError, Result}; +use crate::media_type::MediaType; + +use super::{ExtractedMetadata, MetadataExtractor}; + +pub struct DocumentExtractor; + +impl MetadataExtractor for DocumentExtractor { + fn extract(&self, path: &Path) -> Result { + match MediaType::from_path(path) { + Some(MediaType::Pdf) => extract_pdf(path), + Some(MediaType::Epub) => extract_epub(path), + Some(MediaType::Djvu) => extract_djvu(path), + _ => Ok(ExtractedMetadata::default()), + } + } + + fn supported_types(&self) -> &[MediaType] { + &[MediaType::Pdf, MediaType::Epub, MediaType::Djvu] + } +} + +fn extract_pdf(path: &Path) -> Result { + let doc = lopdf::Document::load(path) + .map_err(|e| PinakesError::MetadataExtraction(format!("PDF load: {e}")))?; + + let mut meta = ExtractedMetadata::default(); + + // Find the Info dictionary via the trailer + if let Ok(info_ref) = doc.trailer.get(b"Info") { + let info_obj = if let Ok(reference) = info_ref.as_reference() { + doc.get_object(reference).ok() + } else { + Some(info_ref) + }; + + if let Some(obj) = info_obj + && let Ok(dict) = obj.as_dict() + { + if let Ok(title) = dict.get(b"Title") { + meta.title = pdf_object_to_string(title); + } + if let Ok(author) = dict.get(b"Author") { + meta.artist = pdf_object_to_string(author); + } + if let Ok(subject) = dict.get(b"Subject") { + meta.description = pdf_object_to_string(subject); + } + if let Ok(creator) = dict.get(b"Creator") { + meta.extra.insert( + "creator".to_string(), + pdf_object_to_string(creator).unwrap_or_default(), + ); + } + if let Ok(producer) = dict.get(b"Producer") { + meta.extra.insert( + "producer".to_string(), + pdf_object_to_string(producer).unwrap_or_default(), + ); + } + } + } + + // Page count + let page_count = doc.get_pages().len(); + if page_count > 0 { + meta.extra + .insert("page_count".to_string(), page_count.to_string()); + } + + Ok(meta) +} + +fn pdf_object_to_string(obj: &lopdf::Object) -> Option { + match obj { + lopdf::Object::String(bytes, _) => Some(String::from_utf8_lossy(bytes).into_owned()), + lopdf::Object::Name(name) => Some(String::from_utf8_lossy(name).into_owned()), + _ => None, + } +} + +fn extract_epub(path: &Path) -> Result { + let doc = epub::doc::EpubDoc::new(path) + .map_err(|e| PinakesError::MetadataExtraction(format!("EPUB parse: {e}")))?; + + let mut meta = ExtractedMetadata { + title: doc.mdata("title").map(|item| item.value.clone()), + artist: doc.mdata("creator").map(|item| item.value.clone()), + description: doc.mdata("description").map(|item| item.value.clone()), + ..Default::default() + }; + + if let Some(lang) = doc.mdata("language") { + meta.extra + .insert("language".to_string(), lang.value.clone()); + } + if let Some(publisher) = doc.mdata("publisher") { + meta.extra + .insert("publisher".to_string(), publisher.value.clone()); + } + if let Some(date) = doc.mdata("date") { + meta.extra.insert("date".to_string(), date.value.clone()); + } + + Ok(meta) +} + +fn extract_djvu(path: &Path) -> Result { + // DjVu files contain metadata in SEXPR (S-expression) format within + // ANTa/ANTz chunks, or in the DIRM chunk. We parse the raw bytes to + // extract any metadata fields we can find. + let data = std::fs::read(path) + .map_err(|e| PinakesError::MetadataExtraction(format!("DjVu read: {e}")))?; + + let mut meta = ExtractedMetadata::default(); + + // DjVu files start with "AT&T" magic followed by FORM:DJVU or FORM:DJVM + if data.len() < 16 { + return Ok(meta); + } + + // Search for metadata annotations in the file. DjVu metadata is stored + // as S-expressions like (metadata (key "value") ...) within ANTa chunks. + let content = String::from_utf8_lossy(&data); + + // Look for (metadata ...) blocks + if let Some(meta_start) = content.find("(metadata") { + let remainder = &content[meta_start..]; + // Extract key-value pairs like (title "Some Title") + extract_djvu_field(remainder, "title", &mut meta.title); + extract_djvu_field(remainder, "author", &mut meta.artist); + + let mut desc = None; + extract_djvu_field(remainder, "subject", &mut desc); + if desc.is_none() { + extract_djvu_field(remainder, "description", &mut desc); + } + meta.description = desc; + + let mut year_str = None; + extract_djvu_field(remainder, "year", &mut year_str); + if let Some(ref y) = year_str { + meta.year = y.parse().ok(); + } + + let mut creator = None; + extract_djvu_field(remainder, "creator", &mut creator); + if let Some(c) = creator { + meta.extra.insert("creator".to_string(), c); + } + } + + // Also check for booklet-style metadata that some DjVu encoders write + // outside the metadata SEXPR + if meta.title.is_none() + && let Some(title_start) = content.find("(bookmarks") + { + let remainder = &content[title_start..]; + // First bookmark title is often the document title + if let Some(q1) = remainder.find('"') { + let after_q1 = &remainder[q1 + 1..]; + if let Some(q2) = after_q1.find('"') { + let val = &after_q1[..q2]; + if !val.is_empty() { + meta.title = Some(val.to_string()); + } + } + } + } + + Ok(meta) +} + +fn extract_djvu_field(sexpr: &str, key: &str, out: &mut Option) { + // Look for patterns like (key "value") in the S-expression + let pattern = format!("({key}"); + if let Some(start) = sexpr.find(&pattern) { + let remainder = &sexpr[start + pattern.len()..]; + // Find the quoted value + if let Some(q1) = remainder.find('"') { + let after_q1 = &remainder[q1 + 1..]; + if let Some(q2) = after_q1.find('"') { + let val = &after_q1[..q2]; + if !val.is_empty() { + *out = Some(val.to_string()); + } + } + } + } +} diff --git a/crates/pinakes-core/src/metadata/image.rs b/crates/pinakes-core/src/metadata/image.rs new file mode 100644 index 0000000..a38d465 --- /dev/null +++ b/crates/pinakes-core/src/metadata/image.rs @@ -0,0 +1,213 @@ +use std::path::Path; + +use crate::error::Result; +use crate::media_type::MediaType; + +use super::{ExtractedMetadata, MetadataExtractor}; + +pub struct ImageExtractor; + +impl MetadataExtractor for ImageExtractor { + fn extract(&self, path: &Path) -> Result { + let mut meta = ExtractedMetadata::default(); + + let file = std::fs::File::open(path)?; + let mut buf_reader = std::io::BufReader::new(&file); + + let exif_data = match exif::Reader::new().read_from_container(&mut buf_reader) { + Ok(exif) => exif, + Err(_) => return Ok(meta), + }; + + // Image dimensions + if let Some(width) = exif_data + .get_field(exif::Tag::PixelXDimension, exif::In::PRIMARY) + .or_else(|| exif_data.get_field(exif::Tag::ImageWidth, exif::In::PRIMARY)) + && let Some(w) = field_to_u32(width) + { + meta.extra.insert("width".to_string(), w.to_string()); + } + if let Some(height) = exif_data + .get_field(exif::Tag::PixelYDimension, exif::In::PRIMARY) + .or_else(|| exif_data.get_field(exif::Tag::ImageLength, exif::In::PRIMARY)) + && let Some(h) = field_to_u32(height) + { + meta.extra.insert("height".to_string(), h.to_string()); + } + + // Camera make and model + if let Some(make) = exif_data.get_field(exif::Tag::Make, exif::In::PRIMARY) { + let val = make.display_value().to_string(); + if !val.is_empty() { + meta.extra.insert("camera_make".to_string(), val); + } + } + if let Some(model) = exif_data.get_field(exif::Tag::Model, exif::In::PRIMARY) { + let val = model.display_value().to_string(); + if !val.is_empty() { + meta.extra.insert("camera_model".to_string(), val); + } + } + + // Date taken + if let Some(date) = exif_data + .get_field(exif::Tag::DateTimeOriginal, exif::In::PRIMARY) + .or_else(|| exif_data.get_field(exif::Tag::DateTime, exif::In::PRIMARY)) + { + let val = date.display_value().to_string(); + if !val.is_empty() { + meta.extra.insert("date_taken".to_string(), val); + } + } + + // GPS coordinates + if let (Some(lat), Some(lat_ref), Some(lon), Some(lon_ref)) = ( + exif_data.get_field(exif::Tag::GPSLatitude, exif::In::PRIMARY), + exif_data.get_field(exif::Tag::GPSLatitudeRef, exif::In::PRIMARY), + exif_data.get_field(exif::Tag::GPSLongitude, exif::In::PRIMARY), + exif_data.get_field(exif::Tag::GPSLongitudeRef, exif::In::PRIMARY), + ) && let (Some(lat_val), Some(lon_val)) = + (dms_to_decimal(lat, lat_ref), dms_to_decimal(lon, lon_ref)) + { + meta.extra + .insert("gps_latitude".to_string(), format!("{lat_val:.6}")); + meta.extra + .insert("gps_longitude".to_string(), format!("{lon_val:.6}")); + } + + // Exposure info + if let Some(iso) = + exif_data.get_field(exif::Tag::PhotographicSensitivity, exif::In::PRIMARY) + { + let val = iso.display_value().to_string(); + if !val.is_empty() { + meta.extra.insert("iso".to_string(), val); + } + } + if let Some(exposure) = exif_data.get_field(exif::Tag::ExposureTime, exif::In::PRIMARY) { + let val = exposure.display_value().to_string(); + if !val.is_empty() { + meta.extra.insert("exposure_time".to_string(), val); + } + } + if let Some(aperture) = exif_data.get_field(exif::Tag::FNumber, exif::In::PRIMARY) { + let val = aperture.display_value().to_string(); + if !val.is_empty() { + meta.extra.insert("f_number".to_string(), val); + } + } + if let Some(focal) = exif_data.get_field(exif::Tag::FocalLength, exif::In::PRIMARY) { + let val = focal.display_value().to_string(); + if !val.is_empty() { + meta.extra.insert("focal_length".to_string(), val); + } + } + + // Lens model + if let Some(lens) = exif_data.get_field(exif::Tag::LensModel, exif::In::PRIMARY) { + let val = lens.display_value().to_string(); + if !val.is_empty() && val != "\"\"" { + meta.extra + .insert("lens_model".to_string(), val.trim_matches('"').to_string()); + } + } + + // Flash + if let Some(flash) = exif_data.get_field(exif::Tag::Flash, exif::In::PRIMARY) { + let val = flash.display_value().to_string(); + if !val.is_empty() { + meta.extra.insert("flash".to_string(), val); + } + } + + // Orientation + if let Some(orientation) = exif_data.get_field(exif::Tag::Orientation, exif::In::PRIMARY) { + let val = orientation.display_value().to_string(); + if !val.is_empty() { + meta.extra.insert("orientation".to_string(), val); + } + } + + // Software + if let Some(software) = exif_data.get_field(exif::Tag::Software, exif::In::PRIMARY) { + let val = software.display_value().to_string(); + if !val.is_empty() { + meta.extra.insert("software".to_string(), val); + } + } + + // Image description as title + if let Some(desc) = exif_data.get_field(exif::Tag::ImageDescription, exif::In::PRIMARY) { + let val = desc.display_value().to_string(); + if !val.is_empty() && val != "\"\"" { + meta.title = Some(val.trim_matches('"').to_string()); + } + } + + // Artist + if let Some(artist) = exif_data.get_field(exif::Tag::Artist, exif::In::PRIMARY) { + let val = artist.display_value().to_string(); + if !val.is_empty() && val != "\"\"" { + meta.artist = Some(val.trim_matches('"').to_string()); + } + } + + // Copyright as description + if let Some(copyright) = exif_data.get_field(exif::Tag::Copyright, exif::In::PRIMARY) { + let val = copyright.display_value().to_string(); + if !val.is_empty() && val != "\"\"" { + meta.description = Some(val.trim_matches('"').to_string()); + } + } + + Ok(meta) + } + + fn supported_types(&self) -> &[MediaType] { + &[ + MediaType::Jpeg, + MediaType::Png, + MediaType::Gif, + MediaType::Webp, + MediaType::Avif, + MediaType::Tiff, + MediaType::Bmp, + // RAW formats (TIFF-based, kamadak-exif handles these) + MediaType::Cr2, + MediaType::Nef, + MediaType::Arw, + MediaType::Dng, + MediaType::Orf, + MediaType::Rw2, + // HEIC + MediaType::Heic, + ] + } +} + +fn field_to_u32(field: &exif::Field) -> Option { + match &field.value { + exif::Value::Long(v) => v.first().copied(), + exif::Value::Short(v) => v.first().map(|&x| x as u32), + _ => None, + } +} + +fn dms_to_decimal(dms_field: &exif::Field, ref_field: &exif::Field) -> Option { + if let exif::Value::Rational(ref rationals) = dms_field.value + && rationals.len() >= 3 + { + let degrees = rationals[0].to_f64(); + let minutes = rationals[1].to_f64(); + let seconds = rationals[2].to_f64(); + let mut decimal = degrees + minutes / 60.0 + seconds / 3600.0; + + let ref_str = ref_field.display_value().to_string(); + if ref_str.contains('S') || ref_str.contains('W') { + decimal = -decimal; + } + + return Some(decimal); + } + None +} diff --git a/crates/pinakes-core/src/metadata/markdown.rs b/crates/pinakes-core/src/metadata/markdown.rs new file mode 100644 index 0000000..7da1714 --- /dev/null +++ b/crates/pinakes-core/src/metadata/markdown.rs @@ -0,0 +1,40 @@ +use std::path::Path; + +use crate::error::Result; +use crate::media_type::MediaType; + +use super::{ExtractedMetadata, MetadataExtractor}; + +pub struct MarkdownExtractor; + +impl MetadataExtractor for MarkdownExtractor { + fn extract(&self, path: &Path) -> Result { + let content = std::fs::read_to_string(path)?; + let parsed = gray_matter::Matter::::new().parse(&content); + + let mut meta = ExtractedMetadata::default(); + + if let Some(data) = parsed.ok().and_then(|p| p.data) + && let gray_matter::Pod::Hash(map) = data + { + if let Some(gray_matter::Pod::String(title)) = map.get("title") { + meta.title = Some(title.clone()); + } + if let Some(gray_matter::Pod::String(author)) = map.get("author") { + meta.artist = Some(author.clone()); + } + if let Some(gray_matter::Pod::String(desc)) = map.get("description") { + meta.description = Some(desc.clone()); + } + if let Some(gray_matter::Pod::String(date)) = map.get("date") { + meta.extra.insert("date".to_string(), date.clone()); + } + } + + Ok(meta) + } + + fn supported_types(&self) -> &[MediaType] { + &[MediaType::Markdown, MediaType::PlainText] + } +} diff --git a/crates/pinakes-core/src/metadata/mod.rs b/crates/pinakes-core/src/metadata/mod.rs new file mode 100644 index 0000000..fb776d3 --- /dev/null +++ b/crates/pinakes-core/src/metadata/mod.rs @@ -0,0 +1,46 @@ +pub mod audio; +pub mod document; +pub mod image; +pub mod markdown; +pub mod video; + +use std::collections::HashMap; +use std::path::Path; + +use crate::error::Result; +use crate::media_type::MediaType; + +#[derive(Debug, Clone, Default)] +pub struct ExtractedMetadata { + pub title: Option, + pub artist: Option, + pub album: Option, + pub genre: Option, + pub year: Option, + pub duration_secs: Option, + pub description: Option, + pub extra: HashMap, +} + +pub trait MetadataExtractor: Send + Sync { + fn extract(&self, path: &Path) -> Result; + fn supported_types(&self) -> &[MediaType]; +} + +pub fn extract_metadata(path: &Path, media_type: MediaType) -> Result { + let extractors: Vec> = vec![ + Box::new(audio::AudioExtractor), + Box::new(document::DocumentExtractor), + Box::new(video::VideoExtractor), + Box::new(markdown::MarkdownExtractor), + Box::new(image::ImageExtractor), + ]; + + for extractor in &extractors { + if extractor.supported_types().contains(&media_type) { + return extractor.extract(path); + } + } + + Ok(ExtractedMetadata::default()) +} diff --git a/crates/pinakes-core/src/metadata/video.rs b/crates/pinakes-core/src/metadata/video.rs new file mode 100644 index 0000000..8cc6c4d --- /dev/null +++ b/crates/pinakes-core/src/metadata/video.rs @@ -0,0 +1,120 @@ +use std::path::Path; + +use crate::error::{PinakesError, Result}; +use crate::media_type::MediaType; + +use super::{ExtractedMetadata, MetadataExtractor}; + +pub struct VideoExtractor; + +impl MetadataExtractor for VideoExtractor { + fn extract(&self, path: &Path) -> Result { + match MediaType::from_path(path) { + Some(MediaType::Mkv) => extract_mkv(path), + Some(MediaType::Mp4) => extract_mp4(path), + _ => Ok(ExtractedMetadata::default()), + } + } + + fn supported_types(&self) -> &[MediaType] { + &[ + MediaType::Mp4, + MediaType::Mkv, + MediaType::Avi, + MediaType::Webm, + ] + } +} + +fn extract_mkv(path: &Path) -> Result { + let file = std::fs::File::open(path)?; + let mkv = matroska::Matroska::open(file) + .map_err(|e| PinakesError::MetadataExtraction(format!("MKV parse: {e}")))?; + + let mut meta = ExtractedMetadata { + title: mkv.info.title.clone(), + duration_secs: mkv.info.duration.map(|dur| dur.as_secs_f64()), + ..Default::default() + }; + + // Extract resolution and codec info from tracks + for track in &mkv.tracks { + match &track.settings { + matroska::Settings::Video(v) => { + meta.extra.insert( + "resolution".to_string(), + format!("{}x{}", v.pixel_width, v.pixel_height), + ); + if !track.codec_id.is_empty() { + meta.extra + .insert("video_codec".to_string(), track.codec_id.clone()); + } + } + matroska::Settings::Audio(a) => { + meta.extra.insert( + "sample_rate".to_string(), + format!("{} Hz", a.sample_rate as u32), + ); + meta.extra + .insert("channels".to_string(), a.channels.to_string()); + if !track.codec_id.is_empty() { + meta.extra + .insert("audio_codec".to_string(), track.codec_id.clone()); + } + } + _ => {} + } + } + + Ok(meta) +} + +fn extract_mp4(path: &Path) -> Result { + use lofty::file::{AudioFile, TaggedFileExt}; + use lofty::tag::Accessor; + + let tagged_file = lofty::read_from_path(path) + .map_err(|e| PinakesError::MetadataExtraction(format!("MP4 metadata: {e}")))?; + + let mut meta = ExtractedMetadata::default(); + + if let Some(tag) = tagged_file + .primary_tag() + .or_else(|| tagged_file.first_tag()) + { + meta.title = tag + .title() + .map(|s: std::borrow::Cow<'_, str>| s.to_string()); + meta.artist = tag + .artist() + .map(|s: std::borrow::Cow<'_, str>| s.to_string()); + meta.album = tag + .album() + .map(|s: std::borrow::Cow<'_, str>| s.to_string()); + meta.genre = tag + .genre() + .map(|s: std::borrow::Cow<'_, str>| s.to_string()); + meta.year = tag.year().map(|y| y as i32); + } + + let properties = tagged_file.properties(); + let duration = properties.duration(); + if !duration.is_zero() { + meta.duration_secs = Some(duration.as_secs_f64()); + } + + if let Some(bitrate) = properties.audio_bitrate() { + meta.extra + .insert("audio_bitrate".to_string(), format!("{bitrate} kbps")); + } + if let Some(sample_rate) = properties.sample_rate() { + meta.extra + .insert("sample_rate".to_string(), format!("{sample_rate} Hz")); + } + if let Some(channels) = properties.channels() { + meta.extra + .insert("channels".to_string(), channels.to_string()); + } + + Ok(meta) +} diff --git a/crates/pinakes-core/src/model.rs b/crates/pinakes-core/src/model.rs new file mode 100644 index 0000000..53db65f --- /dev/null +++ b/crates/pinakes-core/src/model.rs @@ -0,0 +1,191 @@ +use std::collections::HashMap; +use std::fmt; +use std::path::PathBuf; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::media_type::MediaType; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct MediaId(pub Uuid); + +impl MediaId { + pub fn new() -> Self { + Self(Uuid::now_v7()) + } +} + +impl fmt::Display for MediaId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Default for MediaId { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ContentHash(pub String); + +impl ContentHash { + pub fn new(hex: String) -> Self { + Self(hex) + } +} + +impl fmt::Display for ContentHash { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MediaItem { + pub id: MediaId, + pub path: PathBuf, + pub file_name: String, + pub media_type: MediaType, + pub content_hash: ContentHash, + pub file_size: u64, + pub title: Option, + pub artist: Option, + pub album: Option, + pub genre: Option, + pub year: Option, + pub duration_secs: Option, + pub description: Option, + pub thumbnail_path: Option, + pub custom_fields: HashMap, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CustomField { + pub field_type: CustomFieldType, + pub value: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CustomFieldType { + Text, + Number, + Date, + Boolean, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Tag { + pub id: Uuid, + pub name: String, + pub parent_id: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Collection { + pub id: Uuid, + pub name: String, + pub description: Option, + pub kind: CollectionKind, + pub filter_query: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CollectionKind { + Manual, + Virtual, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CollectionMember { + pub collection_id: Uuid, + pub media_id: MediaId, + pub position: i32, + pub added_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuditEntry { + pub id: Uuid, + pub media_id: Option, + pub action: AuditAction, + pub details: Option, + pub timestamp: DateTime, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuditAction { + Imported, + Updated, + Deleted, + Tagged, + Untagged, + AddedToCollection, + RemovedFromCollection, + Opened, + Scanned, +} + +impl fmt::Display for AuditAction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::Imported => "imported", + Self::Updated => "updated", + Self::Deleted => "deleted", + Self::Tagged => "tagged", + Self::Untagged => "untagged", + Self::AddedToCollection => "added_to_collection", + Self::RemovedFromCollection => "removed_from_collection", + Self::Opened => "opened", + Self::Scanned => "scanned", + }; + write!(f, "{s}") + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Pagination { + pub offset: u64, + pub limit: u64, + pub sort: Option, +} + +impl Pagination { + pub fn new(offset: u64, limit: u64, sort: Option) -> Self { + Self { + offset, + limit, + sort, + } + } +} + +impl Default for Pagination { + fn default() -> Self { + Self { + offset: 0, + limit: 50, + sort: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SavedSearch { + pub id: Uuid, + pub name: String, + pub query: String, + pub sort_order: Option, + pub created_at: DateTime, +} diff --git a/crates/pinakes-core/src/opener.rs b/crates/pinakes-core/src/opener.rs new file mode 100644 index 0000000..10df99c --- /dev/null +++ b/crates/pinakes-core/src/opener.rs @@ -0,0 +1,79 @@ +use std::path::Path; +use std::process::Command; + +use crate::error::{PinakesError, Result}; + +pub trait Opener: Send + Sync { + fn open(&self, path: &Path) -> Result<()>; +} + +/// Linux opener using xdg-open +pub struct XdgOpener; + +impl Opener for XdgOpener { + fn open(&self, path: &Path) -> Result<()> { + let status = Command::new("xdg-open") + .arg(path) + .status() + .map_err(|e| PinakesError::InvalidOperation(format!("failed to run xdg-open: {e}")))?; + if status.success() { + Ok(()) + } else { + Err(PinakesError::InvalidOperation(format!( + "xdg-open exited with status {status}" + ))) + } + } +} + +/// macOS opener using the `open` command +pub struct MacOpener; + +impl Opener for MacOpener { + fn open(&self, path: &Path) -> Result<()> { + let status = Command::new("open") + .arg(path) + .status() + .map_err(|e| PinakesError::InvalidOperation(format!("failed to run open: {e}")))?; + if status.success() { + Ok(()) + } else { + Err(PinakesError::InvalidOperation(format!( + "open exited with status {status}" + ))) + } + } +} + +/// Windows opener using `cmd /c start` +pub struct WindowsOpener; + +impl Opener for WindowsOpener { + fn open(&self, path: &Path) -> Result<()> { + let status = Command::new("cmd") + .args(["/C", "start", ""]) + .arg(path) + .status() + .map_err(|e| { + PinakesError::InvalidOperation(format!("failed to run cmd /c start: {e}")) + })?; + if status.success() { + Ok(()) + } else { + Err(PinakesError::InvalidOperation(format!( + "cmd /c start exited with status {status}" + ))) + } + } +} + +/// Returns the platform-appropriate opener. +pub fn default_opener() -> Box { + if cfg!(target_os = "macos") { + Box::new(MacOpener) + } else if cfg!(target_os = "windows") { + Box::new(WindowsOpener) + } else { + Box::new(XdgOpener) + } +} diff --git a/crates/pinakes-core/src/scan.rs b/crates/pinakes-core/src/scan.rs new file mode 100644 index 0000000..5b3debd --- /dev/null +++ b/crates/pinakes-core/src/scan.rs @@ -0,0 +1,283 @@ +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use notify::{PollWatcher, RecursiveMode, Watcher}; +use tokio::sync::mpsc; +use tracing::{info, warn}; + +use crate::error::Result; +use crate::import; +use crate::storage::DynStorageBackend; + +pub struct ScanStatus { + pub scanning: bool, + pub files_found: usize, + pub files_processed: usize, + pub errors: Vec, +} + +/// Shared scan progress that can be read by the status endpoint while a scan runs. +#[derive(Clone)] +pub struct ScanProgress { + pub is_scanning: Arc, + pub files_found: Arc, + pub files_processed: Arc, + pub error_count: Arc, + pub error_messages: Arc>>, +} + +const MAX_STORED_ERRORS: usize = 100; + +impl ScanProgress { + pub fn new() -> Self { + Self { + is_scanning: Arc::new(AtomicBool::new(false)), + files_found: Arc::new(AtomicUsize::new(0)), + files_processed: Arc::new(AtomicUsize::new(0)), + error_count: Arc::new(AtomicUsize::new(0)), + error_messages: Arc::new(Mutex::new(Vec::new())), + } + } + + pub fn snapshot(&self) -> ScanStatus { + let errors = self + .error_messages + .lock() + .map(|v| v.clone()) + .unwrap_or_default(); + ScanStatus { + scanning: self.is_scanning.load(Ordering::Acquire), + files_found: self.files_found.load(Ordering::Acquire), + files_processed: self.files_processed.load(Ordering::Acquire), + errors, + } + } + + fn begin(&self) { + self.is_scanning.store(true, Ordering::Release); + self.files_found.store(0, Ordering::Release); + self.files_processed.store(0, Ordering::Release); + self.error_count.store(0, Ordering::Release); + if let Ok(mut msgs) = self.error_messages.lock() { + msgs.clear(); + } + } + + fn record_error(&self, message: String) { + self.error_count.fetch_add(1, Ordering::Release); + if let Ok(mut msgs) = self.error_messages.lock() + && msgs.len() < MAX_STORED_ERRORS + { + msgs.push(message); + } + } + + fn finish(&self) { + self.is_scanning.store(false, Ordering::Release); + } +} + +impl Default for ScanProgress { + fn default() -> Self { + Self::new() + } +} + +pub async fn scan_directory( + storage: &DynStorageBackend, + dir: &Path, + ignore_patterns: &[String], +) -> Result { + scan_directory_with_progress(storage, dir, ignore_patterns, None).await +} + +pub async fn scan_directory_with_progress( + storage: &DynStorageBackend, + dir: &Path, + ignore_patterns: &[String], + progress: Option<&ScanProgress>, +) -> Result { + info!(dir = %dir.display(), "starting directory scan"); + + if let Some(p) = progress { + p.begin(); + } + + let results = import::import_directory(storage, dir, ignore_patterns).await?; + // Note: for configurable concurrency, use import_directory_with_concurrency directly + + let mut errors = Vec::new(); + let mut processed = 0; + for result in &results { + match result { + Ok(_) => processed += 1, + Err(e) => { + let msg = e.to_string(); + if let Some(p) = progress { + p.record_error(msg.clone()); + } + errors.push(msg); + } + } + } + + if let Some(p) = progress { + p.files_found.store(results.len(), Ordering::Release); + p.files_processed.store(processed, Ordering::Release); + p.finish(); + } + + let status = ScanStatus { + scanning: false, + files_found: results.len(), + files_processed: processed, + errors, + }; + + Ok(status) +} + +pub async fn scan_all_roots( + storage: &DynStorageBackend, + ignore_patterns: &[String], +) -> Result> { + scan_all_roots_with_progress(storage, ignore_patterns, None).await +} + +pub async fn scan_all_roots_with_progress( + storage: &DynStorageBackend, + ignore_patterns: &[String], + progress: Option<&ScanProgress>, +) -> Result> { + let roots = storage.list_root_dirs().await?; + let mut statuses = Vec::new(); + + for root in roots { + match scan_directory_with_progress(storage, &root, ignore_patterns, progress).await { + Ok(status) => statuses.push(status), + Err(e) => { + warn!(root = %root.display(), error = %e, "failed to scan root directory"); + statuses.push(ScanStatus { + scanning: false, + files_found: 0, + files_processed: 0, + errors: vec![e.to_string()], + }); + } + } + } + + Ok(statuses) +} + +pub struct FileWatcher { + _watcher: Box, + rx: mpsc::Receiver, +} + +impl FileWatcher { + pub fn new(dirs: &[PathBuf]) -> Result { + let (tx, rx) = mpsc::channel(1024); + + // Try the recommended (native) watcher first, fall back to polling + let watcher: Box = match Self::try_native_watcher(dirs, tx.clone()) { + Ok(w) => { + info!("using native filesystem watcher"); + w + } + Err(native_err) => { + warn!(error = %native_err, "native watcher failed, falling back to polling"); + Self::polling_watcher(dirs, tx)? + } + }; + + Ok(Self { + _watcher: watcher, + rx, + }) + } + + fn try_native_watcher( + dirs: &[PathBuf], + tx: mpsc::Sender, + ) -> std::result::Result, notify::Error> { + let tx_clone = tx.clone(); + let mut watcher = + notify::recommended_watcher(move |res: notify::Result| { + if let Ok(event) = res { + for path in event.paths { + if tx_clone.blocking_send(path).is_err() { + tracing::warn!("filesystem watcher channel closed, stopping"); + break; + } + } + } + })?; + + for dir in dirs { + watcher.watch(dir, RecursiveMode::Recursive)?; + } + + Ok(Box::new(watcher)) + } + + fn polling_watcher( + dirs: &[PathBuf], + tx: mpsc::Sender, + ) -> Result> { + let tx_clone = tx.clone(); + let poll_interval = std::time::Duration::from_secs(5); + let config = notify::Config::default().with_poll_interval(poll_interval); + + let mut watcher = PollWatcher::new( + move |res: notify::Result| { + if let Ok(event) = res { + for path in event.paths { + if tx_clone.blocking_send(path).is_err() { + tracing::warn!("filesystem watcher channel closed, stopping"); + break; + } + } + } + }, + config, + ) + .map_err(|e| crate::error::PinakesError::Io(std::io::Error::other(e)))?; + + for dir in dirs { + watcher + .watch(dir, RecursiveMode::Recursive) + .map_err(|e| crate::error::PinakesError::Io(std::io::Error::other(e)))?; + } + + Ok(Box::new(watcher)) + } + + pub async fn next_change(&mut self) -> Option { + self.rx.recv().await + } +} + +pub async fn watch_and_import( + storage: DynStorageBackend, + dirs: Vec, + ignore_patterns: Vec, +) -> Result<()> { + let mut watcher = FileWatcher::new(&dirs)?; + info!("filesystem watcher started"); + + while let Some(path) = watcher.next_change().await { + if path.is_file() + && crate::media_type::MediaType::from_path(&path).is_some() + && !crate::import::should_ignore(&path, &ignore_patterns) + { + info!(path = %path.display(), "detected file change, importing"); + if let Err(e) = import::import_file(&storage, &path).await { + warn!(path = %path.display(), error = %e, "failed to import changed file"); + } + } + } + + Ok(()) +} diff --git a/crates/pinakes-core/src/scheduler.rs b/crates/pinakes-core/src/scheduler.rs new file mode 100644 index 0000000..88784c4 --- /dev/null +++ b/crates/pinakes-core/src/scheduler.rs @@ -0,0 +1,517 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use chrono::{DateTime, Datelike, Utc}; +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use crate::config::Config; +use crate::jobs::{JobKind, JobQueue}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "type")] +pub enum Schedule { + Interval { secs: u64 }, + Daily { hour: u32, minute: u32 }, + Weekly { day: u32, hour: u32, minute: u32 }, +} + +impl Schedule { + pub fn next_run(&self, from: DateTime) -> DateTime { + match self { + Schedule::Interval { secs } => from + chrono::Duration::seconds(*secs as i64), + Schedule::Daily { hour, minute } => { + let today = from + .date_naive() + .and_hms_opt(*hour, *minute, 0) + .unwrap_or_default(); + let today_utc = today.and_utc(); + if today_utc > from { + today_utc + } else { + today_utc + chrono::Duration::days(1) + } + } + Schedule::Weekly { day, hour, minute } => { + let current_day = from.weekday().num_days_from_monday(); + let target_day = *day; + let days_ahead = if target_day > current_day { + target_day - current_day + } else if target_day < current_day { + 7 - (current_day - target_day) + } else { + let today = from + .date_naive() + .and_hms_opt(*hour, *minute, 0) + .unwrap_or_default() + .and_utc(); + if today > from { + return today; + } + 7 + }; + let target_date = from.date_naive() + chrono::Duration::days(days_ahead as i64); + target_date + .and_hms_opt(*hour, *minute, 0) + .unwrap_or_default() + .and_utc() + } + } + } + + pub fn display_string(&self) -> String { + match self { + Schedule::Interval { secs } => { + if *secs >= 3600 { + format!("Every {}h", secs / 3600) + } else if *secs >= 60 { + format!("Every {}m", secs / 60) + } else { + format!("Every {}s", secs) + } + } + Schedule::Daily { hour, minute } => format!("Daily {hour:02}:{minute:02}"), + Schedule::Weekly { day, hour, minute } => { + let day_name = match day { + 0 => "Mon", + 1 => "Tue", + 2 => "Wed", + 3 => "Thu", + 4 => "Fri", + 5 => "Sat", + _ => "Sun", + }; + format!("{day_name} {hour:02}:{minute:02}") + } + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScheduledTask { + pub id: String, + pub name: String, + pub kind: JobKind, + pub schedule: Schedule, + pub enabled: bool, + pub last_run: Option>, + pub next_run: Option>, + pub last_status: Option, + /// Whether a job for this task is currently running. Skipped during serialization. + #[serde(default, skip_serializing)] + pub running: bool, + /// The job ID of the last submitted job. Skipped during serialization/deserialization. + #[serde(skip)] + pub last_job_id: Option, +} + +pub struct TaskScheduler { + tasks: Arc>>, + job_queue: Arc, + cancel: CancellationToken, + config: Arc>, + config_path: Option, +} + +impl TaskScheduler { + pub fn new( + job_queue: Arc, + cancel: CancellationToken, + config: Arc>, + config_path: Option, + ) -> Self { + let now = Utc::now(); + let default_tasks = vec![ + ScheduledTask { + id: "periodic_scan".to_string(), + name: "Periodic Scan".to_string(), + kind: JobKind::Scan { path: None }, + schedule: Schedule::Interval { secs: 3600 }, + enabled: true, + last_run: None, + next_run: Some(now + chrono::Duration::seconds(3600)), + last_status: None, + running: false, + last_job_id: None, + }, + ScheduledTask { + id: "integrity_check".to_string(), + name: "Integrity Check".to_string(), + kind: JobKind::VerifyIntegrity { media_ids: vec![] }, + schedule: Schedule::Weekly { + day: 0, + hour: 3, + minute: 0, + }, + enabled: false, + last_run: None, + next_run: None, + last_status: None, + running: false, + last_job_id: None, + }, + ScheduledTask { + id: "orphan_detection".to_string(), + name: "Orphan Detection".to_string(), + kind: JobKind::OrphanDetection, + schedule: Schedule::Daily { hour: 2, minute: 0 }, + enabled: false, + last_run: None, + next_run: None, + last_status: None, + running: false, + last_job_id: None, + }, + ScheduledTask { + id: "thumbnail_cleanup".to_string(), + name: "Thumbnail Cleanup".to_string(), + kind: JobKind::CleanupThumbnails, + schedule: Schedule::Weekly { + day: 6, + hour: 4, + minute: 0, + }, + enabled: false, + last_run: None, + next_run: None, + last_status: None, + running: false, + last_job_id: None, + }, + ]; + + Self { + tasks: Arc::new(RwLock::new(default_tasks)), + job_queue, + cancel, + config, + config_path, + } + } + + /// Restore saved task state from config. Should be called once after construction. + pub async fn restore_state(&self) { + let saved = self.config.read().await.scheduled_tasks.clone(); + if saved.is_empty() { + return; + } + let mut tasks = self.tasks.write().await; + for saved_task in &saved { + if let Some(task) = tasks.iter_mut().find(|t| t.id == saved_task.id) { + task.enabled = saved_task.enabled; + task.schedule = saved_task.schedule.clone(); + if let Some(Ok(dt)) = saved_task + .last_run + .as_ref() + .map(|s| DateTime::parse_from_rfc3339(s)) + { + task.last_run = Some(dt.with_timezone(&Utc)); + } + if task.enabled { + let from = task.last_run.unwrap_or_else(Utc::now); + task.next_run = Some(task.schedule.next_run(from)); + } else { + task.next_run = None; + } + } + } + } + + /// Persist current task state to config file. + async fn persist_task_state(&self) { + let tasks = self.tasks.read().await; + let task_configs: Vec = tasks + .iter() + .map(|t| crate::config::ScheduledTaskConfig { + id: t.id.clone(), + enabled: t.enabled, + schedule: t.schedule.clone(), + last_run: t.last_run.map(|dt| dt.to_rfc3339()), + }) + .collect(); + drop(tasks); + + { + let mut config = self.config.write().await; + config.scheduled_tasks = task_configs; + } + + if let Some(ref path) = self.config_path { + let config = self.config.read().await; + if let Err(e) = config.save_to_file(path) { + tracing::warn!(error = %e, "failed to persist scheduler state to config file"); + } + } + } + + pub async fn list_tasks(&self) -> Vec { + self.tasks.read().await.clone() + } + + pub async fn toggle_task(&self, id: &str) -> Option { + let result = { + let mut tasks = self.tasks.write().await; + if let Some(task) = tasks.iter_mut().find(|t| t.id == id) { + task.enabled = !task.enabled; + if task.enabled { + task.next_run = Some(task.schedule.next_run(Utc::now())); + } else { + task.next_run = None; + } + Some(task.enabled) + } else { + None + } + }; + if result.is_some() { + self.persist_task_state().await; + } + result + } + + /// Run a task immediately. Uses a single write lock to avoid TOCTOU races. + pub async fn run_now(&self, id: &str) -> Option { + let result = { + let mut tasks = self.tasks.write().await; + let task = tasks.iter_mut().find(|t| t.id == id)?; + + // Submit the job (cheap: sends to mpsc channel) + let job_id = self.job_queue.submit(task.kind.clone()).await; + + task.last_run = Some(Utc::now()); + task.last_status = Some("running".to_string()); + task.running = true; + task.last_job_id = Some(job_id); + if task.enabled { + task.next_run = Some(task.schedule.next_run(Utc::now())); + } + + Some(job_id.to_string()) + }; + if result.is_some() { + self.persist_task_state().await; + } + result + } + + /// Main scheduler loop. Uses a two-phase approach per tick to avoid + /// holding the write lock across await points. Returns when the + /// cancellation token is triggered. + pub async fn run(&self) { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(30)); + loop { + tokio::select! { + _ = interval.tick() => {} + _ = self.cancel.cancelled() => { + tracing::info!("scheduler shutting down"); + return; + } + } + + // Phase 1: Check completed jobs and update running status + { + use crate::jobs::JobStatus; + let mut tasks = self.tasks.write().await; + for task in tasks.iter_mut() { + if !task.running { + continue; + } + let Some(job_id) = task.last_job_id else { + continue; + }; + let Some(job) = self.job_queue.status(job_id).await else { + continue; + }; + match &job.status { + JobStatus::Completed { .. } => { + task.running = false; + task.last_status = Some("completed".to_string()); + } + JobStatus::Failed { error } => { + task.running = false; + task.last_status = Some(format!("failed: {error}")); + } + JobStatus::Cancelled => { + task.running = false; + task.last_status = Some("cancelled".to_string()); + } + _ => {} // still pending or running + } + } + } + + // Phase 2: Collect due tasks and submit jobs + let now = Utc::now(); + let mut to_submit: Vec<(usize, JobKind)> = Vec::new(); + + { + let mut tasks = self.tasks.write().await; + for (i, task) in tasks.iter_mut().enumerate() { + if !task.enabled || task.running { + continue; + } + let due = task.next_run.is_some_and(|next| now >= next); + if due { + to_submit.push((i, task.kind.clone())); + task.last_run = Some(now); + task.last_status = Some("running".to_string()); + task.running = true; + task.next_run = Some(task.schedule.next_run(now)); + } + } + } + + // Submit jobs without holding the lock + for (idx, kind) in to_submit { + let job_id = self.job_queue.submit(kind).await; + let mut tasks = self.tasks.write().await; + if let Some(task) = tasks.get_mut(idx) { + task.last_job_id = Some(job_id); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + #[test] + fn test_interval_next_run() { + let from = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap(); + let schedule = Schedule::Interval { secs: 3600 }; + let next = schedule.next_run(from); + assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 15, 13, 0, 0).unwrap()); + } + + #[test] + fn test_daily_next_run_future_today() { + // 10:00 UTC, schedule is 14:00 => same day + let from = Utc.with_ymd_and_hms(2025, 6, 15, 10, 0, 0).unwrap(); + let schedule = Schedule::Daily { + hour: 14, + minute: 0, + }; + let next = schedule.next_run(from); + assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 15, 14, 0, 0).unwrap()); + } + + #[test] + fn test_daily_next_run_past_today() { + // 16:00 UTC, schedule is 14:00 => next day + let from = Utc.with_ymd_and_hms(2025, 6, 15, 16, 0, 0).unwrap(); + let schedule = Schedule::Daily { + hour: 14, + minute: 0, + }; + let next = schedule.next_run(from); + assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 16, 14, 0, 0).unwrap()); + } + + #[test] + fn test_weekly_next_run() { + // 2025-06-15 is a Sunday (day 6). Target is Monday (day 0) at 03:00. + let from = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap(); + let schedule = Schedule::Weekly { + day: 0, + hour: 3, + minute: 0, + }; + let next = schedule.next_run(from); + assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 16, 3, 0, 0).unwrap()); + } + + #[test] + fn test_weekly_same_day_future() { + // 2025-06-15 is Sunday (day 6). Schedule is Sunday 14:00, current is 10:00 => today. + let from = Utc.with_ymd_and_hms(2025, 6, 15, 10, 0, 0).unwrap(); + let schedule = Schedule::Weekly { + day: 6, + hour: 14, + minute: 0, + }; + let next = schedule.next_run(from); + assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 15, 14, 0, 0).unwrap()); + } + + #[test] + fn test_weekly_same_day_past() { + // 2025-06-15 is Sunday (day 6). Schedule is Sunday 08:00, current is 10:00 => next week. + let from = Utc.with_ymd_and_hms(2025, 6, 15, 10, 0, 0).unwrap(); + let schedule = Schedule::Weekly { + day: 6, + hour: 8, + minute: 0, + }; + let next = schedule.next_run(from); + assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 22, 8, 0, 0).unwrap()); + } + + #[test] + fn test_serde_roundtrip() { + let task = ScheduledTask { + id: "test".to_string(), + name: "Test Task".to_string(), + kind: JobKind::Scan { path: None }, + schedule: Schedule::Interval { secs: 3600 }, + enabled: true, + last_run: Some(Utc::now()), + next_run: Some(Utc::now()), + last_status: Some("completed".to_string()), + running: true, + last_job_id: Some(Uuid::now_v7()), + }; + + let json = serde_json::to_string(&task).unwrap(); + let deserialized: ScheduledTask = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.id, "test"); + assert_eq!(deserialized.enabled, true); + // running defaults to false on deserialization (skip_serializing) + assert!(!deserialized.running); + // last_job_id is skipped entirely + assert!(deserialized.last_job_id.is_none()); + } + + #[test] + fn test_display_string() { + assert_eq!( + Schedule::Interval { secs: 3600 }.display_string(), + "Every 1h" + ); + assert_eq!( + Schedule::Interval { secs: 300 }.display_string(), + "Every 5m" + ); + assert_eq!( + Schedule::Interval { secs: 30 }.display_string(), + "Every 30s" + ); + assert_eq!( + Schedule::Daily { hour: 3, minute: 0 }.display_string(), + "Daily 03:00" + ); + assert_eq!( + Schedule::Weekly { + day: 0, + hour: 3, + minute: 0 + } + .display_string(), + "Mon 03:00" + ); + assert_eq!( + Schedule::Weekly { + day: 6, + hour: 14, + minute: 30 + } + .display_string(), + "Sun 14:30" + ); + } +} diff --git a/crates/pinakes-core/src/search.rs b/crates/pinakes-core/src/search.rs new file mode 100644 index 0000000..fa0278f --- /dev/null +++ b/crates/pinakes-core/src/search.rs @@ -0,0 +1,256 @@ +use serde::{Deserialize, Serialize}; +use winnow::combinator::{alt, delimited, preceded, repeat}; +use winnow::token::{take_till, take_while}; +use winnow::{ModalResult, Parser}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum SearchQuery { + FullText(String), + FieldMatch { field: String, value: String }, + And(Vec), + Or(Vec), + Not(Box), + Prefix(String), + Fuzzy(String), + TypeFilter(String), + TagFilter(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchRequest { + pub query: SearchQuery, + pub sort: SortOrder, + pub pagination: crate::model::Pagination, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchResults { + pub items: Vec, + pub total_count: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[derive(Default)] +pub enum SortOrder { + #[default] + Relevance, + DateAsc, + DateDesc, + NameAsc, + NameDesc, + SizeAsc, + SizeDesc, +} + +fn ws<'i>(input: &mut &'i str) -> ModalResult<&'i str> { + take_while(0.., ' ').parse_next(input) +} + +fn quoted_string(input: &mut &str) -> ModalResult { + delimited('"', take_till(0.., '"'), '"') + .map(|s: &str| s.to_string()) + .parse_next(input) +} + +fn bare_word(input: &mut &str) -> ModalResult { + take_while(1.., |c: char| !c.is_whitespace() && c != ')' && c != '(') + .map(|s: &str| s.to_string()) + .parse_next(input) +} + +fn word_or_quoted(input: &mut &str) -> ModalResult { + alt((quoted_string, bare_word)).parse_next(input) +} + +fn not_expr(input: &mut &str) -> ModalResult { + preceded(('-', ws), atom) + .map(|q| SearchQuery::Not(Box::new(q))) + .parse_next(input) +} + +fn field_match(input: &mut &str) -> ModalResult { + let field_name = + take_while(1.., |c: char| c.is_alphanumeric() || c == '_').map(|s: &str| s.to_string()); + (field_name, ':', word_or_quoted) + .map(|(field, _, value)| match field.as_str() { + "type" => SearchQuery::TypeFilter(value), + "tag" => SearchQuery::TagFilter(value), + _ => SearchQuery::FieldMatch { field, value }, + }) + .parse_next(input) +} + +fn prefix_expr(input: &mut &str) -> ModalResult { + let word = take_while(1.., |c: char| { + !c.is_whitespace() && c != ')' && c != '(' && c != '*' + }) + .map(|s: &str| s.to_string()); + (word, '*') + .map(|(w, _)| SearchQuery::Prefix(w)) + .parse_next(input) +} + +fn fuzzy_expr(input: &mut &str) -> ModalResult { + let word = take_while(1.., |c: char| { + !c.is_whitespace() && c != ')' && c != '(' && c != '~' + }) + .map(|s: &str| s.to_string()); + (word, '~') + .map(|(w, _)| SearchQuery::Fuzzy(w)) + .parse_next(input) +} + +fn paren_expr(input: &mut &str) -> ModalResult { + delimited(('(', ws), or_expr, (ws, ')')).parse_next(input) +} + +fn not_or_keyword(input: &mut &str) -> ModalResult<()> { + if let Some(rest) = input.strip_prefix("OR") + && (rest.is_empty() || rest.starts_with(' ') || rest.starts_with(')')) + { + return Err(winnow::error::ErrMode::Backtrack( + winnow::error::ContextError::new(), + )); + } + Ok(()) +} + +fn full_text(input: &mut &str) -> ModalResult { + not_or_keyword.parse_next(input)?; + word_or_quoted.map(SearchQuery::FullText).parse_next(input) +} + +fn atom(input: &mut &str) -> ModalResult { + alt(( + paren_expr, + not_expr, + field_match, + prefix_expr, + fuzzy_expr, + full_text, + )) + .parse_next(input) +} + +fn and_expr(input: &mut &str) -> ModalResult { + let first = atom.parse_next(input)?; + let rest: Vec = repeat(0.., preceded(ws, atom)).parse_next(input)?; + if rest.is_empty() { + Ok(first) + } else { + let mut terms = vec![first]; + terms.extend(rest); + Ok(SearchQuery::And(terms)) + } +} + +fn or_expr(input: &mut &str) -> ModalResult { + let first = and_expr.parse_next(input)?; + let rest: Vec = + repeat(0.., preceded((ws, "OR", ws), and_expr)).parse_next(input)?; + if rest.is_empty() { + Ok(first) + } else { + let mut terms = vec![first]; + terms.extend(rest); + Ok(SearchQuery::Or(terms)) + } +} + +pub fn parse_search_query(input: &str) -> crate::error::Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Ok(SearchQuery::FullText(String::new())); + } + let mut input = trimmed; + or_expr + .parse_next(&mut input) + .map_err(|e| crate::error::PinakesError::SearchParse(format!("{e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_simple_text() { + let q = parse_search_query("hello").unwrap(); + assert_eq!(q, SearchQuery::FullText("hello".into())); + } + + #[test] + fn test_field_match() { + let q = parse_search_query("artist:Beatles").unwrap(); + assert_eq!( + q, + SearchQuery::FieldMatch { + field: "artist".into(), + value: "Beatles".into() + } + ); + } + + #[test] + fn test_type_filter() { + let q = parse_search_query("type:pdf").unwrap(); + assert_eq!(q, SearchQuery::TypeFilter("pdf".into())); + } + + #[test] + fn test_tag_filter() { + let q = parse_search_query("tag:music").unwrap(); + assert_eq!(q, SearchQuery::TagFilter("music".into())); + } + + #[test] + fn test_and_implicit() { + let q = parse_search_query("hello world").unwrap(); + assert_eq!( + q, + SearchQuery::And(vec![ + SearchQuery::FullText("hello".into()), + SearchQuery::FullText("world".into()), + ]) + ); + } + + #[test] + fn test_or() { + let q = parse_search_query("hello OR world").unwrap(); + assert_eq!( + q, + SearchQuery::Or(vec![ + SearchQuery::FullText("hello".into()), + SearchQuery::FullText("world".into()), + ]) + ); + } + + #[test] + fn test_not() { + let q = parse_search_query("-excluded").unwrap(); + assert_eq!( + q, + SearchQuery::Not(Box::new(SearchQuery::FullText("excluded".into()))) + ); + } + + #[test] + fn test_prefix() { + let q = parse_search_query("hel*").unwrap(); + assert_eq!(q, SearchQuery::Prefix("hel".into())); + } + + #[test] + fn test_fuzzy() { + let q = parse_search_query("hello~").unwrap(); + assert_eq!(q, SearchQuery::Fuzzy("hello".into())); + } + + #[test] + fn test_quoted() { + let q = parse_search_query("\"hello world\"").unwrap(); + assert_eq!(q, SearchQuery::FullText("hello world".into())); + } +} diff --git a/crates/pinakes-core/src/storage/migrations.rs b/crates/pinakes-core/src/storage/migrations.rs new file mode 100644 index 0000000..bc0cbec --- /dev/null +++ b/crates/pinakes-core/src/storage/migrations.rs @@ -0,0 +1,26 @@ +use crate::error::{PinakesError, Result}; + +mod sqlite_migrations { + use refinery::embed_migrations; + embed_migrations!("../../migrations/sqlite"); +} + +mod postgres_migrations { + use refinery::embed_migrations; + embed_migrations!("../../migrations/postgres"); +} + +pub fn run_sqlite_migrations(conn: &mut rusqlite::Connection) -> Result<()> { + sqlite_migrations::migrations::runner() + .run(conn) + .map_err(|e| PinakesError::Migration(e.to_string()))?; + Ok(()) +} + +pub async fn run_postgres_migrations(client: &mut tokio_postgres::Client) -> Result<()> { + postgres_migrations::migrations::runner() + .run_async(client) + .await + .map_err(|e| PinakesError::Migration(e.to_string()))?; + Ok(()) +} diff --git a/crates/pinakes-core/src/storage/mod.rs b/crates/pinakes-core/src/storage/mod.rs new file mode 100644 index 0000000..73e4241 --- /dev/null +++ b/crates/pinakes-core/src/storage/mod.rs @@ -0,0 +1,209 @@ +pub mod migrations; +pub mod postgres; +pub mod sqlite; + +use std::path::PathBuf; +use std::sync::Arc; + +use uuid::Uuid; + +use crate::error::Result; +use crate::model::*; +use crate::search::{SearchRequest, SearchResults}; + +/// Statistics about the database. +#[derive(Debug, Clone, Default)] +pub struct DatabaseStats { + pub media_count: u64, + pub tag_count: u64, + pub collection_count: u64, + pub audit_count: u64, + pub database_size_bytes: u64, + pub backend_name: String, +} + +#[async_trait::async_trait] +pub trait StorageBackend: Send + Sync + 'static { + // Migrations + async fn run_migrations(&self) -> Result<()>; + + // Root directories + async fn add_root_dir(&self, path: PathBuf) -> Result<()>; + async fn list_root_dirs(&self) -> Result>; + async fn remove_root_dir(&self, path: &std::path::Path) -> Result<()>; + + // Media CRUD + async fn insert_media(&self, item: &MediaItem) -> Result<()>; + async fn get_media(&self, id: MediaId) -> Result; + async fn count_media(&self) -> Result; + async fn get_media_by_hash(&self, hash: &ContentHash) -> Result>; + async fn list_media(&self, pagination: &Pagination) -> Result>; + async fn update_media(&self, item: &MediaItem) -> Result<()>; + async fn delete_media(&self, id: MediaId) -> Result<()>; + async fn delete_all_media(&self) -> Result; + + // Tags + async fn create_tag(&self, name: &str, parent_id: Option) -> Result; + async fn get_tag(&self, id: Uuid) -> Result; + async fn list_tags(&self) -> Result>; + async fn delete_tag(&self, id: Uuid) -> Result<()>; + async fn tag_media(&self, media_id: MediaId, tag_id: Uuid) -> Result<()>; + async fn untag_media(&self, media_id: MediaId, tag_id: Uuid) -> Result<()>; + async fn get_media_tags(&self, media_id: MediaId) -> Result>; + async fn get_tag_descendants(&self, tag_id: Uuid) -> Result>; + + // Collections + async fn create_collection( + &self, + name: &str, + kind: CollectionKind, + description: Option<&str>, + filter_query: Option<&str>, + ) -> Result; + async fn get_collection(&self, id: Uuid) -> Result; + async fn list_collections(&self) -> Result>; + async fn delete_collection(&self, id: Uuid) -> Result<()>; + async fn add_to_collection( + &self, + collection_id: Uuid, + media_id: MediaId, + position: i32, + ) -> Result<()>; + async fn remove_from_collection(&self, collection_id: Uuid, media_id: MediaId) -> Result<()>; + async fn get_collection_members(&self, collection_id: Uuid) -> Result>; + + // Search + async fn search(&self, request: &SearchRequest) -> Result; + + // Audit + async fn record_audit(&self, entry: &AuditEntry) -> Result<()>; + async fn list_audit_entries( + &self, + media_id: Option, + pagination: &Pagination, + ) -> Result>; + + // Custom fields + async fn set_custom_field( + &self, + media_id: MediaId, + name: &str, + field: &CustomField, + ) -> Result<()>; + async fn get_custom_fields( + &self, + media_id: MediaId, + ) -> Result>; + async fn delete_custom_field(&self, media_id: MediaId, name: &str) -> Result<()>; + + // Batch operations (transactional where supported) + async fn batch_delete_media(&self, ids: &[MediaId]) -> Result { + let mut count = 0u64; + for id in ids { + self.delete_media(*id).await?; + count += 1; + } + Ok(count) + } + + async fn batch_tag_media(&self, media_ids: &[MediaId], tag_ids: &[Uuid]) -> Result { + let mut count = 0u64; + for media_id in media_ids { + for tag_id in tag_ids { + self.tag_media(*media_id, *tag_id).await?; + count += 1; + } + } + Ok(count) + } + + // Integrity + async fn list_media_paths(&self) -> Result>; + + // Batch metadata update + async fn batch_update_media( + &self, + ids: &[MediaId], + title: Option<&str>, + artist: Option<&str>, + album: Option<&str>, + genre: Option<&str>, + year: Option, + description: Option<&str>, + ) -> Result { + let mut count = 0u64; + for id in ids { + let mut item = self.get_media(*id).await?; + if let Some(v) = title { + item.title = Some(v.to_string()); + } + if let Some(v) = artist { + item.artist = Some(v.to_string()); + } + if let Some(v) = album { + item.album = Some(v.to_string()); + } + if let Some(v) = genre { + item.genre = Some(v.to_string()); + } + if let Some(v) = &year { + item.year = Some(*v); + } + if let Some(v) = description { + item.description = Some(v.to_string()); + } + item.updated_at = chrono::Utc::now(); + self.update_media(&item).await?; + count += 1; + } + Ok(count) + } + + // Saved searches + async fn save_search( + &self, + id: uuid::Uuid, + name: &str, + query: &str, + sort_order: Option<&str>, + ) -> Result<()>; + async fn list_saved_searches(&self) -> Result>; + async fn delete_saved_search(&self, id: uuid::Uuid) -> Result<()>; + + // Duplicates + async fn find_duplicates(&self) -> Result>>; + + // Database management + async fn database_stats(&self) -> Result; + async fn vacuum(&self) -> Result<()>; + async fn clear_all_data(&self) -> Result<()>; + + // Thumbnail helpers + /// List all media IDs, optionally filtering to those missing thumbnails. + async fn list_media_ids_for_thumbnails( + &self, + only_missing: bool, + ) -> Result>; + + // Library statistics + async fn library_statistics(&self) -> Result; +} + +/// Comprehensive library statistics. +#[derive(Debug, Clone, Default)] +pub struct LibraryStatistics { + pub total_media: u64, + pub total_size_bytes: u64, + pub avg_file_size_bytes: u64, + pub media_by_type: Vec<(String, u64)>, + pub storage_by_type: Vec<(String, u64)>, + pub newest_item: Option, + pub oldest_item: Option, + pub top_tags: Vec<(String, u64)>, + pub top_collections: Vec<(String, u64)>, + pub total_tags: u64, + pub total_collections: u64, + pub total_duplicates: u64, +} + +pub type DynStorageBackend = Arc; diff --git a/crates/pinakes-core/src/storage/postgres.rs b/crates/pinakes-core/src/storage/postgres.rs new file mode 100644 index 0000000..0099e50 --- /dev/null +++ b/crates/pinakes-core/src/storage/postgres.rs @@ -0,0 +1,1847 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use chrono::Utc; +use deadpool_postgres::{Config as PoolConfig, Pool, Runtime}; +use tokio_postgres::types::ToSql; +use tokio_postgres::{NoTls, Row}; +use uuid::Uuid; + +use crate::config::PostgresConfig; +use crate::error::{PinakesError, Result}; +use crate::media_type::MediaType; +use crate::model::*; +use crate::search::*; +use crate::storage::StorageBackend; + +pub struct PostgresBackend { + pool: Pool, +} + +impl PostgresBackend { + pub async fn new(config: &PostgresConfig) -> Result { + let mut pool_config = PoolConfig::new(); + pool_config.host = Some(config.host.clone()); + pool_config.port = Some(config.port); + pool_config.dbname = Some(config.database.clone()); + pool_config.user = Some(config.username.clone()); + pool_config.password = Some(config.password.clone()); + + let pool = pool_config + .create_pool(Some(Runtime::Tokio1), NoTls) + .map_err(|e| { + PinakesError::Database(format!("failed to create connection pool: {e}")) + })?; + + // Verify connectivity + let _ = pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("failed to connect to postgres: {e}")))?; + + Ok(Self { pool }) + } +} + +fn media_type_to_string(mt: &MediaType) -> String { + serde_json::to_value(mt) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_else(|| format!("{mt:?}").to_lowercase()) +} + +fn media_type_from_string(s: &str) -> Result { + serde_json::from_value(serde_json::Value::String(s.to_string())) + .map_err(|_| PinakesError::Database(format!("unknown media type: {s}"))) +} + +fn audit_action_to_string(action: &AuditAction) -> String { + // AuditAction uses serde rename_all = "snake_case" + serde_json::to_value(action) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_else(|| format!("{action}")) +} + +fn audit_action_from_string(s: &str) -> Result { + serde_json::from_value(serde_json::Value::String(s.to_string())) + .map_err(|_| PinakesError::Database(format!("unknown audit action: {s}"))) +} + +fn collection_kind_to_string(kind: &CollectionKind) -> String { + serde_json::to_value(kind) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_else(|| format!("{kind:?}").to_lowercase()) +} + +fn collection_kind_from_string(s: &str) -> Result { + serde_json::from_value(serde_json::Value::String(s.to_string())) + .map_err(|_| PinakesError::Database(format!("unknown collection kind: {s}"))) +} + +fn custom_field_type_to_string(ft: &CustomFieldType) -> String { + serde_json::to_value(ft) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_else(|| format!("{ft:?}").to_lowercase()) +} + +fn custom_field_type_from_string(s: &str) -> Result { + serde_json::from_value(serde_json::Value::String(s.to_string())) + .map_err(|_| PinakesError::Database(format!("unknown custom field type: {s}"))) +} + +fn row_to_media_item(row: &Row) -> Result { + let media_type_str: String = row.get("media_type"); + let media_type = media_type_from_string(&media_type_str)?; + + Ok(MediaItem { + id: MediaId(row.get("id")), + path: PathBuf::from(row.get::<_, String>("path")), + file_name: row.get("file_name"), + media_type, + content_hash: ContentHash(row.get("content_hash")), + file_size: row.get::<_, i64>("file_size") as u64, + title: row.get("title"), + artist: row.get("artist"), + album: row.get("album"), + genre: row.get("genre"), + year: row.get("year"), + duration_secs: row.get("duration_secs"), + description: row.get("description"), + thumbnail_path: row + .get::<_, Option>("thumbnail_path") + .map(PathBuf::from), + custom_fields: HashMap::new(), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + }) +} + +fn row_to_tag(row: &Row) -> Result { + Ok(Tag { + id: row.get("id"), + name: row.get("name"), + parent_id: row.get("parent_id"), + created_at: row.get("created_at"), + }) +} + +fn row_to_collection(row: &Row) -> Result { + let kind_str: String = row.get("kind"); + let kind = collection_kind_from_string(&kind_str)?; + + Ok(Collection { + id: row.get("id"), + name: row.get("name"), + description: row.get("description"), + kind, + filter_query: row.get("filter_query"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + }) +} + +fn row_to_audit_entry(row: &Row) -> Result { + let action_str: String = row.get("action"); + let action = audit_action_from_string(&action_str)?; + let media_id: Option = row.get("media_id"); + + Ok(AuditEntry { + id: row.get("id"), + media_id: media_id.map(MediaId), + action, + details: row.get("details"), + timestamp: row.get("timestamp"), + }) +} + +/// Recursively builds a tsquery string and collects parameters for a SearchQuery. +/// +/// Returns a tuple of: +/// - `sql_fragment`: the WHERE clause fragment (may include $N placeholders) +/// - `params`: boxed parameter values matching the placeholders +/// - `type_filters`: collected TypeFilter values to append as extra WHERE clauses +/// - `tag_filters`: collected TagFilter values to append as extra WHERE clauses +/// +/// `param_offset` is the current 1-based parameter index; the function returns +/// the next available offset. +fn build_search_clause( + query: &SearchQuery, + param_offset: &mut i32, + params: &mut Vec>, +) -> Result<(String, Vec, Vec)> { + let mut type_filters = Vec::new(); + let mut tag_filters = Vec::new(); + + let fragment = build_search_inner( + query, + param_offset, + params, + &mut type_filters, + &mut tag_filters, + )?; + + Ok((fragment, type_filters, tag_filters)) +} + +fn build_search_inner( + query: &SearchQuery, + offset: &mut i32, + params: &mut Vec>, + type_filters: &mut Vec, + tag_filters: &mut Vec, +) -> Result { + match query { + SearchQuery::FullText(text) => { + if text.is_empty() { + return Ok("TRUE".to_string()); + } + let idx = *offset; + *offset += 1; + params.push(Box::new(text.clone())); + Ok(format!( + "search_vector @@ plainto_tsquery('english', ${idx})" + )) + } + SearchQuery::Prefix(term) => { + let idx = *offset; + *offset += 1; + // Sanitize by stripping special tsquery characters + let sanitized = term.replace(['&', '|', '!', '(', ')', ':', '*', '\\', '\''], ""); + params.push(Box::new(format!("{sanitized}:*"))); + Ok(format!("search_vector @@ to_tsquery('english', ${idx})")) + } + SearchQuery::Fuzzy(term) => { + let idx_title = *offset; + *offset += 1; + let idx_artist = *offset; + *offset += 1; + params.push(Box::new(term.clone())); + params.push(Box::new(term.clone())); + Ok(format!( + "(similarity(COALESCE(title, ''), ${idx_title}) > 0.3 OR similarity(COALESCE(artist, ''), ${idx_artist}) > 0.3)" + )) + } + SearchQuery::FieldMatch { field, value } => { + let idx = *offset; + *offset += 1; + params.push(Box::new(value.clone())); + let col = match field.as_str() { + "title" => "title", + "artist" => "artist", + "album" => "album", + "genre" => "genre", + "file_name" => "file_name", + "description" => "description", + _ => { + return Err(PinakesError::SearchParse(format!("unknown field: {field}"))); + } + }; + Ok(format!("LOWER(COALESCE({col}, '')) = LOWER(${idx})")) + } + SearchQuery::TypeFilter(type_val) => { + type_filters.push(type_val.clone()); + Ok("TRUE".to_string()) + } + SearchQuery::TagFilter(tag_name) => { + tag_filters.push(tag_name.clone()); + Ok("TRUE".to_string()) + } + SearchQuery::And(children) => { + let mut parts = Vec::new(); + for child in children { + let frag = build_search_inner(child, offset, params, type_filters, tag_filters)?; + parts.push(frag); + } + if parts.is_empty() { + Ok("TRUE".to_string()) + } else { + Ok(format!("({})", parts.join(" AND "))) + } + } + SearchQuery::Or(children) => { + let mut parts = Vec::new(); + for child in children { + let frag = build_search_inner(child, offset, params, type_filters, tag_filters)?; + parts.push(frag); + } + if parts.is_empty() { + Ok("TRUE".to_string()) + } else { + Ok(format!("({})", parts.join(" OR "))) + } + } + SearchQuery::Not(inner) => { + let frag = build_search_inner(inner, offset, params, type_filters, tag_filters)?; + Ok(format!("NOT ({frag})")) + } + } +} + +fn sort_order_clause(sort: &SortOrder) -> &'static str { + match sort { + SortOrder::Relevance => "created_at DESC", // fallback when no FTS + SortOrder::DateAsc => "created_at ASC", + SortOrder::DateDesc => "created_at DESC", + SortOrder::NameAsc => "file_name ASC", + SortOrder::NameDesc => "file_name DESC", + SortOrder::SizeAsc => "file_size ASC", + SortOrder::SizeDesc => "file_size DESC", + } +} + +/// Returns a relevance-aware ORDER BY when there's an active FTS query. +fn sort_order_clause_with_rank(sort: &SortOrder, has_fts: bool) -> String { + match sort { + SortOrder::Relevance if has_fts => "ts_rank(search_vector, query) DESC".to_string(), + _ => sort_order_clause(sort).to_string(), + } +} + +#[async_trait::async_trait] +impl StorageBackend for PostgresBackend { + async fn run_migrations(&self) -> Result<()> { + let mut obj = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + // deadpool_postgres::Object derefs to tokio_postgres::Client, + // but refinery needs &mut Client. We can get the inner client. + let client: &mut tokio_postgres::Client = obj.as_mut(); + crate::storage::migrations::run_postgres_migrations(client).await + } + + // ---- Root directories ---- + + async fn add_root_dir(&self, path: PathBuf) -> Result<()> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + client + .execute( + "INSERT INTO root_dirs (path) VALUES ($1) ON CONFLICT (path) DO NOTHING", + &[&path.to_string_lossy().as_ref()], + ) + .await?; + + Ok(()) + } + + async fn list_root_dirs(&self) -> Result> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + let rows = client + .query("SELECT path FROM root_dirs ORDER BY path", &[]) + .await?; + + Ok(rows + .iter() + .map(|r| PathBuf::from(r.get::<_, String>(0))) + .collect()) + } + + async fn remove_root_dir(&self, path: &std::path::Path) -> Result<()> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + client + .execute( + "DELETE FROM root_dirs WHERE path = $1", + &[&path.to_string_lossy().as_ref()], + ) + .await?; + + Ok(()) + } + + // ---- Media CRUD ---- + + async fn insert_media(&self, item: &MediaItem) -> Result<()> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + let media_type_str = media_type_to_string(&item.media_type); + let path_str = item.path.to_string_lossy().to_string(); + let file_size = item.file_size as i64; + + client + .execute( + "INSERT INTO media_items ( + id, path, file_name, media_type, content_hash, file_size, + title, artist, album, genre, year, duration_secs, description, + thumbnail_path, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16 + )", + &[ + &item.id.0, + &path_str, + &item.file_name, + &media_type_str, + &item.content_hash.0, + &file_size, + &item.title, + &item.artist, + &item.album, + &item.genre, + &item.year, + &item.duration_secs, + &item.description, + &item + .thumbnail_path + .as_ref() + .map(|p| p.to_string_lossy().to_string()), + &item.created_at, + &item.updated_at, + ], + ) + .await?; + + // Insert custom fields + for (name, field) in &item.custom_fields { + let ft = custom_field_type_to_string(&field.field_type); + client + .execute( + "INSERT INTO custom_fields (media_id, field_name, field_type, field_value) + VALUES ($1, $2, $3, $4) + ON CONFLICT (media_id, field_name) DO UPDATE + SET field_type = EXCLUDED.field_type, field_value = EXCLUDED.field_value", + &[&item.id.0, &name, &ft, &field.value], + ) + .await?; + } + + Ok(()) + } + + async fn count_media(&self) -> Result { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; + let row = client + .query_one("SELECT COUNT(*) FROM media_items", &[]) + .await?; + let count: i64 = row.get(0); + Ok(count as u64) + } + + async fn get_media(&self, id: MediaId) -> Result { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + let row = client + .query_opt( + "SELECT id, path, file_name, media_type, content_hash, file_size, + title, artist, album, genre, year, duration_secs, description, + thumbnail_path, created_at, updated_at + FROM media_items WHERE id = $1", + &[&id.0], + ) + .await? + .ok_or_else(|| PinakesError::NotFound(format!("media item {id}")))?; + + let mut item = row_to_media_item(&row)?; + item.custom_fields = self.get_custom_fields(id).await?; + Ok(item) + } + + async fn get_media_by_hash(&self, hash: &ContentHash) -> Result> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + let row = client + .query_opt( + "SELECT id, path, file_name, media_type, content_hash, file_size, + title, artist, album, genre, year, duration_secs, description, + thumbnail_path, created_at, updated_at + FROM media_items WHERE content_hash = $1", + &[&hash.0], + ) + .await?; + + match row { + Some(r) => { + let mut item = row_to_media_item(&r)?; + item.custom_fields = self.get_custom_fields(item.id).await?; + Ok(Some(item)) + } + None => Ok(None), + } + } + + async fn list_media(&self, pagination: &Pagination) -> Result> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + let order_by = match pagination.sort.as_deref() { + Some("created_at_asc") => "created_at ASC", + Some("name_asc") => "file_name ASC", + Some("name_desc") => "file_name DESC", + Some("size_asc") => "file_size ASC", + Some("size_desc") => "file_size DESC", + Some("type_asc") => "media_type ASC", + Some("type_desc") => "media_type DESC", + // "created_at_desc" or any unrecognized value falls back to default + _ => "created_at DESC", + }; + let sql = format!( + "SELECT id, path, file_name, media_type, content_hash, file_size, + title, artist, album, genre, year, duration_secs, description, + thumbnail_path, created_at, updated_at + FROM media_items + ORDER BY {order_by} + LIMIT $1 OFFSET $2" + ); + + let rows = client + .query( + &sql, + &[&(pagination.limit as i64), &(pagination.offset as i64)], + ) + .await?; + + let mut items = Vec::with_capacity(rows.len()); + for row in &rows { + let item = row_to_media_item(row)?; + items.push(item); + } + + // Batch-load custom fields for all items + if !items.is_empty() { + let ids: Vec = items.iter().map(|i| i.id.0).collect(); + let cf_rows = client + .query( + "SELECT media_id, field_name, field_type, field_value + FROM custom_fields WHERE media_id = ANY($1)", + &[&ids], + ) + .await?; + + let mut cf_map: HashMap> = HashMap::new(); + for row in &cf_rows { + let mid: Uuid = row.get("media_id"); + let name: String = row.get("field_name"); + let ft_str: String = row.get("field_type"); + let value: String = row.get("field_value"); + let field_type = custom_field_type_from_string(&ft_str)?; + cf_map + .entry(mid) + .or_default() + .insert(name, CustomField { field_type, value }); + } + + for item in &mut items { + if let Some(fields) = cf_map.remove(&item.id.0) { + item.custom_fields = fields; + } + } + } + + Ok(items) + } + + async fn update_media(&self, item: &MediaItem) -> Result<()> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + let media_type_str = media_type_to_string(&item.media_type); + let path_str = item.path.to_string_lossy().to_string(); + let file_size = item.file_size as i64; + + let rows_affected = client + .execute( + "UPDATE media_items SET + path = $2, file_name = $3, media_type = $4, content_hash = $5, + file_size = $6, title = $7, artist = $8, album = $9, genre = $10, + year = $11, duration_secs = $12, description = $13, + thumbnail_path = $14, updated_at = $15 + WHERE id = $1", + &[ + &item.id.0, + &path_str, + &item.file_name, + &media_type_str, + &item.content_hash.0, + &file_size, + &item.title, + &item.artist, + &item.album, + &item.genre, + &item.year, + &item.duration_secs, + &item.description, + &item + .thumbnail_path + .as_ref() + .map(|p| p.to_string_lossy().to_string()), + &item.updated_at, + ], + ) + .await?; + + if rows_affected == 0 { + return Err(PinakesError::NotFound(format!("media item {}", item.id))); + } + + // Replace custom fields: delete all then re-insert + client + .execute( + "DELETE FROM custom_fields WHERE media_id = $1", + &[&item.id.0], + ) + .await?; + + for (name, field) in &item.custom_fields { + let ft = custom_field_type_to_string(&field.field_type); + client + .execute( + "INSERT INTO custom_fields (media_id, field_name, field_type, field_value) + VALUES ($1, $2, $3, $4)", + &[&item.id.0, &name, &ft, &field.value], + ) + .await?; + } + + Ok(()) + } + + async fn delete_media(&self, id: MediaId) -> Result<()> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + let rows_affected = client + .execute("DELETE FROM media_items WHERE id = $1", &[&id.0]) + .await?; + + if rows_affected == 0 { + return Err(PinakesError::NotFound(format!("media item {id}"))); + } + + Ok(()) + } + + async fn delete_all_media(&self) -> Result { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + let count: i64 = client + .query_one("SELECT COUNT(*) FROM media_items", &[]) + .await? + .get(0); + + client.execute("DELETE FROM media_items", &[]).await?; + + Ok(count as u64) + } + + // ---- Tags ---- + + async fn create_tag(&self, name: &str, parent_id: Option) -> Result { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + let id = Uuid::now_v7(); + let now = Utc::now(); + + client + .execute( + "INSERT INTO tags (id, name, parent_id, created_at) VALUES ($1, $2, $3, $4)", + &[&id, &name, &parent_id, &now], + ) + .await?; + + Ok(Tag { + id, + name: name.to_string(), + parent_id, + created_at: now, + }) + } + + async fn get_tag(&self, id: Uuid) -> Result { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + let row = client + .query_opt( + "SELECT id, name, parent_id, created_at FROM tags WHERE id = $1", + &[&id], + ) + .await? + .ok_or_else(|| PinakesError::TagNotFound(id.to_string()))?; + + row_to_tag(&row) + } + + async fn list_tags(&self) -> Result> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + let rows = client + .query( + "SELECT id, name, parent_id, created_at FROM tags ORDER BY name", + &[], + ) + .await?; + + rows.iter().map(row_to_tag).collect() + } + + async fn delete_tag(&self, id: Uuid) -> Result<()> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + let rows_affected = client + .execute("DELETE FROM tags WHERE id = $1", &[&id]) + .await?; + + if rows_affected == 0 { + return Err(PinakesError::TagNotFound(id.to_string())); + } + + Ok(()) + } + + async fn tag_media(&self, media_id: MediaId, tag_id: Uuid) -> Result<()> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + client + .execute( + "INSERT INTO media_tags (media_id, tag_id) VALUES ($1, $2) + ON CONFLICT (media_id, tag_id) DO NOTHING", + &[&media_id.0, &tag_id], + ) + .await?; + + Ok(()) + } + + async fn untag_media(&self, media_id: MediaId, tag_id: Uuid) -> Result<()> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + client + .execute( + "DELETE FROM media_tags WHERE media_id = $1 AND tag_id = $2", + &[&media_id.0, &tag_id], + ) + .await?; + + Ok(()) + } + + async fn get_media_tags(&self, media_id: MediaId) -> Result> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + let rows = client + .query( + "SELECT t.id, t.name, t.parent_id, t.created_at + FROM tags t + JOIN media_tags mt ON mt.tag_id = t.id + WHERE mt.media_id = $1 + ORDER BY t.name", + &[&media_id.0], + ) + .await?; + + rows.iter().map(row_to_tag).collect() + } + + async fn get_tag_descendants(&self, tag_id: Uuid) -> Result> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + let rows = client + .query( + "WITH RECURSIVE descendants AS ( + SELECT id, name, parent_id, created_at + FROM tags + WHERE parent_id = $1 + UNION ALL + SELECT t.id, t.name, t.parent_id, t.created_at + FROM tags t + JOIN descendants d ON t.parent_id = d.id + ) + SELECT id, name, parent_id, created_at FROM descendants ORDER BY name", + &[&tag_id], + ) + .await?; + + rows.iter().map(row_to_tag).collect() + } + + // ---- Collections ---- + + async fn create_collection( + &self, + name: &str, + kind: CollectionKind, + description: Option<&str>, + filter_query: Option<&str>, + ) -> Result { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + let id = Uuid::now_v7(); + let now = Utc::now(); + let kind_str = collection_kind_to_string(&kind); + + client + .execute( + "INSERT INTO collections (id, name, description, kind, filter_query, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)", + &[ + &id, + &name, + &description, + &kind_str, + &filter_query, + &now, + &now, + ], + ) + .await?; + + Ok(Collection { + id, + name: name.to_string(), + description: description.map(String::from), + kind, + filter_query: filter_query.map(String::from), + created_at: now, + updated_at: now, + }) + } + + async fn get_collection(&self, id: Uuid) -> Result { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + let row = client + .query_opt( + "SELECT id, name, description, kind, filter_query, created_at, updated_at + FROM collections WHERE id = $1", + &[&id], + ) + .await? + .ok_or_else(|| PinakesError::CollectionNotFound(id.to_string()))?; + + row_to_collection(&row) + } + + async fn list_collections(&self) -> Result> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + let rows = client + .query( + "SELECT id, name, description, kind, filter_query, created_at, updated_at + FROM collections ORDER BY name", + &[], + ) + .await?; + + rows.iter().map(row_to_collection).collect() + } + + async fn delete_collection(&self, id: Uuid) -> Result<()> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + let rows_affected = client + .execute("DELETE FROM collections WHERE id = $1", &[&id]) + .await?; + + if rows_affected == 0 { + return Err(PinakesError::CollectionNotFound(id.to_string())); + } + + Ok(()) + } + + async fn add_to_collection( + &self, + collection_id: Uuid, + media_id: MediaId, + position: i32, + ) -> Result<()> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + let now = Utc::now(); + + client + .execute( + "INSERT INTO collection_members (collection_id, media_id, position, added_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (collection_id, media_id) DO UPDATE SET position = EXCLUDED.position", + &[&collection_id, &media_id.0, &position, &now], + ) + .await?; + + // Update the collection's updated_at timestamp + client + .execute( + "UPDATE collections SET updated_at = $2 WHERE id = $1", + &[&collection_id, &now], + ) + .await?; + + Ok(()) + } + + async fn remove_from_collection(&self, collection_id: Uuid, media_id: MediaId) -> Result<()> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + client + .execute( + "DELETE FROM collection_members WHERE collection_id = $1 AND media_id = $2", + &[&collection_id, &media_id.0], + ) + .await?; + + let now = Utc::now(); + client + .execute( + "UPDATE collections SET updated_at = $2 WHERE id = $1", + &[&collection_id, &now], + ) + .await?; + + Ok(()) + } + + async fn get_collection_members(&self, collection_id: Uuid) -> Result> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + let rows = client + .query( + "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, + m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, + m.description, m.thumbnail_path, m.created_at, m.updated_at + FROM media_items m + JOIN collection_members cm ON cm.media_id = m.id + WHERE cm.collection_id = $1 + ORDER BY cm.position ASC", + &[&collection_id], + ) + .await?; + + let mut items = Vec::with_capacity(rows.len()); + for row in &rows { + items.push(row_to_media_item(row)?); + } + + // Batch-load custom fields + if !items.is_empty() { + let ids: Vec = items.iter().map(|i| i.id.0).collect(); + let cf_rows = client + .query( + "SELECT media_id, field_name, field_type, field_value + FROM custom_fields WHERE media_id = ANY($1)", + &[&ids], + ) + .await?; + + let mut cf_map: HashMap> = HashMap::new(); + for row in &cf_rows { + let mid: Uuid = row.get("media_id"); + let name: String = row.get("field_name"); + let ft_str: String = row.get("field_type"); + let value: String = row.get("field_value"); + let field_type = custom_field_type_from_string(&ft_str)?; + cf_map + .entry(mid) + .or_default() + .insert(name, CustomField { field_type, value }); + } + + for item in &mut items { + if let Some(fields) = cf_map.remove(&item.id.0) { + item.custom_fields = fields; + } + } + } + + Ok(items) + } + + // ---- Search ---- + + async fn search(&self, request: &SearchRequest) -> Result { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + let mut param_offset: i32 = 1; + let mut params: Vec> = Vec::new(); + + let (where_clause, type_filters, tag_filters) = + build_search_clause(&request.query, &mut param_offset, &mut params)?; + + // Detect whether we have an FTS condition (for rank-based sorting) + let has_fts = query_has_fts(&request.query); + + // Build additional WHERE conditions for type and tag filters + let mut extra_where = Vec::new(); + + for tf in &type_filters { + let idx = param_offset; + param_offset += 1; + params.push(Box::new(tf.clone())); + extra_where.push(format!("m.media_type = ${idx}")); + } + + for tg in &tag_filters { + let idx = param_offset; + param_offset += 1; + params.push(Box::new(tg.clone())); + extra_where.push(format!( + "EXISTS (SELECT 1 FROM media_tags mt JOIN tags t ON mt.tag_id = t.id WHERE mt.media_id = m.id AND t.name = ${idx})" + )); + } + + let full_where = if extra_where.is_empty() { + where_clause.clone() + } else { + format!("{where_clause} AND {}", extra_where.join(" AND ")) + }; + + let order_by = sort_order_clause_with_rank(&request.sort, has_fts); + + // For relevance sorting with FTS, we need a CTE or subquery to define 'query' + let (count_sql, select_sql) = if has_fts && request.sort == SortOrder::Relevance { + // Extract the FTS query parameter for ts_rank + // We wrap the query in a CTE that exposes the tsquery + let fts_param_idx = find_first_fts_param(&request.query); + let count = format!("SELECT COUNT(*) FROM media_items m WHERE {full_where}"); + let select = format!( + "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, + m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, + m.description, m.thumbnail_path, m.created_at, m.updated_at, + ts_rank(m.search_vector, plainto_tsquery('english', ${fts_param_idx})) AS rank + FROM media_items m + WHERE {full_where} + ORDER BY rank DESC + LIMIT ${} OFFSET ${}", + param_offset, + param_offset + 1 + ); + (count, select) + } else { + let count = format!("SELECT COUNT(*) FROM media_items m WHERE {full_where}"); + let select = format!( + "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, + m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, + m.description, m.thumbnail_path, m.created_at, m.updated_at + FROM media_items m + WHERE {full_where} + ORDER BY {order_by} + LIMIT ${} OFFSET ${}", + param_offset, + param_offset + 1 + ); + (count, select) + }; + + // Count query uses the current params (without limit/offset) + let count_params: Vec<&(dyn ToSql + Sync)> = params + .iter() + .map(|p| p.as_ref() as &(dyn ToSql + Sync)) + .collect(); + + let count_row = client.query_one(&count_sql, &count_params).await?; + let total_count: i64 = count_row.get(0); + + // Add pagination params + params.push(Box::new(request.pagination.limit as i64)); + params.push(Box::new(request.pagination.offset as i64)); + + let select_params: Vec<&(dyn ToSql + Sync)> = params + .iter() + .map(|p| p.as_ref() as &(dyn ToSql + Sync)) + .collect(); + + let rows = client.query(&select_sql, &select_params).await?; + + let mut items = Vec::with_capacity(rows.len()); + for row in &rows { + items.push(row_to_media_item(row)?); + } + + // Batch-load custom fields + if !items.is_empty() { + let ids: Vec = items.iter().map(|i| i.id.0).collect(); + let cf_rows = client + .query( + "SELECT media_id, field_name, field_type, field_value + FROM custom_fields WHERE media_id = ANY($1)", + &[&ids], + ) + .await?; + + let mut cf_map: HashMap> = HashMap::new(); + for row in &cf_rows { + let mid: Uuid = row.get("media_id"); + let name: String = row.get("field_name"); + let ft_str: String = row.get("field_type"); + let value: String = row.get("field_value"); + let field_type = custom_field_type_from_string(&ft_str)?; + cf_map + .entry(mid) + .or_default() + .insert(name, CustomField { field_type, value }); + } + + for item in &mut items { + if let Some(fields) = cf_map.remove(&item.id.0) { + item.custom_fields = fields; + } + } + } + + Ok(SearchResults { + items, + total_count: total_count as u64, + }) + } + + // ---- Audit ---- + + async fn record_audit(&self, entry: &AuditEntry) -> Result<()> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + let action_str = audit_action_to_string(&entry.action); + let media_id = entry.media_id.map(|m| m.0); + + client + .execute( + "INSERT INTO audit_log (id, media_id, action, details, timestamp) + VALUES ($1, $2, $3, $4, $5)", + &[ + &entry.id, + &media_id, + &action_str, + &entry.details, + &entry.timestamp, + ], + ) + .await?; + + Ok(()) + } + + async fn list_audit_entries( + &self, + media_id: Option, + pagination: &Pagination, + ) -> Result> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + let rows = match media_id { + Some(mid) => { + client + .query( + "SELECT id, media_id, action, details, timestamp + FROM audit_log + WHERE media_id = $1 + ORDER BY timestamp DESC + LIMIT $2 OFFSET $3", + &[ + &mid.0, + &(pagination.limit as i64), + &(pagination.offset as i64), + ], + ) + .await? + } + None => { + client + .query( + "SELECT id, media_id, action, details, timestamp + FROM audit_log + ORDER BY timestamp DESC + LIMIT $1 OFFSET $2", + &[&(pagination.limit as i64), &(pagination.offset as i64)], + ) + .await? + } + }; + + rows.iter().map(row_to_audit_entry).collect() + } + + // ---- Custom fields ---- + + async fn set_custom_field( + &self, + media_id: MediaId, + name: &str, + field: &CustomField, + ) -> Result<()> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + let ft = custom_field_type_to_string(&field.field_type); + + client + .execute( + "INSERT INTO custom_fields (media_id, field_name, field_type, field_value) + VALUES ($1, $2, $3, $4) + ON CONFLICT (media_id, field_name) DO UPDATE + SET field_type = EXCLUDED.field_type, field_value = EXCLUDED.field_value", + &[&media_id.0, &name, &ft, &field.value], + ) + .await?; + + Ok(()) + } + + async fn get_custom_fields(&self, media_id: MediaId) -> Result> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + let rows = client + .query( + "SELECT field_name, field_type, field_value + FROM custom_fields WHERE media_id = $1", + &[&media_id.0], + ) + .await?; + + let mut map = HashMap::new(); + for row in &rows { + let name: String = row.get("field_name"); + let ft_str: String = row.get("field_type"); + let value: String = row.get("field_value"); + let field_type = custom_field_type_from_string(&ft_str)?; + map.insert(name, CustomField { field_type, value }); + } + + Ok(map) + } + + async fn delete_custom_field(&self, media_id: MediaId, name: &str) -> Result<()> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + client + .execute( + "DELETE FROM custom_fields WHERE media_id = $1 AND field_name = $2", + &[&media_id.0, &name], + ) + .await?; + + Ok(()) + } + + // ---- Duplicates ---- + + async fn find_duplicates(&self) -> Result>> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + let rows = client + .query( + "SELECT * FROM media_items WHERE content_hash IN ( + SELECT content_hash FROM media_items GROUP BY content_hash HAVING COUNT(*) > 1 + ) ORDER BY content_hash, created_at", + &[], + ) + .await?; + + let mut items = Vec::with_capacity(rows.len()); + for row in &rows { + items.push(row_to_media_item(row)?); + } + + // Batch-load custom fields + if !items.is_empty() { + let ids: Vec = items.iter().map(|i| i.id.0).collect(); + let cf_rows = client + .query( + "SELECT media_id, field_name, field_type, field_value + FROM custom_fields WHERE media_id = ANY($1)", + &[&ids], + ) + .await?; + + let mut cf_map: HashMap> = HashMap::new(); + for row in &cf_rows { + let mid: Uuid = row.get("media_id"); + let name: String = row.get("field_name"); + let ft_str: String = row.get("field_type"); + let value: String = row.get("field_value"); + let field_type = custom_field_type_from_string(&ft_str)?; + cf_map + .entry(mid) + .or_default() + .insert(name, CustomField { field_type, value }); + } + + for item in &mut items { + if let Some(fields) = cf_map.remove(&item.id.0) { + item.custom_fields = fields; + } + } + } + + // Group by content_hash + let mut groups: Vec> = Vec::new(); + let mut current_hash = String::new(); + for item in items { + if item.content_hash.0 != current_hash { + current_hash = item.content_hash.0.clone(); + groups.push(Vec::new()); + } + if let Some(group) = groups.last_mut() { + group.push(item); + } + } + + Ok(groups) + } + + // ---- Database management ---- + + async fn database_stats(&self) -> Result { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + let media_count: i64 = client + .query_one("SELECT COUNT(*) FROM media_items", &[]) + .await? + .get(0); + let tag_count: i64 = client + .query_one("SELECT COUNT(*) FROM tags", &[]) + .await? + .get(0); + let collection_count: i64 = client + .query_one("SELECT COUNT(*) FROM collections", &[]) + .await? + .get(0); + let audit_count: i64 = client + .query_one("SELECT COUNT(*) FROM audit_log", &[]) + .await? + .get(0); + let database_size_bytes: i64 = client + .query_one("SELECT pg_database_size(current_database())", &[]) + .await? + .get(0); + + Ok(crate::storage::DatabaseStats { + media_count: media_count as u64, + tag_count: tag_count as u64, + collection_count: collection_count as u64, + audit_count: audit_count as u64, + database_size_bytes: database_size_bytes as u64, + backend_name: "postgres".to_string(), + }) + } + + async fn vacuum(&self) -> Result<()> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + client.execute("VACUUM ANALYZE", &[]).await?; + + Ok(()) + } + + async fn clear_all_data(&self) -> Result<()> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + client + .execute( + "TRUNCATE audit_log, custom_fields, collection_members, media_tags, media_items, tags, collections CASCADE", + &[], + ) + .await?; + + Ok(()) + } + + async fn list_media_paths(&self) -> Result> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + let rows = client + .query("SELECT id, path, content_hash FROM media_items", &[]) + .await?; + let mut results = Vec::with_capacity(rows.len()); + for row in rows { + let id: Uuid = row.get(0); + let path: String = row.get(1); + let hash: String = row.get(2); + results.push((MediaId(id), PathBuf::from(path), ContentHash::new(hash))); + } + Ok(results) + } + + async fn save_search( + &self, + id: Uuid, + name: &str, + query: &str, + sort_order: Option<&str>, + ) -> Result<()> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + let now = Utc::now(); + client + .execute( + "INSERT INTO saved_searches (id, name, query, sort_order, created_at) VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (id) DO UPDATE SET name = $2, query = $3, sort_order = $4", + &[&id, &name, &query, &sort_order, &now], + ) + .await?; + Ok(()) + } + + async fn list_saved_searches(&self) -> Result> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + let rows = client + .query("SELECT id, name, query, sort_order, created_at FROM saved_searches ORDER BY created_at DESC", &[]) + .await?; + let mut results = Vec::with_capacity(rows.len()); + for row in rows { + results.push(crate::model::SavedSearch { + id: row.get(0), + name: row.get(1), + query: row.get(2), + sort_order: row.get(3), + created_at: row.get(4), + }); + } + Ok(results) + } + + async fn delete_saved_search(&self, id: Uuid) -> Result<()> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + client + .execute("DELETE FROM saved_searches WHERE id = $1", &[&id]) + .await?; + Ok(()) + } + + async fn list_media_ids_for_thumbnails(&self, only_missing: bool) -> Result> { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + let sql = if only_missing { + "SELECT id FROM media_items WHERE thumbnail_path IS NULL ORDER BY created_at DESC" + } else { + "SELECT id FROM media_items ORDER BY created_at DESC" + }; + let rows = client.query(sql, &[]).await?; + let ids = rows + .iter() + .map(|r| { + let id: uuid::Uuid = r.get(0); + MediaId(id) + }) + .collect(); + Ok(ids) + } + + async fn library_statistics(&self) -> Result { + tokio::time::timeout( + std::time::Duration::from_secs(30), + self.library_statistics_inner(), + ) + .await + .map_err(|_| PinakesError::Database("library_statistics query timed out".to_string()))? + } +} + +impl PostgresBackend { + async fn library_statistics_inner(&self) -> Result { + let client = self + .pool + .get() + .await + .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; + + let row = client + .query_one( + "SELECT COUNT(*), COALESCE(SUM(file_size), 0) FROM media_items", + &[], + ) + .await?; + let total_media: i64 = row.get(0); + let total_size: i64 = row.get(1); + let avg_size = if total_media > 0 { + total_size / total_media + } else { + 0 + }; + + let rows = client.query("SELECT media_type, COUNT(*) FROM media_items GROUP BY media_type ORDER BY COUNT(*) DESC", &[]).await?; + let media_by_type: Vec<(String, u64)> = rows + .iter() + .map(|r| { + let mt: String = r.get(0); + let cnt: i64 = r.get(1); + (mt, cnt as u64) + }) + .collect(); + + let rows = client.query("SELECT media_type, COALESCE(SUM(file_size), 0) FROM media_items GROUP BY media_type ORDER BY SUM(file_size) DESC", &[]).await?; + let storage_by_type: Vec<(String, u64)> = rows + .iter() + .map(|r| { + let mt: String = r.get(0); + let sz: i64 = r.get(1); + (mt, sz as u64) + }) + .collect(); + + let newest: Option = client + .query_opt( + "SELECT created_at::text FROM media_items ORDER BY created_at DESC LIMIT 1", + &[], + ) + .await? + .map(|r| r.get(0)); + let oldest: Option = client + .query_opt( + "SELECT created_at::text FROM media_items ORDER BY created_at ASC LIMIT 1", + &[], + ) + .await? + .map(|r| r.get(0)); + + let rows = client.query( + "SELECT t.name, COUNT(*) as cnt FROM media_tags mt JOIN tags t ON mt.tag_id = t.id GROUP BY t.id, t.name ORDER BY cnt DESC LIMIT 10", + &[], + ).await?; + let top_tags: Vec<(String, u64)> = rows + .iter() + .map(|r| { + let name: String = r.get(0); + let cnt: i64 = r.get(1); + (name, cnt as u64) + }) + .collect(); + + let rows = client.query( + "SELECT c.name, COUNT(*) as cnt FROM collection_members cm JOIN collections c ON cm.collection_id = c.id GROUP BY c.id, c.name ORDER BY cnt DESC LIMIT 10", + &[], + ).await?; + let top_collections: Vec<(String, u64)> = rows + .iter() + .map(|r| { + let name: String = r.get(0); + let cnt: i64 = r.get(1); + (name, cnt as u64) + }) + .collect(); + + let total_tags: i64 = client + .query_one("SELECT COUNT(*) FROM tags", &[]) + .await? + .get(0); + let total_collections: i64 = client + .query_one("SELECT COUNT(*) FROM collections", &[]) + .await? + .get(0); + let total_duplicates: i64 = client.query_one( + "SELECT COUNT(*) FROM (SELECT content_hash FROM media_items GROUP BY content_hash HAVING COUNT(*) > 1) sub", + &[], + ).await?.get(0); + + Ok(super::LibraryStatistics { + total_media: total_media as u64, + total_size_bytes: total_size as u64, + avg_file_size_bytes: avg_size as u64, + media_by_type, + storage_by_type, + newest_item: newest, + oldest_item: oldest, + top_tags, + top_collections, + total_tags: total_tags as u64, + total_collections: total_collections as u64, + total_duplicates: total_duplicates as u64, + }) + } +} + +/// Check if a SearchQuery tree contains any FullText or Prefix node (i.e. uses the FTS index). +fn query_has_fts(query: &SearchQuery) -> bool { + match query { + SearchQuery::FullText(t) => !t.is_empty(), + SearchQuery::Prefix(_) => true, + SearchQuery::Fuzzy(_) => false, + SearchQuery::FieldMatch { .. } => false, + SearchQuery::TypeFilter(_) => false, + SearchQuery::TagFilter(_) => false, + SearchQuery::And(children) | SearchQuery::Or(children) => { + children.iter().any(query_has_fts) + } + SearchQuery::Not(inner) => query_has_fts(inner), + } +} + +/// Find the 1-based parameter index of the first FullText query parameter. +/// Used to pass the same text to ts_rank for relevance sorting. +/// Falls back to 1 if not found (should not happen when has_fts is true). +fn find_first_fts_param(query: &SearchQuery) -> i32 { + fn find_inner(query: &SearchQuery, offset: &mut i32) -> Option { + match query { + SearchQuery::FullText(t) => { + if t.is_empty() { + None + } else { + let idx = *offset; + *offset += 1; + Some(idx) + } + } + SearchQuery::Prefix(_) => { + let idx = *offset; + *offset += 1; + Some(idx) + } + SearchQuery::Fuzzy(_) => { + *offset += 2; // fuzzy uses two params + None + } + SearchQuery::FieldMatch { .. } => { + *offset += 1; + None + } + SearchQuery::TypeFilter(_) | SearchQuery::TagFilter(_) => None, + SearchQuery::And(children) | SearchQuery::Or(children) => { + for child in children { + if let Some(idx) = find_inner(child, offset) { + return Some(idx); + } + } + None + } + SearchQuery::Not(inner) => find_inner(inner, offset), + } + } + + let mut offset = 1; + find_inner(query, &mut offset).unwrap_or(1) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_media_type_roundtrip() { + let mt = MediaType::Mp3; + let s = media_type_to_string(&mt); + assert_eq!(s, "mp3"); + let parsed = media_type_from_string(&s).unwrap(); + assert_eq!(parsed, mt); + } + + #[test] + fn test_audit_action_roundtrip() { + let action = AuditAction::AddedToCollection; + let s = audit_action_to_string(&action); + assert_eq!(s, "added_to_collection"); + let parsed = audit_action_from_string(&s).unwrap(); + assert_eq!(parsed, action); + } + + #[test] + fn test_collection_kind_roundtrip() { + let kind = CollectionKind::Virtual; + let s = collection_kind_to_string(&kind); + assert_eq!(s, "virtual"); + let parsed = collection_kind_from_string(&s).unwrap(); + assert_eq!(parsed, kind); + } + + #[test] + fn test_custom_field_type_roundtrip() { + let ft = CustomFieldType::Boolean; + let s = custom_field_type_to_string(&ft); + assert_eq!(s, "boolean"); + let parsed = custom_field_type_from_string(&s).unwrap(); + assert_eq!(parsed, ft); + } + + #[test] + fn test_build_search_fulltext() { + let query = SearchQuery::FullText("hello world".into()); + let mut offset = 1; + let mut params: Vec> = Vec::new(); + let (clause, types, tags) = build_search_clause(&query, &mut offset, &mut params).unwrap(); + assert_eq!(clause, "search_vector @@ plainto_tsquery('english', $1)"); + assert!(types.is_empty()); + assert!(tags.is_empty()); + assert_eq!(offset, 2); + } + + #[test] + fn test_build_search_and() { + let query = SearchQuery::And(vec![ + SearchQuery::FullText("foo".into()), + SearchQuery::TypeFilter("pdf".into()), + ]); + let mut offset = 1; + let mut params: Vec> = Vec::new(); + let (clause, types, _tags) = build_search_clause(&query, &mut offset, &mut params).unwrap(); + assert!(clause.contains("AND")); + assert_eq!(types, vec!["pdf"]); + } + + #[test] + fn test_query_has_fts() { + assert!(query_has_fts(&SearchQuery::FullText("test".into()))); + assert!(!query_has_fts(&SearchQuery::FullText(String::new()))); + assert!(query_has_fts(&SearchQuery::Prefix("te".into()))); + assert!(!query_has_fts(&SearchQuery::Fuzzy("test".into()))); + assert!(query_has_fts(&SearchQuery::And(vec![ + SearchQuery::Fuzzy("x".into()), + SearchQuery::FullText("y".into()), + ]))); + } + + #[test] + fn test_sort_order_clause() { + assert_eq!(sort_order_clause(&SortOrder::DateAsc), "created_at ASC"); + assert_eq!(sort_order_clause(&SortOrder::NameDesc), "file_name DESC"); + assert_eq!(sort_order_clause(&SortOrder::SizeAsc), "file_size ASC"); + } +} diff --git a/crates/pinakes-core/src/storage/sqlite.rs b/crates/pinakes-core/src/storage/sqlite.rs new file mode 100644 index 0000000..a08ea4f --- /dev/null +++ b/crates/pinakes-core/src/storage/sqlite.rs @@ -0,0 +1,1649 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use chrono::{DateTime, NaiveDateTime, Utc}; +use rusqlite::{Connection, Row, params}; +use uuid::Uuid; + +use crate::error::{PinakesError, Result}; +use crate::media_type::MediaType; +use crate::model::*; +use crate::search::*; +use crate::storage::StorageBackend; + +/// Parse a UUID string from the database, returning a proper error on corruption. +fn parse_uuid(s: &str) -> rusqlite::Result { + Uuid::parse_str(s).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e)) + }) +} + +/// SQLite storage backend using WAL mode for concurrent reads. +/// +/// All async trait methods delegate to `tokio::task::spawn_blocking` because +/// `rusqlite::Connection` is synchronous. The connection is wrapped in an +/// `Arc>` so it can be shared across tasks safely. +pub struct SqliteBackend { + conn: Arc>, +} + +impl SqliteBackend { + /// Open (or create) a database at the given file path. + pub fn new(path: &Path) -> Result { + let conn = Connection::open(path)?; + Self::configure(conn) + } + + /// Create an in-memory database -- useful for tests. + pub fn in_memory() -> Result { + let conn = Connection::open_in_memory()?; + Self::configure(conn) + } + + fn configure(conn: Connection) -> Result { + conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;")?; + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + }) + } +} + +// --------------------------------------------------------------------------- +// Row-parsing helpers +// --------------------------------------------------------------------------- + +fn parse_datetime(s: &str) -> DateTime { + // Try RFC 3339 first (includes timezone), then fall back to a naive format. + if let Ok(dt) = DateTime::parse_from_rfc3339(s) { + return dt.with_timezone(&Utc); + } + if let Ok(naive) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f") { + return naive.and_utc(); + } + if let Ok(naive) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") { + return naive.and_utc(); + } + // Last resort -- epoch + tracing::warn!(value = %s, "failed to parse datetime, falling back to epoch"); + DateTime::default() +} + +fn parse_media_type(s: &str) -> MediaType { + // MediaType derives Serialize/Deserialize with serde rename_all = "lowercase", so + // a JSON round-trip uses e.g. `"mp3"`. We store the bare lowercase string in the + // database, so we must wrap it in quotes for serde_json. + let quoted = format!("\"{s}\""); + serde_json::from_str("ed).unwrap_or(MediaType::PlainText) +} + +fn media_type_to_str(mt: &MediaType) -> String { + // Produces e.g. `"mp3"` -- strip the surrounding quotes. + let s = serde_json::to_string(mt).unwrap_or_else(|_| "\"plaintext\"".to_string()); + s.trim_matches('"').to_string() +} + +fn row_to_media_item(row: &Row) -> rusqlite::Result { + let id_str: String = row.get("id")?; + let path_str: String = row.get("path")?; + let media_type_str: String = row.get("media_type")?; + let hash_str: String = row.get("content_hash")?; + let created_str: String = row.get("created_at")?; + let updated_str: String = row.get("updated_at")?; + + Ok(MediaItem { + id: MediaId(parse_uuid(&id_str)?), + path: PathBuf::from(path_str), + file_name: row.get("file_name")?, + media_type: parse_media_type(&media_type_str), + content_hash: ContentHash(hash_str), + file_size: row.get::<_, i64>("file_size")? as u64, + title: row.get("title")?, + artist: row.get("artist")?, + album: row.get("album")?, + genre: row.get("genre")?, + year: row.get("year")?, + duration_secs: row.get("duration_secs")?, + description: row.get("description")?, + thumbnail_path: row + .get::<_, Option>("thumbnail_path")? + .map(PathBuf::from), + custom_fields: HashMap::new(), // loaded separately + created_at: parse_datetime(&created_str), + updated_at: parse_datetime(&updated_str), + }) +} + +fn row_to_tag(row: &Row) -> rusqlite::Result { + let id_str: String = row.get("id")?; + let parent_str: Option = row.get("parent_id")?; + let created_str: String = row.get("created_at")?; + + Ok(Tag { + id: parse_uuid(&id_str)?, + name: row.get("name")?, + parent_id: parent_str.and_then(|s| Uuid::parse_str(&s).ok()), + created_at: parse_datetime(&created_str), + }) +} + +fn row_to_collection(row: &Row) -> rusqlite::Result { + let id_str: String = row.get("id")?; + let kind_str: String = row.get("kind")?; + let created_str: String = row.get("created_at")?; + let updated_str: String = row.get("updated_at")?; + + let kind = match kind_str.as_str() { + "virtual" => CollectionKind::Virtual, + _ => CollectionKind::Manual, + }; + + Ok(Collection { + id: parse_uuid(&id_str)?, + name: row.get("name")?, + description: row.get("description")?, + kind, + filter_query: row.get("filter_query")?, + created_at: parse_datetime(&created_str), + updated_at: parse_datetime(&updated_str), + }) +} + +fn row_to_audit_entry(row: &Row) -> rusqlite::Result { + let id_str: String = row.get("id")?; + let media_id_str: Option = row.get("media_id")?; + let action_str: String = row.get("action")?; + let ts_str: String = row.get("timestamp")?; + + let action = match action_str.as_str() { + "imported" => AuditAction::Imported, + "updated" => AuditAction::Updated, + "deleted" => AuditAction::Deleted, + "tagged" => AuditAction::Tagged, + "untagged" => AuditAction::Untagged, + "added_to_collection" => AuditAction::AddedToCollection, + "removed_from_collection" => AuditAction::RemovedFromCollection, + "opened" => AuditAction::Opened, + "scanned" => AuditAction::Scanned, + _ => AuditAction::Updated, // fallback + }; + + Ok(AuditEntry { + id: parse_uuid(&id_str)?, + media_id: media_id_str.and_then(|s| Uuid::parse_str(&s).ok().map(MediaId)), + action, + details: row.get("details")?, + timestamp: parse_datetime(&ts_str), + }) +} + +fn collection_kind_to_str(kind: CollectionKind) -> &'static str { + match kind { + CollectionKind::Manual => "manual", + CollectionKind::Virtual => "virtual", + } +} + +fn custom_field_type_to_str(ft: CustomFieldType) -> &'static str { + match ft { + CustomFieldType::Text => "text", + CustomFieldType::Number => "number", + CustomFieldType::Date => "date", + CustomFieldType::Boolean => "boolean", + } +} + +fn str_to_custom_field_type(s: &str) -> CustomFieldType { + match s { + "number" => CustomFieldType::Number, + "date" => CustomFieldType::Date, + "boolean" => CustomFieldType::Boolean, + _ => CustomFieldType::Text, + } +} + +fn load_custom_fields_sync( + db: &Connection, + media_id: MediaId, +) -> rusqlite::Result> { + let mut stmt = db.prepare( + "SELECT field_name, field_type, field_value FROM custom_fields WHERE media_id = ?1", + )?; + let rows = stmt.query_map(params![media_id.0.to_string()], |row| { + let name: String = row.get(0)?; + let ft_str: String = row.get(1)?; + let value: String = row.get(2)?; + Ok(( + name, + CustomField { + field_type: str_to_custom_field_type(&ft_str), + value, + }, + )) + })?; + let mut map = HashMap::new(); + for r in rows { + let (name, field) = r?; + map.insert(name, field); + } + Ok(map) +} + +fn load_custom_fields_batch(db: &Connection, items: &mut [MediaItem]) -> rusqlite::Result<()> { + if items.is_empty() { + return Ok(()); + } + // Build a simple query for all IDs + let ids: Vec = items.iter().map(|i| i.id.0.to_string()).collect(); + let placeholders: Vec = (1..=ids.len()).map(|i| format!("?{i}")).collect(); + let sql = format!( + "SELECT media_id, field_name, field_type, field_value FROM custom_fields WHERE media_id IN ({})", + placeholders.join(", ") + ); + let mut stmt = db.prepare(&sql)?; + let params: Vec<&dyn rusqlite::types::ToSql> = ids + .iter() + .map(|s| s as &dyn rusqlite::types::ToSql) + .collect(); + let rows = stmt.query_map(params.as_slice(), |row| { + let mid_str: String = row.get(0)?; + let name: String = row.get(1)?; + let ft_str: String = row.get(2)?; + let value: String = row.get(3)?; + Ok((mid_str, name, ft_str, value)) + })?; + + let mut fields_map: HashMap> = HashMap::new(); + for r in rows { + let (mid_str, name, ft_str, value) = r?; + fields_map.entry(mid_str).or_default().insert( + name, + CustomField { + field_type: str_to_custom_field_type(&ft_str), + value, + }, + ); + } + + for item in items.iter_mut() { + if let Some(fields) = fields_map.remove(&item.id.0.to_string()) { + item.custom_fields = fields; + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Search query translation +// --------------------------------------------------------------------------- + +/// Translate a `SearchQuery` into components that can be assembled into SQL. +/// +/// Returns `(fts_expr, where_clauses, join_clauses)` where: +/// - `fts_expr` is an FTS5 MATCH expression (may be empty), +/// - `where_clauses` are extra WHERE predicates (e.g. type filters), +/// - `join_clauses` are extra JOIN snippets (e.g. tag filters). +/// - `params` are bind parameter values corresponding to `?` placeholders in +/// where_clauses and join_clauses. +fn search_query_to_fts(query: &SearchQuery) -> (String, Vec, Vec, Vec) { + let mut wheres = Vec::new(); + let mut joins = Vec::new(); + let mut params = Vec::new(); + let fts = build_fts_expr(query, &mut wheres, &mut joins, &mut params); + (fts, wheres, joins, params) +} + +fn build_fts_expr( + query: &SearchQuery, + wheres: &mut Vec, + joins: &mut Vec, + params: &mut Vec, +) -> String { + match query { + SearchQuery::FullText(text) => { + if text.is_empty() { + String::new() + } else { + sanitize_fts_token(text) + } + } + SearchQuery::Prefix(prefix) => { + format!("{}*", sanitize_fts_token(prefix)) + } + SearchQuery::Fuzzy(term) => { + // FTS5 does not natively support fuzzy; fall back to prefix match + // as a best-effort approximation. + format!("{}*", sanitize_fts_token(term)) + } + SearchQuery::FieldMatch { field, value } => { + // FTS5 column filter syntax: `column:term` + let safe_field = sanitize_fts_token(field); + let safe_value = sanitize_fts_token(value); + format!("{safe_field}:{safe_value}") + } + SearchQuery::Not(inner) => { + let inner_expr = build_fts_expr(inner, wheres, joins, params); + if inner_expr.is_empty() { + String::new() + } else { + format!("NOT {inner_expr}") + } + } + SearchQuery::And(terms) => { + let parts: Vec = terms + .iter() + .map(|t| build_fts_expr(t, wheres, joins, params)) + .filter(|s| !s.is_empty()) + .collect(); + parts.join(" ") + } + SearchQuery::Or(terms) => { + let parts: Vec = terms + .iter() + .map(|t| build_fts_expr(t, wheres, joins, params)) + .filter(|s| !s.is_empty()) + .collect(); + if parts.len() <= 1 { + parts.into_iter().next().unwrap_or_default() + } else { + format!("({})", parts.join(" OR ")) + } + } + SearchQuery::TypeFilter(type_name) => { + wheres.push("m.media_type = ?".to_string()); + params.push(type_name.clone()); + String::new() + } + SearchQuery::TagFilter(tag_name) => { + // Use a unique alias per tag join to allow multiple tag filters. + let alias_idx = joins.len(); + let alias_mt = format!("mt{alias_idx}"); + let alias_t = format!("t{alias_idx}"); + joins.push(format!( + "JOIN media_tags {alias_mt} ON {alias_mt}.media_id = m.id \ + JOIN tags {alias_t} ON {alias_t}.id = {alias_mt}.tag_id AND {alias_t}.name = ?", + )); + params.push(tag_name.clone()); + String::new() + } + } +} + +/// Sanitize a string for use in FTS5 query expressions. +/// +/// Strips control characters, escapes double quotes, and wraps the result +/// in double quotes so it is treated as a single FTS5 term. +fn sanitize_fts_token(s: &str) -> String { + let cleaned: String = s + .chars() + .filter(|c| !c.is_control()) + .filter(|c| c.is_alphanumeric() || *c == '_' || *c == ' ') + .collect(); + let escaped = cleaned.replace('"', "\"\""); + format!("\"{escaped}\"") +} + +fn sort_order_to_sql(sort: &SortOrder) -> &'static str { + match sort { + SortOrder::Relevance => "m.created_at DESC", // FTS rank not easily portable; use date + SortOrder::DateAsc => "m.created_at ASC", + SortOrder::DateDesc => "m.created_at DESC", + SortOrder::NameAsc => "m.file_name ASC", + SortOrder::NameDesc => "m.file_name DESC", + SortOrder::SizeAsc => "m.file_size ASC", + SortOrder::SizeDesc => "m.file_size DESC", + } +} + +// --------------------------------------------------------------------------- +// StorageBackend implementation +// --------------------------------------------------------------------------- + +#[async_trait::async_trait] +impl StorageBackend for SqliteBackend { + // -- Migrations -------------------------------------------------------- + + async fn run_migrations(&self) -> Result<()> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let mut db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + crate::storage::migrations::run_sqlite_migrations(&mut db) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + // -- Root directories -------------------------------------------------- + + async fn add_root_dir(&self, path: PathBuf) -> Result<()> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute( + "INSERT OR IGNORE INTO root_dirs (path) VALUES (?1)", + params![path.to_string_lossy().as_ref()], + )?; + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn list_root_dirs(&self) -> Result> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare("SELECT path FROM root_dirs ORDER BY path")?; + let rows = stmt + .query_map([], |row| { + let p: String = row.get(0)?; + Ok(PathBuf::from(p)) + })? + .collect::>>()?; + Ok(rows) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn remove_root_dir(&self, path: &Path) -> Result<()> { + let path = path.to_path_buf(); + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute( + "DELETE FROM root_dirs WHERE path = ?1", + params![path.to_string_lossy().as_ref()], + )?; + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + // -- Media CRUD -------------------------------------------------------- + + async fn insert_media(&self, item: &MediaItem) -> Result<()> { + let item = item.clone(); + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute( + "INSERT INTO media_items (id, path, file_name, media_type, content_hash, \ + file_size, title, artist, album, genre, year, duration_secs, description, \ + thumbnail_path, created_at, updated_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)", + params![ + item.id.0.to_string(), + item.path.to_string_lossy().as_ref(), + item.file_name, + media_type_to_str(&item.media_type), + item.content_hash.0, + item.file_size as i64, + item.title, + item.artist, + item.album, + item.genre, + item.year, + item.duration_secs, + item.description, + item.thumbnail_path + .as_ref() + .map(|p| p.to_string_lossy().to_string()), + item.created_at.to_rfc3339(), + item.updated_at.to_rfc3339(), + ], + )?; + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn count_media(&self) -> Result { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let count: i64 = + db.query_row("SELECT COUNT(*) FROM media_items", [], |row| row.get(0))?; + Ok(count as u64) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn get_media(&self, id: MediaId) -> Result { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare( + "SELECT id, path, file_name, media_type, content_hash, file_size, \ + title, artist, album, genre, year, duration_secs, description, \ + thumbnail_path, created_at, updated_at FROM media_items WHERE id = ?1", + )?; + let mut item = stmt + .query_row(params![id.0.to_string()], row_to_media_item) + .map_err(|e| match e { + rusqlite::Error::QueryReturnedNoRows => { + PinakesError::NotFound(format!("media item {id}")) + } + other => PinakesError::from(other), + })?; + item.custom_fields = load_custom_fields_sync(&db, item.id)?; + Ok(item) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn get_media_by_hash(&self, hash: &ContentHash) -> Result> { + let hash = hash.clone(); + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare( + "SELECT id, path, file_name, media_type, content_hash, file_size, \ + title, artist, album, genre, year, duration_secs, description, \ + thumbnail_path, created_at, updated_at FROM media_items WHERE content_hash = ?1", + )?; + let result = stmt + .query_row(params![hash.0], row_to_media_item) + .optional()?; + if let Some(mut item) = result { + item.custom_fields = load_custom_fields_sync(&db, item.id)?; + Ok(Some(item)) + } else { + Ok(None) + } + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn list_media(&self, pagination: &Pagination) -> Result> { + let pagination = pagination.clone(); + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let order_by = match pagination.sort.as_deref() { + Some("created_at_asc") => "created_at ASC", + Some("name_asc") => "file_name ASC", + Some("name_desc") => "file_name DESC", + Some("size_asc") => "file_size ASC", + Some("size_desc") => "file_size DESC", + Some("type_asc") => "media_type ASC", + Some("type_desc") => "media_type DESC", + // "created_at_desc" or any unrecognized value falls back to default + _ => "created_at DESC", + }; + let sql = format!( + "SELECT id, path, file_name, media_type, content_hash, file_size, \ + title, artist, album, genre, year, duration_secs, description, \ + thumbnail_path, created_at, updated_at FROM media_items \ + ORDER BY {order_by} LIMIT ?1 OFFSET ?2" + ); + let mut stmt = db.prepare(&sql)?; + let mut rows = stmt + .query_map( + params![pagination.limit as i64, pagination.offset as i64], + row_to_media_item, + )? + .collect::>>()?; + load_custom_fields_batch(&db, &mut rows)?; + Ok(rows) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn update_media(&self, item: &MediaItem) -> Result<()> { + let item = item.clone(); + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let changed = db.execute( + "UPDATE media_items SET path = ?2, file_name = ?3, media_type = ?4, \ + content_hash = ?5, file_size = ?6, title = ?7, artist = ?8, album = ?9, \ + genre = ?10, year = ?11, duration_secs = ?12, description = ?13, \ + thumbnail_path = ?14, updated_at = ?15 WHERE id = ?1", + params![ + item.id.0.to_string(), + item.path.to_string_lossy().as_ref(), + item.file_name, + media_type_to_str(&item.media_type), + item.content_hash.0, + item.file_size as i64, + item.title, + item.artist, + item.album, + item.genre, + item.year, + item.duration_secs, + item.description, + item.thumbnail_path + .as_ref() + .map(|p| p.to_string_lossy().to_string()), + item.updated_at.to_rfc3339(), + ], + )?; + if changed == 0 { + return Err(PinakesError::NotFound(format!("media item {}", item.id))); + } + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn delete_media(&self, id: MediaId) -> Result<()> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let changed = db.execute( + "DELETE FROM media_items WHERE id = ?1", + params![id.0.to_string()], + )?; + if changed == 0 { + return Err(PinakesError::NotFound(format!("media item {id}"))); + } + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn delete_all_media(&self) -> Result { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let count: u64 = + db.query_row("SELECT COUNT(*) FROM media_items", [], |row| row.get(0))?; + db.execute("DELETE FROM media_items", [])?; + Ok(count) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + // -- Tags -------------------------------------------------------------- + + async fn create_tag(&self, name: &str, parent_id: Option) -> Result { + let name = name.to_string(); + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let id = Uuid::now_v7(); + let now = Utc::now(); + db.execute( + "INSERT INTO tags (id, name, parent_id, created_at) VALUES (?1, ?2, ?3, ?4)", + params![ + id.to_string(), + name, + parent_id.map(|p| p.to_string()), + now.to_rfc3339(), + ], + )?; + Ok(Tag { + id, + name, + parent_id, + created_at: now, + }) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn get_tag(&self, id: Uuid) -> Result { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = + db.prepare("SELECT id, name, parent_id, created_at FROM tags WHERE id = ?1")?; + stmt.query_row(params![id.to_string()], row_to_tag) + .map_err(|e| match e { + rusqlite::Error::QueryReturnedNoRows => { + PinakesError::TagNotFound(id.to_string()) + } + other => PinakesError::from(other), + }) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn list_tags(&self) -> Result> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = + db.prepare("SELECT id, name, parent_id, created_at FROM tags ORDER BY name")?; + let rows = stmt + .query_map([], row_to_tag)? + .collect::>>()?; + Ok(rows) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn delete_tag(&self, id: Uuid) -> Result<()> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let changed = db.execute("DELETE FROM tags WHERE id = ?1", params![id.to_string()])?; + if changed == 0 { + return Err(PinakesError::TagNotFound(id.to_string())); + } + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn tag_media(&self, media_id: MediaId, tag_id: Uuid) -> Result<()> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute( + "INSERT OR IGNORE INTO media_tags (media_id, tag_id) VALUES (?1, ?2)", + params![media_id.0.to_string(), tag_id.to_string()], + )?; + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn untag_media(&self, media_id: MediaId, tag_id: Uuid) -> Result<()> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute( + "DELETE FROM media_tags WHERE media_id = ?1 AND tag_id = ?2", + params![media_id.0.to_string(), tag_id.to_string()], + )?; + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn get_media_tags(&self, media_id: MediaId) -> Result> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare( + "SELECT t.id, t.name, t.parent_id, t.created_at \ + FROM tags t JOIN media_tags mt ON mt.tag_id = t.id \ + WHERE mt.media_id = ?1 ORDER BY t.name", + )?; + let rows = stmt + .query_map(params![media_id.0.to_string()], row_to_tag)? + .collect::>>()?; + Ok(rows) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn get_tag_descendants(&self, tag_id: Uuid) -> Result> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare( + "WITH RECURSIVE descendants(id, name, parent_id, created_at) AS ( \ + SELECT id, name, parent_id, created_at FROM tags WHERE parent_id = ?1 \ + UNION ALL \ + SELECT t.id, t.name, t.parent_id, t.created_at \ + FROM tags t JOIN descendants d ON t.parent_id = d.id \ + ) \ + SELECT id, name, parent_id, created_at FROM descendants ORDER BY name", + )?; + let rows = stmt + .query_map(params![tag_id.to_string()], row_to_tag)? + .collect::>>()?; + Ok(rows) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + // -- Collections ------------------------------------------------------- + + async fn create_collection( + &self, + name: &str, + kind: CollectionKind, + description: Option<&str>, + filter_query: Option<&str>, + ) -> Result { + let name = name.to_string(); + let description = description.map(|s| s.to_string()); + let filter_query = filter_query.map(|s| s.to_string()); + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let id = Uuid::now_v7(); + let now = Utc::now(); + db.execute( + "INSERT INTO collections (id, name, description, kind, filter_query, \ + created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + id.to_string(), + name, + description, + collection_kind_to_str(kind), + filter_query, + now.to_rfc3339(), + now.to_rfc3339(), + ], + )?; + Ok(Collection { + id, + name, + description, + kind, + filter_query, + created_at: now, + updated_at: now, + }) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn get_collection(&self, id: Uuid) -> Result { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare( + "SELECT id, name, description, kind, filter_query, created_at, updated_at \ + FROM collections WHERE id = ?1", + )?; + stmt.query_row(params![id.to_string()], row_to_collection) + .map_err(|e| match e { + rusqlite::Error::QueryReturnedNoRows => { + PinakesError::CollectionNotFound(id.to_string()) + } + other => PinakesError::from(other), + }) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn list_collections(&self) -> Result> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare( + "SELECT id, name, description, kind, filter_query, created_at, updated_at \ + FROM collections ORDER BY name", + )?; + let rows = stmt + .query_map([], row_to_collection)? + .collect::>>()?; + Ok(rows) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn delete_collection(&self, id: Uuid) -> Result<()> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let changed = db.execute( + "DELETE FROM collections WHERE id = ?1", + params![id.to_string()], + )?; + if changed == 0 { + return Err(PinakesError::CollectionNotFound(id.to_string())); + } + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn add_to_collection( + &self, + collection_id: Uuid, + media_id: MediaId, + position: i32, + ) -> Result<()> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let now = Utc::now(); + db.execute( + "INSERT OR REPLACE INTO collection_members \ + (collection_id, media_id, position, added_at) VALUES (?1, ?2, ?3, ?4)", + params![ + collection_id.to_string(), + media_id.0.to_string(), + position, + now.to_rfc3339(), + ], + )?; + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn remove_from_collection(&self, collection_id: Uuid, media_id: MediaId) -> Result<()> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute( + "DELETE FROM collection_members WHERE collection_id = ?1 AND media_id = ?2", + params![collection_id.to_string(), media_id.0.to_string()], + )?; + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn get_collection_members(&self, collection_id: Uuid) -> Result> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare( + "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, \ + m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, m.description, \ + m.thumbnail_path, m.created_at, m.updated_at \ + FROM media_items m \ + JOIN collection_members cm ON cm.media_id = m.id \ + WHERE cm.collection_id = ?1 \ + ORDER BY cm.position", + )?; + let mut rows = stmt + .query_map(params![collection_id.to_string()], row_to_media_item)? + .collect::>>()?; + load_custom_fields_batch(&db, &mut rows)?; + Ok(rows) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + // -- Search ------------------------------------------------------------ + + async fn search(&self, request: &SearchRequest) -> Result { + let request = request.clone(); + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + + let (fts_expr, where_clauses, join_clauses, bind_params) = + search_query_to_fts(&request.query); + + let use_fts = !fts_expr.is_empty(); + let order_by = sort_order_to_sql(&request.sort); + + // Build the base query. + let mut sql = String::from( + "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, \ + m.title, m.artist, m.album, m.genre, m.year, m.duration_secs, m.description, \ + m.thumbnail_path, m.created_at, m.updated_at FROM media_items m ", + ); + + if use_fts { + sql.push_str("JOIN media_fts ON media_fts.rowid = m.rowid "); + } + + for j in &join_clauses { + sql.push_str(j); + sql.push(' '); + } + + // Collect all bind parameters: first the filter params, then FTS + // match (if any), then LIMIT and OFFSET. + let mut all_params: Vec = bind_params.clone(); + + let mut conditions = where_clauses.clone(); + if use_fts { + conditions.push("media_fts MATCH ?".to_string()); + all_params.push(fts_expr.clone()); + } + + if !conditions.is_empty() { + sql.push_str("WHERE "); + sql.push_str(&conditions.join(" AND ")); + sql.push(' '); + } + + sql.push_str(&format!("ORDER BY {order_by} LIMIT ? OFFSET ?",)); + all_params.push(request.pagination.limit.to_string()); + all_params.push(request.pagination.offset.to_string()); + + let mut stmt = db.prepare(&sql)?; + let param_refs: Vec<&dyn rusqlite::types::ToSql> = all_params + .iter() + .map(|s| s as &dyn rusqlite::types::ToSql) + .collect(); + let mut items = stmt + .query_map(param_refs.as_slice(), row_to_media_item)? + .collect::>>()?; + load_custom_fields_batch(&db, &mut items)?; + + // Count query (same filters, no LIMIT/OFFSET) + let mut count_sql = String::from("SELECT COUNT(*) FROM media_items m "); + if use_fts { + count_sql.push_str("JOIN media_fts ON media_fts.rowid = m.rowid "); + } + for j in &join_clauses { + count_sql.push_str(j); + count_sql.push(' '); + } + if !conditions.is_empty() { + count_sql.push_str("WHERE "); + count_sql.push_str(&conditions.join(" AND ")); + } + + // Count query uses the same filter params (+ FTS match) but no LIMIT/OFFSET + let mut count_params: Vec = bind_params; + if use_fts { + count_params.push(fts_expr); + } + let count_param_refs: Vec<&dyn rusqlite::types::ToSql> = count_params + .iter() + .map(|s| s as &dyn rusqlite::types::ToSql) + .collect(); + let total_count: i64 = + db.query_row(&count_sql, count_param_refs.as_slice(), |row| row.get(0))?; + + Ok(SearchResults { + items, + total_count: total_count as u64, + }) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + // -- Audit ------------------------------------------------------------- + + async fn record_audit(&self, entry: &AuditEntry) -> Result<()> { + let entry = entry.clone(); + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute( + "INSERT INTO audit_log (id, media_id, action, details, timestamp) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + entry.id.to_string(), + entry.media_id.map(|mid| mid.0.to_string()), + entry.action.to_string(), + entry.details, + entry.timestamp.to_rfc3339(), + ], + )?; + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn list_audit_entries( + &self, + media_id: Option, + pagination: &Pagination, + ) -> Result> { + let pagination = pagination.clone(); + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + + let (sql, bind_media_id) = if let Some(mid) = media_id { + ( + "SELECT id, media_id, action, details, timestamp FROM audit_log \ + WHERE media_id = ?1 ORDER BY timestamp DESC LIMIT ?2 OFFSET ?3" + .to_string(), + Some(mid.0.to_string()), + ) + } else { + ( + "SELECT id, media_id, action, details, timestamp FROM audit_log \ + ORDER BY timestamp DESC LIMIT ?1 OFFSET ?2" + .to_string(), + None, + ) + }; + + let mut stmt = db.prepare(&sql)?; + let rows = if let Some(ref mid_str) = bind_media_id { + stmt.query_map( + params![mid_str, pagination.limit as i64, pagination.offset as i64], + row_to_audit_entry, + )? + .collect::>>()? + } else { + stmt.query_map( + params![pagination.limit as i64, pagination.offset as i64], + row_to_audit_entry, + )? + .collect::>>()? + }; + + Ok(rows) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + // -- Custom fields ----------------------------------------------------- + + async fn set_custom_field( + &self, + media_id: MediaId, + name: &str, + field: &CustomField, + ) -> Result<()> { + let name = name.to_string(); + let field = field.clone(); + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute( + "INSERT OR REPLACE INTO custom_fields (media_id, field_name, field_type, field_value) \ + VALUES (?1, ?2, ?3, ?4)", + params![ + media_id.0.to_string(), + name, + custom_field_type_to_str(field.field_type), + field.value, + ], + )?; + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn get_custom_fields(&self, media_id: MediaId) -> Result> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare( + "SELECT field_name, field_type, field_value FROM custom_fields WHERE media_id = ?1", + )?; + let rows = stmt.query_map(params![media_id.0.to_string()], |row| { + let name: String = row.get(0)?; + let ft_str: String = row.get(1)?; + let value: String = row.get(2)?; + Ok(( + name, + CustomField { + field_type: str_to_custom_field_type(&ft_str), + value, + }, + )) + })?; + + let mut map = HashMap::new(); + for r in rows { + let (name, field) = r?; + map.insert(name, field); + } + Ok(map) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn delete_custom_field(&self, media_id: MediaId, name: &str) -> Result<()> { + let name = name.to_string(); + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute( + "DELETE FROM custom_fields WHERE media_id = ?1 AND field_name = ?2", + params![media_id.0.to_string(), name], + )?; + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn batch_delete_media(&self, ids: &[MediaId]) -> Result { + let ids: Vec = ids.iter().map(|id| id.0.to_string()).collect(); + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute_batch("BEGIN IMMEDIATE")?; + let mut count = 0u64; + for id in &ids { + let rows = db.execute("DELETE FROM media_items WHERE id = ?1", params![id])?; + count += rows as u64; + } + db.execute_batch("COMMIT")?; + Ok(count) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn batch_tag_media(&self, media_ids: &[MediaId], tag_ids: &[Uuid]) -> Result { + let media_ids: Vec = media_ids.iter().map(|id| id.0.to_string()).collect(); + let tag_ids: Vec = tag_ids.iter().map(|id| id.to_string()).collect(); + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute_batch("BEGIN IMMEDIATE")?; + let mut count = 0u64; + for mid in &media_ids { + for tid in &tag_ids { + db.execute( + "INSERT OR IGNORE INTO media_tags (media_id, tag_id) VALUES (?1, ?2)", + params![mid, tid], + )?; + count += 1; + } + } + db.execute_batch("COMMIT")?; + Ok(count) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + // -- Duplicates ----------------------------------------------------------- + + async fn find_duplicates(&self) -> Result>> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare( + "SELECT * FROM media_items WHERE content_hash IN ( + SELECT content_hash FROM media_items GROUP BY content_hash HAVING COUNT(*) > 1 + ) ORDER BY content_hash, created_at", + )?; + let mut rows: Vec = stmt + .query_map([], row_to_media_item)? + .collect::>>()?; + + load_custom_fields_batch(&db, &mut rows)?; + + // Group by content_hash + let mut groups: Vec> = Vec::new(); + let mut current_hash = String::new(); + for item in rows { + if item.content_hash.0 != current_hash { + current_hash = item.content_hash.0.clone(); + groups.push(Vec::new()); + } + if let Some(group) = groups.last_mut() { + group.push(item); + } + } + + Ok(groups) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + // -- Database management ----------------------------------------------- + + async fn database_stats(&self) -> Result { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let media_count: i64 = + db.query_row("SELECT COUNT(*) FROM media_items", [], |row| row.get(0))?; + let tag_count: i64 = db.query_row("SELECT COUNT(*) FROM tags", [], |row| row.get(0))?; + let collection_count: i64 = + db.query_row("SELECT COUNT(*) FROM collections", [], |row| row.get(0))?; + let audit_count: i64 = + db.query_row("SELECT COUNT(*) FROM audit_log", [], |row| row.get(0))?; + let page_count: i64 = db.query_row("PRAGMA page_count", [], |row| row.get(0))?; + let page_size: i64 = db.query_row("PRAGMA page_size", [], |row| row.get(0))?; + let database_size_bytes = (page_count * page_size) as u64; + Ok(crate::storage::DatabaseStats { + media_count: media_count as u64, + tag_count: tag_count as u64, + collection_count: collection_count as u64, + audit_count: audit_count as u64, + database_size_bytes, + backend_name: "sqlite".to_string(), + }) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn vacuum(&self) -> Result<()> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute_batch("VACUUM")?; + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn clear_all_data(&self) -> Result<()> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute_batch( + "DELETE FROM audit_log; + DELETE FROM custom_fields; + DELETE FROM collection_members; + DELETE FROM media_tags; + DELETE FROM media_items; + DELETE FROM tags; + DELETE FROM collections;", + )?; + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn list_media_paths(&self) -> Result> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare("SELECT id, path, content_hash FROM media_items")?; + let rows = stmt.query_map([], |row| { + let id_str: String = row.get(0)?; + let path_str: String = row.get(1)?; + let hash_str: String = row.get(2)?; + let id = parse_uuid(&id_str)?; + Ok(( + MediaId(id), + PathBuf::from(path_str), + ContentHash::new(hash_str), + )) + })?; + let mut results = Vec::new(); + for row in rows { + results.push(row?); + } + Ok(results) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn save_search( + &self, + id: Uuid, + name: &str, + query: &str, + sort_order: Option<&str>, + ) -> Result<()> { + let conn = Arc::clone(&self.conn); + let id_str = id.to_string(); + let name = name.to_string(); + let query = query.to_string(); + let sort_order = sort_order.map(|s| s.to_string()); + let now = chrono::Utc::now().to_rfc3339(); + tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute( + "INSERT OR REPLACE INTO saved_searches (id, name, query, sort_order, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", + params![id_str, name, query, sort_order, now], + )?; + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn list_saved_searches(&self) -> Result> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn.lock().map_err(|e| PinakesError::Database(e.to_string()))?; + let mut stmt = db.prepare("SELECT id, name, query, sort_order, created_at FROM saved_searches ORDER BY created_at DESC")?; + let rows = stmt.query_map([], |row| { + let id_str: String = row.get(0)?; + let name: String = row.get(1)?; + let query: String = row.get(2)?; + let sort_order: Option = row.get(3)?; + let created_at_str: String = row.get(4)?; + let id = parse_uuid(&id_str)?; + Ok(crate::model::SavedSearch { + id, + name, + query, + sort_order, + created_at: parse_datetime(&created_at_str), + }) + })?; + let mut results = Vec::new(); + for row in rows { + results.push(row?); + } + Ok(results) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn delete_saved_search(&self, id: Uuid) -> Result<()> { + let conn = Arc::clone(&self.conn); + let id_str = id.to_string(); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + db.execute("DELETE FROM saved_searches WHERE id = ?1", params![id_str])?; + Ok(()) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + async fn list_media_ids_for_thumbnails(&self, only_missing: bool) -> Result> { + let conn = Arc::clone(&self.conn); + tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + let sql = if only_missing { + "SELECT id FROM media_items WHERE thumbnail_path IS NULL ORDER BY created_at DESC" + } else { + "SELECT id FROM media_items ORDER BY created_at DESC" + }; + let mut stmt = db.prepare(sql)?; + let ids: Vec = stmt + .query_map([], |r| { + let s: String = r.get(0)?; + Ok(MediaId(uuid::Uuid::parse_str(&s).unwrap_or_default())) + })? + .filter_map(|r| r.ok()) + .collect(); + Ok(ids) + }) + .await + .map_err(|e| PinakesError::Database(e.to_string()))? + } + + async fn library_statistics(&self) -> Result { + let conn = Arc::clone(&self.conn); + let fut = tokio::task::spawn_blocking(move || { + let db = conn + .lock() + .map_err(|e| PinakesError::Database(e.to_string()))?; + + let total_media: u64 = + db.query_row("SELECT COUNT(*) FROM media_items", [], |r| r.get(0))?; + let total_size: u64 = db.query_row( + "SELECT COALESCE(SUM(file_size), 0) FROM media_items", + [], + |r| r.get(0), + )?; + let avg_size: u64 = if total_media > 0 { + total_size / total_media + } else { + 0 + }; + + // Media count by type + let mut stmt = db.prepare("SELECT media_type, COUNT(*) FROM media_items GROUP BY media_type ORDER BY COUNT(*) DESC")?; + let media_by_type: Vec<(String, u64)> = stmt + .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, u64>(1)?)))? + .filter_map(|r| r.ok()) + .collect(); + + // Storage by type + let mut stmt = db.prepare("SELECT media_type, COALESCE(SUM(file_size), 0) FROM media_items GROUP BY media_type ORDER BY SUM(file_size) DESC")?; + let storage_by_type: Vec<(String, u64)> = stmt + .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, u64>(1)?)))? + .filter_map(|r| r.ok()) + .collect(); + + // Newest / oldest + let newest: Option = db + .query_row( + "SELECT created_at FROM media_items ORDER BY created_at DESC LIMIT 1", + [], + |r| r.get(0), + ) + .optional()?; + let oldest: Option = db + .query_row( + "SELECT created_at FROM media_items ORDER BY created_at ASC LIMIT 1", + [], + |r| r.get(0), + ) + .optional()?; + + // Top tags + let mut stmt = db.prepare( + "SELECT t.name, COUNT(*) as cnt FROM media_tags mt JOIN tags t ON mt.tag_id = t.id GROUP BY t.id ORDER BY cnt DESC LIMIT 10" + )?; + let top_tags: Vec<(String, u64)> = stmt + .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, u64>(1)?)))? + .filter_map(|r| r.ok()) + .collect(); + + // Top collections + let mut stmt = db.prepare( + "SELECT c.name, COUNT(*) as cnt FROM collection_members cm JOIN collections c ON cm.collection_id = c.id GROUP BY c.id ORDER BY cnt DESC LIMIT 10" + )?; + let top_collections: Vec<(String, u64)> = stmt + .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, u64>(1)?)))? + .filter_map(|r| r.ok()) + .collect(); + + let total_tags: u64 = db.query_row("SELECT COUNT(*) FROM tags", [], |r| r.get(0))?; + let total_collections: u64 = + db.query_row("SELECT COUNT(*) FROM collections", [], |r| r.get(0))?; + + // Duplicates: count of hashes that appear more than once + let total_duplicates: u64 = db.query_row( + "SELECT COUNT(*) FROM (SELECT content_hash FROM media_items GROUP BY content_hash HAVING COUNT(*) > 1)", + [], |r| r.get(0) + )?; + + Ok(super::LibraryStatistics { + total_media, + total_size_bytes: total_size, + avg_file_size_bytes: avg_size, + media_by_type, + storage_by_type, + newest_item: newest, + oldest_item: oldest, + top_tags, + top_collections, + total_tags, + total_collections, + total_duplicates, + }) + }); + tokio::time::timeout(std::time::Duration::from_secs(30), fut) + .await + .map_err(|_| PinakesError::Database("library_statistics query timed out".to_string()))? + .map_err(|e| PinakesError::Database(e.to_string()))? + } +} + +// Needed for `query_row(...).optional()` +use rusqlite::OptionalExtension; diff --git a/crates/pinakes-core/src/tags.rs b/crates/pinakes-core/src/tags.rs new file mode 100644 index 0000000..2c09ff6 --- /dev/null +++ b/crates/pinakes-core/src/tags.rs @@ -0,0 +1,43 @@ +use uuid::Uuid; + +use crate::error::Result; +use crate::model::{AuditAction, MediaId, Tag}; +use crate::storage::DynStorageBackend; + +pub async fn create_tag( + storage: &DynStorageBackend, + name: &str, + parent_id: Option, +) -> Result { + storage.create_tag(name, parent_id).await +} + +pub async fn tag_media(storage: &DynStorageBackend, media_id: MediaId, tag_id: Uuid) -> Result<()> { + storage.tag_media(media_id, tag_id).await?; + crate::audit::record_action( + storage, + Some(media_id), + AuditAction::Tagged, + Some(format!("tag_id={tag_id}")), + ) + .await +} + +pub async fn untag_media( + storage: &DynStorageBackend, + media_id: MediaId, + tag_id: Uuid, +) -> Result<()> { + storage.untag_media(media_id, tag_id).await?; + crate::audit::record_action( + storage, + Some(media_id), + AuditAction::Untagged, + Some(format!("tag_id={tag_id}")), + ) + .await +} + +pub async fn get_tag_tree(storage: &DynStorageBackend, tag_id: Uuid) -> Result> { + storage.get_tag_descendants(tag_id).await +} diff --git a/crates/pinakes-core/src/thumbnail.rs b/crates/pinakes-core/src/thumbnail.rs new file mode 100644 index 0000000..e41f008 --- /dev/null +++ b/crates/pinakes-core/src/thumbnail.rs @@ -0,0 +1,278 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + +use tracing::{info, warn}; + +use crate::config::ThumbnailConfig; +use crate::error::{PinakesError, Result}; +use crate::media_type::{MediaCategory, MediaType}; +use crate::model::MediaId; + +/// Generate a thumbnail for a media file and return the path to the thumbnail. +/// +/// Supports images (via `image` crate), videos (via ffmpeg), PDFs (via pdftoppm), +/// and EPUBs (via cover image extraction). +pub fn generate_thumbnail( + media_id: MediaId, + source_path: &Path, + media_type: MediaType, + thumbnail_dir: &Path, +) -> Result> { + generate_thumbnail_with_config( + media_id, + source_path, + media_type, + thumbnail_dir, + &ThumbnailConfig::default(), + ) +} + +pub fn generate_thumbnail_with_config( + media_id: MediaId, + source_path: &Path, + media_type: MediaType, + thumbnail_dir: &Path, + config: &ThumbnailConfig, +) -> Result> { + std::fs::create_dir_all(thumbnail_dir)?; + let thumb_path = thumbnail_dir.join(format!("{}.jpg", media_id)); + + let result = match media_type.category() { + MediaCategory::Image => { + if media_type.is_raw() { + generate_raw_thumbnail(source_path, &thumb_path, config) + } else if media_type == MediaType::Heic { + generate_heic_thumbnail(source_path, &thumb_path, config) + } else { + generate_image_thumbnail(source_path, &thumb_path, config) + } + } + MediaCategory::Video => generate_video_thumbnail(source_path, &thumb_path, config), + MediaCategory::Document => match media_type { + MediaType::Pdf => generate_pdf_thumbnail(source_path, &thumb_path, config), + MediaType::Epub => generate_epub_thumbnail(source_path, &thumb_path, config), + _ => return Ok(None), + }, + _ => return Ok(None), + }; + + match result { + Ok(()) => { + info!(media_id = %media_id, category = ?media_type.category(), "generated thumbnail"); + Ok(Some(thumb_path)) + } + Err(e) => { + warn!(media_id = %media_id, error = %e, "failed to generate thumbnail"); + Ok(None) + } + } +} + +fn generate_image_thumbnail(source: &Path, dest: &Path, config: &ThumbnailConfig) -> Result<()> { + let img = image::open(source) + .map_err(|e| PinakesError::MetadataExtraction(format!("image open: {e}")))?; + + let thumb = img.thumbnail(config.size, config.size); + + let mut output = std::fs::File::create(dest)?; + let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, config.quality); + thumb + .write_with_encoder(encoder) + .map_err(|e| PinakesError::MetadataExtraction(format!("thumbnail encode: {e}")))?; + + Ok(()) +} + +fn generate_video_thumbnail(source: &Path, dest: &Path, config: &ThumbnailConfig) -> Result<()> { + let ffmpeg = config.ffmpeg_path.as_deref().unwrap_or("ffmpeg"); + + let status = Command::new(ffmpeg) + .args(["-ss", &config.video_seek_secs.to_string(), "-i"]) + .arg(source) + .args([ + "-vframes", + "1", + "-vf", + &format!("scale={}:{}", config.size, config.size), + "-y", + ]) + .arg(dest) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map_err(|e| { + PinakesError::MetadataExtraction(format!("ffmpeg not found or failed to execute: {e}")) + })?; + + if !status.success() { + return Err(PinakesError::MetadataExtraction(format!( + "ffmpeg exited with status {}", + status + ))); + } + + Ok(()) +} + +fn generate_pdf_thumbnail(source: &Path, dest: &Path, config: &ThumbnailConfig) -> Result<()> { + // Use pdftoppm to render first page, then resize with image crate + let temp_prefix = dest.with_extension("tmp"); + let status = Command::new("pdftoppm") + .args(["-jpeg", "-f", "1", "-l", "1", "-singlefile"]) + .arg(source) + .arg(&temp_prefix) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map_err(|e| { + PinakesError::MetadataExtraction(format!( + "pdftoppm not found or failed to execute: {e}" + )) + })?; + + if !status.success() { + return Err(PinakesError::MetadataExtraction(format!( + "pdftoppm exited with status {}", + status + ))); + } + + // pdftoppm outputs .jpg + let rendered = temp_prefix.with_extension("jpg"); + if rendered.exists() { + // Resize to thumbnail size + let img = image::open(&rendered) + .map_err(|e| PinakesError::MetadataExtraction(format!("pdf thumbnail open: {e}")))?; + let thumb = img.thumbnail(config.size, config.size); + let mut output = std::fs::File::create(dest)?; + let encoder = + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, config.quality); + thumb + .write_with_encoder(encoder) + .map_err(|e| PinakesError::MetadataExtraction(format!("pdf thumbnail encode: {e}")))?; + let _ = std::fs::remove_file(&rendered); + Ok(()) + } else { + Err(PinakesError::MetadataExtraction( + "pdftoppm did not produce output".to_string(), + )) + } +} + +fn generate_epub_thumbnail(source: &Path, dest: &Path, config: &ThumbnailConfig) -> Result<()> { + // Try to extract cover image from EPUB + let mut doc = epub::doc::EpubDoc::new(source) + .map_err(|e| PinakesError::MetadataExtraction(format!("epub open: {e}")))?; + + let cover_data = doc.get_cover().map(|(data, _mime)| data).or_else(|| { + // Fallback: try to find a cover image in the resources + doc.get_resource("cover-image") + .map(|(data, _)| data) + .or_else(|| doc.get_resource("cover").map(|(data, _)| data)) + }); + + if let Some(data) = cover_data { + let img = image::load_from_memory(&data) + .map_err(|e| PinakesError::MetadataExtraction(format!("epub cover decode: {e}")))?; + let thumb = img.thumbnail(config.size, config.size); + let mut output = std::fs::File::create(dest)?; + let encoder = + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, config.quality); + thumb + .write_with_encoder(encoder) + .map_err(|e| PinakesError::MetadataExtraction(format!("epub thumbnail encode: {e}")))?; + Ok(()) + } else { + Err(PinakesError::MetadataExtraction( + "no cover image found in epub".to_string(), + )) + } +} + +fn generate_raw_thumbnail(source: &Path, dest: &Path, config: &ThumbnailConfig) -> Result<()> { + // Try dcraw to extract embedded JPEG preview, then resize + let temp_ppm = dest.with_extension("ppm"); + let status = Command::new("dcraw") + .args(["-e", "-c"]) + .arg(source) + .stdout(std::fs::File::create(&temp_ppm).map_err(|e| { + PinakesError::MetadataExtraction(format!("failed to create temp file: {e}")) + })?) + .stderr(std::process::Stdio::null()) + .status() + .map_err(|e| PinakesError::MetadataExtraction(format!("dcraw not found or failed: {e}")))?; + + if !status.success() { + let _ = std::fs::remove_file(&temp_ppm); + return Err(PinakesError::MetadataExtraction(format!( + "dcraw exited with status {}", + status + ))); + } + + // The extracted preview is typically a JPEG — try loading it + if temp_ppm.exists() { + let result = image::open(&temp_ppm); + let _ = std::fs::remove_file(&temp_ppm); + let img = result + .map_err(|e| PinakesError::MetadataExtraction(format!("raw preview decode: {e}")))?; + let thumb = img.thumbnail(config.size, config.size); + let mut output = std::fs::File::create(dest)?; + let encoder = + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, config.quality); + thumb + .write_with_encoder(encoder) + .map_err(|e| PinakesError::MetadataExtraction(format!("raw thumbnail encode: {e}")))?; + Ok(()) + } else { + Err(PinakesError::MetadataExtraction( + "dcraw did not produce output".to_string(), + )) + } +} + +fn generate_heic_thumbnail(source: &Path, dest: &Path, config: &ThumbnailConfig) -> Result<()> { + // Use heif-convert to convert to JPEG, then resize + let temp_jpg = dest.with_extension("tmp.jpg"); + let status = Command::new("heif-convert") + .arg(source) + .arg(&temp_jpg) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map_err(|e| { + PinakesError::MetadataExtraction(format!("heif-convert not found or failed: {e}")) + })?; + + if !status.success() { + let _ = std::fs::remove_file(&temp_jpg); + return Err(PinakesError::MetadataExtraction(format!( + "heif-convert exited with status {}", + status + ))); + } + + if temp_jpg.exists() { + let result = image::open(&temp_jpg); + let _ = std::fs::remove_file(&temp_jpg); + let img = + result.map_err(|e| PinakesError::MetadataExtraction(format!("heic decode: {e}")))?; + let thumb = img.thumbnail(config.size, config.size); + let mut output = std::fs::File::create(dest)?; + let encoder = + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, config.quality); + thumb + .write_with_encoder(encoder) + .map_err(|e| PinakesError::MetadataExtraction(format!("heic thumbnail encode: {e}")))?; + Ok(()) + } else { + Err(PinakesError::MetadataExtraction( + "heif-convert did not produce output".to_string(), + )) + } +} + +/// Returns the default thumbnail directory under the data dir. +pub fn default_thumbnail_dir() -> PathBuf { + crate::config::Config::default_data_dir().join("thumbnails") +} diff --git a/crates/pinakes-core/tests/integration_test.rs b/crates/pinakes-core/tests/integration_test.rs new file mode 100644 index 0000000..673efb8 --- /dev/null +++ b/crates/pinakes-core/tests/integration_test.rs @@ -0,0 +1,414 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use pinakes_core::model::*; +use pinakes_core::storage::StorageBackend; +use pinakes_core::storage::sqlite::SqliteBackend; + +async fn setup() -> Arc { + let backend = SqliteBackend::in_memory().expect("in-memory SQLite"); + backend.run_migrations().await.expect("migrations"); + Arc::new(backend) +} + +#[tokio::test] +async fn test_media_crud() { + let storage = setup().await; + + let now = chrono::Utc::now(); + let id = MediaId::new(); + let item = MediaItem { + id, + path: "/tmp/test.txt".into(), + file_name: "test.txt".to_string(), + media_type: pinakes_core::media_type::MediaType::PlainText, + content_hash: ContentHash::new("abc123".to_string()), + file_size: 100, + title: Some("Test Title".to_string()), + artist: None, + album: None, + genre: None, + year: Some(2024), + duration_secs: None, + description: Some("A test file".to_string()), + thumbnail_path: None, + custom_fields: HashMap::new(), + created_at: now, + updated_at: now, + }; + + // Insert + storage.insert_media(&item).await.unwrap(); + + // Get + let fetched = storage.get_media(id).await.unwrap(); + assert_eq!(fetched.id, id); + assert_eq!(fetched.title.as_deref(), Some("Test Title")); + assert_eq!(fetched.file_size, 100); + + // Get by hash + let by_hash = storage + .get_media_by_hash(&ContentHash::new("abc123".into())) + .await + .unwrap(); + assert!(by_hash.is_some()); + assert_eq!(by_hash.unwrap().id, id); + + // Update + let mut updated = fetched; + updated.title = Some("Updated Title".to_string()); + storage.update_media(&updated).await.unwrap(); + let re_fetched = storage.get_media(id).await.unwrap(); + assert_eq!(re_fetched.title.as_deref(), Some("Updated Title")); + + // List + let list = storage.list_media(&Pagination::default()).await.unwrap(); + assert_eq!(list.len(), 1); + + // Delete + storage.delete_media(id).await.unwrap(); + let result = storage.get_media(id).await; + assert!(result.is_err()); +} + +#[tokio::test] +async fn test_tags() { + let storage = setup().await; + + // Create tags + let parent = storage.create_tag("Music", None).await.unwrap(); + let child = storage.create_tag("Rock", Some(parent.id)).await.unwrap(); + + assert_eq!(parent.name, "Music"); + assert_eq!(child.parent_id, Some(parent.id)); + + // List tags + let tags = storage.list_tags().await.unwrap(); + assert_eq!(tags.len(), 2); + + // Get descendants + let descendants = storage.get_tag_descendants(parent.id).await.unwrap(); + assert!(descendants.iter().any(|t| t.name == "Rock")); + + // Tag media + let now = chrono::Utc::now(); + let id = MediaId::new(); + let item = MediaItem { + id, + path: "/tmp/song.mp3".into(), + file_name: "song.mp3".to_string(), + media_type: pinakes_core::media_type::MediaType::Mp3, + content_hash: ContentHash::new("hash1".to_string()), + file_size: 5000, + title: Some("Test Song".to_string()), + artist: Some("Test Artist".to_string()), + album: None, + genre: None, + year: None, + duration_secs: Some(180.0), + description: None, + thumbnail_path: None, + custom_fields: HashMap::new(), + created_at: now, + updated_at: now, + }; + storage.insert_media(&item).await.unwrap(); + storage.tag_media(id, parent.id).await.unwrap(); + + let media_tags = storage.get_media_tags(id).await.unwrap(); + assert_eq!(media_tags.len(), 1); + assert_eq!(media_tags[0].name, "Music"); + + // Untag + storage.untag_media(id, parent.id).await.unwrap(); + let media_tags = storage.get_media_tags(id).await.unwrap(); + assert_eq!(media_tags.len(), 0); + + // Delete tag + storage.delete_tag(child.id).await.unwrap(); + let tags = storage.list_tags().await.unwrap(); + assert_eq!(tags.len(), 1); +} + +#[tokio::test] +async fn test_collections() { + let storage = setup().await; + + let col = storage + .create_collection("Favorites", CollectionKind::Manual, Some("My faves"), None) + .await + .unwrap(); + assert_eq!(col.name, "Favorites"); + assert_eq!(col.kind, CollectionKind::Manual); + + let now = chrono::Utc::now(); + let id = MediaId::new(); + let item = MediaItem { + id, + path: "/tmp/doc.pdf".into(), + file_name: "doc.pdf".to_string(), + media_type: pinakes_core::media_type::MediaType::Pdf, + content_hash: ContentHash::new("pdfhash".to_string()), + file_size: 10000, + title: None, + artist: None, + album: None, + genre: None, + year: None, + duration_secs: None, + description: None, + thumbnail_path: None, + custom_fields: HashMap::new(), + created_at: now, + updated_at: now, + }; + storage.insert_media(&item).await.unwrap(); + + storage.add_to_collection(col.id, id, 0).await.unwrap(); + let members = storage.get_collection_members(col.id).await.unwrap(); + assert_eq!(members.len(), 1); + assert_eq!(members[0].id, id); + + storage.remove_from_collection(col.id, id).await.unwrap(); + let members = storage.get_collection_members(col.id).await.unwrap(); + assert_eq!(members.len(), 0); + + // List collections + let cols = storage.list_collections().await.unwrap(); + assert_eq!(cols.len(), 1); + + storage.delete_collection(col.id).await.unwrap(); + let cols = storage.list_collections().await.unwrap(); + assert_eq!(cols.len(), 0); +} + +#[tokio::test] +async fn test_custom_fields() { + let storage = setup().await; + + let now = chrono::Utc::now(); + let id = MediaId::new(); + let item = MediaItem { + id, + path: "/tmp/test.md".into(), + file_name: "test.md".to_string(), + media_type: pinakes_core::media_type::MediaType::Markdown, + content_hash: ContentHash::new("mdhash".to_string()), + file_size: 500, + title: None, + artist: None, + album: None, + genre: None, + year: None, + duration_secs: None, + description: None, + thumbnail_path: None, + custom_fields: HashMap::new(), + created_at: now, + updated_at: now, + }; + storage.insert_media(&item).await.unwrap(); + + // Set custom field + let field = CustomField { + field_type: CustomFieldType::Text, + value: "important".to_string(), + }; + storage + .set_custom_field(id, "priority", &field) + .await + .unwrap(); + + // Get custom fields + let fields = storage.get_custom_fields(id).await.unwrap(); + assert_eq!(fields.len(), 1); + assert_eq!(fields["priority"].value, "important"); + + // Verify custom fields are loaded with get_media + let media = storage.get_media(id).await.unwrap(); + assert_eq!(media.custom_fields.len(), 1); + assert_eq!(media.custom_fields["priority"].value, "important"); + + // Delete custom field + storage.delete_custom_field(id, "priority").await.unwrap(); + let fields = storage.get_custom_fields(id).await.unwrap(); + assert_eq!(fields.len(), 0); +} + +#[tokio::test] +async fn test_search() { + let storage = setup().await; + + let now = chrono::Utc::now(); + // Insert a few items + for (i, (name, title, artist)) in [ + ("song1.mp3", "Bohemian Rhapsody", "Queen"), + ("song2.mp3", "Stairway to Heaven", "Led Zeppelin"), + ("doc.pdf", "Rust Programming", ""), + ] + .iter() + .enumerate() + { + let item = MediaItem { + id: MediaId::new(), + path: format!("/tmp/{name}").into(), + file_name: name.to_string(), + media_type: pinakes_core::media_type::MediaType::from_path(std::path::Path::new(name)) + .unwrap(), + content_hash: ContentHash::new(format!("hash{i}")), + file_size: 1000 * (i as u64 + 1), + title: Some(title.to_string()), + artist: if artist.is_empty() { + None + } else { + Some(artist.to_string()) + }, + album: None, + genre: None, + year: None, + duration_secs: None, + description: None, + thumbnail_path: None, + custom_fields: HashMap::new(), + created_at: now, + updated_at: now, + }; + storage.insert_media(&item).await.unwrap(); + } + + // Full-text search + let request = pinakes_core::search::SearchRequest { + query: pinakes_core::search::parse_search_query("Bohemian").unwrap(), + sort: pinakes_core::search::SortOrder::Relevance, + pagination: Pagination::new(0, 50, None), + }; + let results = storage.search(&request).await.unwrap(); + assert_eq!(results.total_count, 1); + assert_eq!(results.items[0].title.as_deref(), Some("Bohemian Rhapsody")); + + // Type filter + let request = pinakes_core::search::SearchRequest { + query: pinakes_core::search::parse_search_query("type:pdf").unwrap(), + sort: pinakes_core::search::SortOrder::Relevance, + pagination: Pagination::new(0, 50, None), + }; + let results = storage.search(&request).await.unwrap(); + assert_eq!(results.total_count, 1); + assert_eq!(results.items[0].file_name, "doc.pdf"); +} + +#[tokio::test] +async fn test_audit_log() { + let storage = setup().await; + + let entry = AuditEntry { + id: uuid::Uuid::now_v7(), + media_id: None, + action: AuditAction::Scanned, + details: Some("test scan".to_string()), + timestamp: chrono::Utc::now(), + }; + storage.record_audit(&entry).await.unwrap(); + + let entries = storage + .list_audit_entries(None, &Pagination::new(0, 10, None)) + .await + .unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].action, AuditAction::Scanned); +} + +#[tokio::test] +async fn test_import_with_dedup() { + let storage = setup().await as pinakes_core::storage::DynStorageBackend; + + // Create a temp file + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("test.txt"); + std::fs::write(&file_path, "hello world").unwrap(); + + // First import + let result1 = pinakes_core::import::import_file(&storage, &file_path) + .await + .unwrap(); + assert!(!result1.was_duplicate); + + // Second import of same file + let result2 = pinakes_core::import::import_file(&storage, &file_path) + .await + .unwrap(); + assert!(result2.was_duplicate); + assert_eq!(result1.media_id, result2.media_id); +} + +#[tokio::test] +async fn test_root_dirs() { + let storage = setup().await; + + storage.add_root_dir("/tmp/music".into()).await.unwrap(); + storage.add_root_dir("/tmp/docs".into()).await.unwrap(); + + let dirs = storage.list_root_dirs().await.unwrap(); + assert_eq!(dirs.len(), 2); + + storage + .remove_root_dir(std::path::Path::new("/tmp/music")) + .await + .unwrap(); + let dirs = storage.list_root_dirs().await.unwrap(); + assert_eq!(dirs.len(), 1); + assert_eq!(dirs[0], std::path::PathBuf::from("/tmp/docs")); +} + +#[tokio::test] +async fn test_library_statistics_empty() { + let storage = setup().await; + let stats = storage.library_statistics().await.unwrap(); + assert_eq!(stats.total_media, 0); + assert_eq!(stats.total_size_bytes, 0); + assert_eq!(stats.avg_file_size_bytes, 0); + assert!(stats.media_by_type.is_empty()); + assert!(stats.storage_by_type.is_empty()); + assert!(stats.top_tags.is_empty()); + assert!(stats.top_collections.is_empty()); + assert!(stats.newest_item.is_none()); + assert!(stats.oldest_item.is_none()); + assert_eq!(stats.total_tags, 0); + assert_eq!(stats.total_collections, 0); + assert_eq!(stats.total_duplicates, 0); +} + +#[tokio::test] +async fn test_library_statistics_with_data() { + let storage = setup().await; + + let now = chrono::Utc::now(); + let item = MediaItem { + id: MediaId::new(), + path: "/tmp/stats_test.mp3".into(), + file_name: "stats_test.mp3".to_string(), + media_type: pinakes_core::media_type::MediaType::Mp3, + content_hash: ContentHash::new("stats_hash".to_string()), + file_size: 5000, + title: Some("Stats Song".to_string()), + artist: None, + album: None, + genre: None, + year: None, + duration_secs: Some(120.0), + description: None, + thumbnail_path: None, + custom_fields: HashMap::new(), + created_at: now, + updated_at: now, + }; + storage.insert_media(&item).await.unwrap(); + + let stats = storage.library_statistics().await.unwrap(); + assert_eq!(stats.total_media, 1); + assert_eq!(stats.total_size_bytes, 5000); + assert_eq!(stats.avg_file_size_bytes, 5000); + assert!(!stats.media_by_type.is_empty()); + assert!(stats.newest_item.is_some()); + assert!(stats.oldest_item.is_some()); +} diff --git a/crates/pinakes-server/Cargo.toml b/crates/pinakes-server/Cargo.toml new file mode 100644 index 0000000..a2bc53f --- /dev/null +++ b/crates/pinakes-server/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "pinakes-server" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +pinakes-core = { path = "../pinakes-core" } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } +chrono = { workspace = true } +uuid = { workspace = true } +thiserror = { workspace = true } +anyhow = { workspace = true } +clap = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +axum = { workspace = true } +tower = { workspace = true } +tower-http = { workspace = true } +governor = { workspace = true } +tower_governor = { workspace = true } +tokio-util = { version = "0.7", features = ["io"] } +argon2 = { workspace = true } +rand = "0.9" + +[dev-dependencies] +http-body-util = "0.1" diff --git a/crates/pinakes-server/src/app.rs b/crates/pinakes-server/src/app.rs new file mode 100644 index 0000000..8702cd2 --- /dev/null +++ b/crates/pinakes-server/src/app.rs @@ -0,0 +1,244 @@ +use std::sync::Arc; + +use axum::Router; +use axum::extract::DefaultBodyLimit; +use axum::http::{HeaderValue, Method, header}; +use axum::middleware; +use axum::routing::{delete, get, patch, post, put}; +use tower_governor::GovernorLayer; +use tower_governor::governor::GovernorConfigBuilder; +use tower_http::cors::CorsLayer; +use tower_http::trace::TraceLayer; + +use crate::auth; +use crate::routes; +use crate::state::AppState; + +pub fn create_router(state: AppState) -> Router { + // Global rate limit: 100 requests/sec per IP + let global_governor = Arc::new( + GovernorConfigBuilder::default() + .per_second(1) + .burst_size(100) + .finish() + .unwrap(), + ); + + // Strict rate limit for login: 5 requests/min per IP + let login_governor = Arc::new( + GovernorConfigBuilder::default() + .per_second(12) // replenish one every 12 seconds + .burst_size(5) + .finish() + .unwrap(), + ); + + // Login route with strict rate limiting + let login_route = Router::new() + .route("/auth/login", post(routes::auth::login)) + .layer(GovernorLayer { + config: login_governor, + }); + + // Read-only routes: any authenticated user (Viewer+) + let viewer_routes = Router::new() + .route("/health", get(routes::health::health)) + .route("/media/count", get(routes::media::get_media_count)) + .route("/media", get(routes::media::list_media)) + .route("/media/{id}", get(routes::media::get_media)) + .route("/media/{id}/stream", get(routes::media::stream_media)) + .route("/media/{id}/thumbnail", get(routes::media::get_thumbnail)) + .route("/media/{media_id}/tags", get(routes::tags::get_media_tags)) + .route("/search", get(routes::search::search)) + .route("/search", post(routes::search::search_post)) + .route("/tags", get(routes::tags::list_tags)) + .route("/tags/{id}", get(routes::tags::get_tag)) + .route("/collections", get(routes::collections::list_collections)) + .route( + "/collections/{id}", + get(routes::collections::get_collection), + ) + .route( + "/collections/{id}/members", + get(routes::collections::get_members), + ) + .route("/audit", get(routes::audit::list_audit)) + .route("/scan/status", get(routes::scan::scan_status)) + .route("/config", get(routes::config::get_config)) + .route("/config/ui", get(routes::config::get_ui_config)) + .route("/database/stats", get(routes::database::database_stats)) + .route("/duplicates", get(routes::duplicates::list_duplicates)) + // Statistics + .route("/statistics", get(routes::statistics::library_statistics)) + // Scheduled tasks (read) + .route( + "/tasks/scheduled", + get(routes::scheduled_tasks::list_scheduled_tasks), + ) + // Jobs + .route("/jobs", get(routes::jobs::list_jobs)) + .route("/jobs/{id}", get(routes::jobs::get_job)) + // Saved searches (read) + .route( + "/searches/saved", + get(routes::saved_searches::list_saved_searches), + ) + // Webhooks (read) + .route("/webhooks", get(routes::webhooks::list_webhooks)) + // Auth endpoints (self-service) — login handled separately with stricter rate limit + .route("/auth/logout", post(routes::auth::logout)) + .route("/auth/me", get(routes::auth::me)); + + // Write routes: Editor+ required + let editor_routes = Router::new() + .route("/media/import", post(routes::media::import_media)) + .route( + "/media/import/options", + post(routes::media::import_with_options), + ) + .route("/media/import/batch", post(routes::media::batch_import)) + .route( + "/media/import/directory", + post(routes::media::import_directory_endpoint), + ) + .route( + "/media/import/preview", + post(routes::media::preview_directory), + ) + .route("/media/batch/tag", post(routes::media::batch_tag)) + .route("/media/batch/delete", post(routes::media::batch_delete)) + .route("/media/batch/update", patch(routes::media::batch_update)) + .route( + "/media/batch/collection", + post(routes::media::batch_add_to_collection), + ) + .route("/media/all", delete(routes::media::delete_all_media)) + .route("/media/{id}", patch(routes::media::update_media)) + .route("/media/{id}", delete(routes::media::delete_media)) + .route("/media/{id}/open", post(routes::media::open_media)) + .route( + "/media/{id}/custom-fields", + post(routes::media::set_custom_field), + ) + .route( + "/media/{id}/custom-fields/{name}", + delete(routes::media::delete_custom_field), + ) + .route("/tags", post(routes::tags::create_tag)) + .route("/tags/{id}", delete(routes::tags::delete_tag)) + .route("/media/{media_id}/tags", post(routes::tags::tag_media)) + .route( + "/media/{media_id}/tags/{tag_id}", + delete(routes::tags::untag_media), + ) + .route("/collections", post(routes::collections::create_collection)) + .route( + "/collections/{id}", + delete(routes::collections::delete_collection), + ) + .route( + "/collections/{id}/members", + post(routes::collections::add_member), + ) + .route( + "/collections/{collection_id}/members/{media_id}", + delete(routes::collections::remove_member), + ) + .route("/scan", post(routes::scan::trigger_scan)) + .route("/jobs/{id}/cancel", post(routes::jobs::cancel_job)) + // Saved searches (write) + .route( + "/searches/saved", + post(routes::saved_searches::create_saved_search), + ) + .route( + "/searches/saved/{id}", + delete(routes::saved_searches::delete_saved_search), + ) + // Integrity + .route( + "/jobs/orphan-detection", + post(routes::integrity::trigger_orphan_detection), + ) + .route( + "/jobs/verify-integrity", + post(routes::integrity::trigger_verify_integrity), + ) + .route( + "/jobs/cleanup-thumbnails", + post(routes::integrity::trigger_cleanup_thumbnails), + ) + .route( + "/jobs/generate-thumbnails", + post(routes::integrity::generate_all_thumbnails), + ) + .route("/orphans/resolve", post(routes::integrity::resolve_orphans)) + // Export + .route("/jobs/export", post(routes::export::trigger_export)) + .route( + "/jobs/export/options", + post(routes::export::trigger_export_with_options), + ) + // Scheduled tasks (write) + .route( + "/tasks/scheduled/{id}/toggle", + post(routes::scheduled_tasks::toggle_scheduled_task), + ) + .route( + "/tasks/scheduled/{id}/run-now", + post(routes::scheduled_tasks::run_scheduled_task_now), + ) + // Webhooks + .route("/webhooks/test", post(routes::webhooks::test_webhook)) + .layer(middleware::from_fn(auth::require_editor)); + + // Admin-only routes: destructive/config operations + let admin_routes = Router::new() + .route( + "/config/scanning", + put(routes::config::update_scanning_config), + ) + .route("/config/roots", post(routes::config::add_root)) + .route("/config/roots", delete(routes::config::remove_root)) + .route("/config/ui", put(routes::config::update_ui_config)) + .route("/database/vacuum", post(routes::database::vacuum_database)) + .route("/database/clear", post(routes::database::clear_database)) + .layer(middleware::from_fn(auth::require_admin)); + + let api = Router::new() + .merge(login_route) + .merge(viewer_routes) + .merge(editor_routes) + .merge(admin_routes); + + // CORS: allow same-origin by default, plus the desktop UI origin + let cors = CorsLayer::new() + .allow_origin([ + "http://localhost:3000".parse::().unwrap(), + "http://127.0.0.1:3000".parse::().unwrap(), + "tauri://localhost".parse::().unwrap(), + ]) + .allow_methods([ + Method::GET, + Method::POST, + Method::PUT, + Method::PATCH, + Method::DELETE, + ]) + .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION]) + .allow_credentials(true); + + Router::new() + .nest("/api/v1", api) + .layer(DefaultBodyLimit::max(10 * 1024 * 1024)) + .layer(middleware::from_fn_with_state( + state.clone(), + auth::require_auth, + )) + .layer(GovernorLayer { + config: global_governor, + }) + .layer(TraceLayer::new_for_http()) + .layer(cors) + .with_state(state) +} diff --git a/crates/pinakes-server/src/auth.rs b/crates/pinakes-server/src/auth.rs new file mode 100644 index 0000000..d094006 --- /dev/null +++ b/crates/pinakes-server/src/auth.rs @@ -0,0 +1,164 @@ +use axum::extract::{Request, State}; +use axum::http::StatusCode; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; + +use pinakes_core::config::UserRole; + +use crate::state::AppState; + +/// Constant-time string comparison to prevent timing attacks on API keys. +fn constant_time_eq(a: &str, b: &str) -> bool { + if a.len() != b.len() { + return false; + } + a.as_bytes() + .iter() + .zip(b.as_bytes()) + .fold(0u8, |acc, (x, y)| acc | (x ^ y)) + == 0 +} + +/// Axum middleware that checks for a valid Bearer token. +/// +/// If `accounts.enabled == true`: look up bearer token in session store. +/// If `accounts.enabled == false`: use existing api_key logic (unchanged behavior). +/// Skips authentication for the `/health` and `/auth/login` path suffixes. +pub async fn require_auth( + State(state): State, + mut request: Request, + next: Next, +) -> Response { + let path = request.uri().path().to_string(); + + // Always allow health and login endpoints + if path.ends_with("/health") || path.ends_with("/auth/login") { + return next.run(request).await; + } + + let config = state.config.read().await; + + if config.accounts.enabled { + // Session-based auth + let token = request + .headers() + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.strip_prefix("Bearer ")) + .map(|s| s.to_string()); + + drop(config); + + let Some(token) = token else { + tracing::debug!(path = %path, "rejected: missing Authorization header"); + return unauthorized("missing Authorization header"); + }; + + let sessions = state.sessions.read().await; + let Some(session) = sessions.get(&token) else { + tracing::debug!(path = %path, "rejected: invalid session token"); + return unauthorized("invalid or expired session token"); + }; + + // Check session expiry + if session.is_expired() { + let username = session.username.clone(); + drop(sessions); + // Remove expired session + let mut sessions_mut = state.sessions.write().await; + sessions_mut.remove(&token); + tracing::info!(username = %username, "session expired"); + return unauthorized("session expired"); + } + + // Inject role and username into request extensions + request.extensions_mut().insert(session.role); + request.extensions_mut().insert(session.username.clone()); + } else { + // Legacy API key auth + let api_key = std::env::var("PINAKES_API_KEY") + .ok() + .or_else(|| config.server.api_key.clone()); + drop(config); + + if let Some(ref expected_key) = api_key { + if expected_key.is_empty() { + // Empty key means no auth required + request.extensions_mut().insert(UserRole::Admin); + return next.run(request).await; + } + + let auth_header = request + .headers() + .get("authorization") + .and_then(|v| v.to_str().ok()); + + match auth_header { + Some(header) if header.starts_with("Bearer ") => { + let token = &header[7..]; + if !constant_time_eq(token, expected_key.as_str()) { + tracing::warn!(path = %path, "rejected: invalid API key"); + return unauthorized("invalid api key"); + } + } + _ => { + return unauthorized( + "missing or malformed Authorization header, expected: Bearer ", + ); + } + } + } + // When no api_key is configured, or key matches, grant admin + request.extensions_mut().insert(UserRole::Admin); + } + + next.run(request).await +} + +/// Middleware: requires Editor or Admin role. +pub async fn require_editor(request: Request, next: Next) -> Response { + let role = request + .extensions() + .get::() + .copied() + .unwrap_or(UserRole::Viewer); + if role.can_write() { + next.run(request).await + } else { + forbidden("editor role required") + } +} + +/// Middleware: requires Admin role. +pub async fn require_admin(request: Request, next: Next) -> Response { + let role = request + .extensions() + .get::() + .copied() + .unwrap_or(UserRole::Viewer); + if role.can_admin() { + next.run(request).await + } else { + forbidden("admin role required") + } +} + +fn unauthorized(message: &str) -> Response { + let body = format!(r#"{{"error":"{message}"}}"#); + ( + StatusCode::UNAUTHORIZED, + [("content-type", "application/json")], + body, + ) + .into_response() +} + +fn forbidden(message: &str) -> Response { + let body = format!(r#"{{"error":"{message}"}}"#); + ( + StatusCode::FORBIDDEN, + [("content-type", "application/json")], + body, + ) + .into_response() +} diff --git a/crates/pinakes-server/src/dto.rs b/crates/pinakes-server/src/dto.rs new file mode 100644 index 0000000..b1aaaf7 --- /dev/null +++ b/crates/pinakes-server/src/dto.rs @@ -0,0 +1,553 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +// Media +#[derive(Debug, Serialize)] +pub struct MediaResponse { + pub id: String, + pub path: String, + pub file_name: String, + pub media_type: String, + pub content_hash: String, + pub file_size: u64, + pub title: Option, + pub artist: Option, + pub album: Option, + pub genre: Option, + pub year: Option, + pub duration_secs: Option, + pub description: Option, + pub has_thumbnail: bool, + pub custom_fields: HashMap, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize)] +pub struct CustomFieldResponse { + pub field_type: String, + pub value: String, +} + +#[derive(Debug, Deserialize)] +pub struct ImportRequest { + pub path: PathBuf, +} + +#[derive(Debug, Serialize)] +pub struct ImportResponse { + pub media_id: String, + pub was_duplicate: bool, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateMediaRequest { + pub title: Option, + pub artist: Option, + pub album: Option, + pub genre: Option, + pub year: Option, + pub description: Option, +} + +// Tags +#[derive(Debug, Serialize)] +pub struct TagResponse { + pub id: String, + pub name: String, + pub parent_id: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct CreateTagRequest { + pub name: String, + pub parent_id: Option, +} + +#[derive(Debug, Deserialize)] +pub struct TagMediaRequest { + pub tag_id: Uuid, +} + +// Collections +#[derive(Debug, Serialize)] +pub struct CollectionResponse { + pub id: String, + pub name: String, + pub description: Option, + pub kind: String, + pub filter_query: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct CreateCollectionRequest { + pub name: String, + pub kind: String, + pub description: Option, + pub filter_query: Option, +} + +#[derive(Debug, Deserialize)] +pub struct AddMemberRequest { + pub media_id: Uuid, + pub position: Option, +} + +// Search +#[derive(Debug, Deserialize)] +pub struct SearchParams { + pub q: String, + pub sort: Option, + pub offset: Option, + pub limit: Option, +} + +#[derive(Debug, Serialize)] +pub struct SearchResponse { + pub items: Vec, + pub total_count: u64, +} + +// Audit +#[derive(Debug, Serialize)] +pub struct AuditEntryResponse { + pub id: String, + pub media_id: Option, + pub action: String, + pub details: Option, + pub timestamp: DateTime, +} + +// Search (POST body) +#[derive(Debug, Deserialize)] +pub struct SearchRequestBody { + pub q: String, + pub sort: Option, + pub offset: Option, + pub limit: Option, +} + +// Scan +#[derive(Debug, Deserialize)] +pub struct ScanRequest { + pub path: Option, +} + +#[derive(Debug, Serialize)] +pub struct ScanResponse { + pub files_found: usize, + pub files_processed: usize, + pub errors: Vec, +} + +#[derive(Debug, Serialize)] +pub struct ScanJobResponse { + pub job_id: String, +} + +#[derive(Debug, Serialize)] +pub struct ScanStatusResponse { + pub scanning: bool, + pub files_found: usize, + pub files_processed: usize, + pub error_count: usize, + pub errors: Vec, +} + +// Pagination +#[derive(Debug, Deserialize)] +pub struct PaginationParams { + pub offset: Option, + pub limit: Option, + pub sort: Option, +} + +// Open +#[derive(Debug, Deserialize)] +pub struct OpenRequest { + pub media_id: Uuid, +} + +// Config +#[derive(Debug, Serialize)] +pub struct ConfigResponse { + pub backend: String, + pub database_path: Option, + pub roots: Vec, + pub scanning: ScanningConfigResponse, + pub server: ServerConfigResponse, + pub ui: UiConfigResponse, + pub config_path: Option, + pub config_writable: bool, +} + +#[derive(Debug, Serialize)] +pub struct ScanningConfigResponse { + pub watch: bool, + pub poll_interval_secs: u64, + pub ignore_patterns: Vec, +} + +#[derive(Debug, Serialize)] +pub struct ServerConfigResponse { + pub host: String, + pub port: u16, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateScanningRequest { + pub watch: Option, + pub poll_interval_secs: Option, + pub ignore_patterns: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct RootDirRequest { + pub path: String, +} + +// Enhanced Import +#[derive(Debug, Deserialize)] +pub struct ImportWithOptionsRequest { + pub path: PathBuf, + pub tag_ids: Option>, + pub new_tags: Option>, + pub collection_id: Option, +} + +#[derive(Debug, Deserialize)] +pub struct BatchImportRequest { + pub paths: Vec, + pub tag_ids: Option>, + pub new_tags: Option>, + pub collection_id: Option, +} + +#[derive(Debug, Serialize)] +pub struct BatchImportResponse { + pub results: Vec, + pub total: usize, + pub imported: usize, + pub duplicates: usize, + pub errors: usize, +} + +#[derive(Debug, Serialize)] +pub struct BatchImportItemResult { + pub path: String, + pub media_id: Option, + pub was_duplicate: bool, + pub error: Option, +} + +#[derive(Debug, Deserialize)] +pub struct DirectoryImportRequest { + pub path: PathBuf, + pub tag_ids: Option>, + pub new_tags: Option>, + pub collection_id: Option, +} + +#[derive(Debug, Serialize)] +pub struct DirectoryPreviewResponse { + pub files: Vec, + pub total_count: usize, + pub total_size: u64, +} + +#[derive(Debug, Serialize)] +pub struct DirectoryPreviewFile { + pub path: String, + pub file_name: String, + pub media_type: String, + pub file_size: u64, +} + +// Custom Fields +#[derive(Debug, Deserialize)] +pub struct SetCustomFieldRequest { + pub name: String, + pub field_type: String, + pub value: String, +} + +// Media update extended +#[derive(Debug, Deserialize)] +pub struct UpdateMediaFullRequest { + pub title: Option, + pub artist: Option, + pub album: Option, + pub genre: Option, + pub year: Option, + pub description: Option, +} + +// Batch operations +#[derive(Debug, Deserialize)] +pub struct BatchTagRequest { + pub media_ids: Vec, + pub tag_ids: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct BatchCollectionRequest { + pub media_ids: Vec, + pub collection_id: Uuid, +} + +#[derive(Debug, Deserialize)] +pub struct BatchDeleteRequest { + pub media_ids: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct BatchUpdateRequest { + pub media_ids: Vec, + pub title: Option, + pub artist: Option, + pub album: Option, + pub genre: Option, + pub year: Option, + pub description: Option, +} + +#[derive(Debug, Serialize)] +pub struct BatchOperationResponse { + pub processed: usize, + pub errors: Vec, +} + +// Search with sort +#[derive(Debug, Serialize)] +pub struct MediaCountResponse { + pub count: u64, +} + +// Database management +#[derive(Debug, Serialize)] +pub struct DatabaseStatsResponse { + pub media_count: u64, + pub tag_count: u64, + pub collection_count: u64, + pub audit_count: u64, + pub database_size_bytes: u64, + pub backend_name: String, +} + +// UI Config +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct UiConfigResponse { + pub theme: String, + pub default_view: String, + pub default_page_size: usize, + pub default_view_mode: String, + pub auto_play_media: bool, + pub show_thumbnails: bool, + pub sidebar_collapsed: bool, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateUiConfigRequest { + pub theme: Option, + pub default_view: Option, + pub default_page_size: Option, + pub default_view_mode: Option, + pub auto_play_media: Option, + pub show_thumbnails: Option, + pub sidebar_collapsed: Option, +} + +impl From<&pinakes_core::config::UiConfig> for UiConfigResponse { + fn from(ui: &pinakes_core::config::UiConfig) -> Self { + Self { + theme: ui.theme.clone(), + default_view: ui.default_view.clone(), + default_page_size: ui.default_page_size, + default_view_mode: ui.default_view_mode.clone(), + auto_play_media: ui.auto_play_media, + show_thumbnails: ui.show_thumbnails, + sidebar_collapsed: ui.sidebar_collapsed, + } + } +} + +// Library Statistics +#[derive(Debug, Serialize)] +pub struct LibraryStatisticsResponse { + pub total_media: u64, + pub total_size_bytes: u64, + pub avg_file_size_bytes: u64, + pub media_by_type: Vec, + pub storage_by_type: Vec, + pub newest_item: Option, + pub oldest_item: Option, + pub top_tags: Vec, + pub top_collections: Vec, + pub total_tags: u64, + pub total_collections: u64, + pub total_duplicates: u64, +} + +#[derive(Debug, Serialize)] +pub struct TypeCountResponse { + pub name: String, + pub count: u64, +} + +impl From for LibraryStatisticsResponse { + fn from(stats: pinakes_core::storage::LibraryStatistics) -> Self { + Self { + total_media: stats.total_media, + total_size_bytes: stats.total_size_bytes, + avg_file_size_bytes: stats.avg_file_size_bytes, + media_by_type: stats + .media_by_type + .into_iter() + .map(|(name, count)| TypeCountResponse { name, count }) + .collect(), + storage_by_type: stats + .storage_by_type + .into_iter() + .map(|(name, count)| TypeCountResponse { name, count }) + .collect(), + newest_item: stats.newest_item, + oldest_item: stats.oldest_item, + top_tags: stats + .top_tags + .into_iter() + .map(|(name, count)| TypeCountResponse { name, count }) + .collect(), + top_collections: stats + .top_collections + .into_iter() + .map(|(name, count)| TypeCountResponse { name, count }) + .collect(), + total_tags: stats.total_tags, + total_collections: stats.total_collections, + total_duplicates: stats.total_duplicates, + } + } +} + +// Scheduled Tasks +#[derive(Debug, Serialize)] +pub struct ScheduledTaskResponse { + pub id: String, + pub name: String, + pub schedule: String, + pub enabled: bool, + pub last_run: Option, + pub next_run: Option, + pub last_status: Option, +} + +// Duplicates +#[derive(Debug, Serialize)] +pub struct DuplicateGroupResponse { + pub content_hash: String, + pub items: Vec, +} + +// Auth +#[derive(Debug, Deserialize)] +pub struct LoginRequest { + pub username: String, + pub password: String, +} + +#[derive(Debug, Serialize)] +pub struct LoginResponse { + pub token: String, + pub username: String, + pub role: String, +} + +#[derive(Debug, Serialize)] +pub struct UserInfoResponse { + pub username: String, + pub role: String, +} + +// Conversion helpers +impl From for MediaResponse { + fn from(item: pinakes_core::model::MediaItem) -> Self { + Self { + id: item.id.0.to_string(), + path: item.path.to_string_lossy().to_string(), + file_name: item.file_name, + media_type: serde_json::to_value(item.media_type) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_default(), + content_hash: item.content_hash.0, + file_size: item.file_size, + title: item.title, + artist: item.artist, + album: item.album, + genre: item.genre, + year: item.year, + duration_secs: item.duration_secs, + description: item.description, + has_thumbnail: item.thumbnail_path.is_some(), + custom_fields: item + .custom_fields + .into_iter() + .map(|(k, v)| { + ( + k, + CustomFieldResponse { + field_type: format!("{:?}", v.field_type).to_lowercase(), + value: v.value, + }, + ) + }) + .collect(), + created_at: item.created_at, + updated_at: item.updated_at, + } + } +} + +impl From for TagResponse { + fn from(tag: pinakes_core::model::Tag) -> Self { + Self { + id: tag.id.to_string(), + name: tag.name, + parent_id: tag.parent_id.map(|id| id.to_string()), + created_at: tag.created_at, + } + } +} + +impl From for CollectionResponse { + fn from(col: pinakes_core::model::Collection) -> Self { + Self { + id: col.id.to_string(), + name: col.name, + description: col.description, + kind: format!("{:?}", col.kind).to_lowercase(), + filter_query: col.filter_query, + created_at: col.created_at, + updated_at: col.updated_at, + } + } +} + +impl From for AuditEntryResponse { + fn from(entry: pinakes_core::model::AuditEntry) -> Self { + Self { + id: entry.id.to_string(), + media_id: entry.media_id.map(|id| id.0.to_string()), + action: entry.action.to_string(), + details: entry.details, + timestamp: entry.timestamp, + } + } +} diff --git a/crates/pinakes-server/src/error.rs b/crates/pinakes-server/src/error.rs new file mode 100644 index 0000000..768a2cf --- /dev/null +++ b/crates/pinakes-server/src/error.rs @@ -0,0 +1,69 @@ +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::Serialize; + +#[derive(Debug, Serialize)] +struct ErrorResponse { + error: String, +} + +pub struct ApiError(pub pinakes_core::error::PinakesError); + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + use pinakes_core::error::PinakesError; + let (status, message) = match &self.0 { + PinakesError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), + PinakesError::FileNotFound(path) => { + // Only expose the file name, not the full path + let name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + tracing::debug!(path = %path.display(), "file not found"); + (StatusCode::NOT_FOUND, format!("file not found: {name}")) + } + PinakesError::TagNotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), + PinakesError::CollectionNotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), + PinakesError::DuplicateHash(msg) => (StatusCode::CONFLICT, msg.clone()), + PinakesError::UnsupportedMediaType(path) => { + let name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + ( + StatusCode::BAD_REQUEST, + format!("unsupported media type: {name}"), + ) + } + PinakesError::SearchParse(msg) => (StatusCode::BAD_REQUEST, msg.clone()), + PinakesError::InvalidOperation(msg) => (StatusCode::BAD_REQUEST, msg.clone()), + PinakesError::Config(_) => { + tracing::error!(error = %self.0, "configuration error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal configuration error".to_string(), + ) + } + _ => { + tracing::error!(error = %self.0, "internal server error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal server error".to_string(), + ) + } + }; + + let body = serde_json::to_string(&ErrorResponse { + error: message.clone(), + }) + .unwrap_or_else(|_| format!(r#"{{"error":"{}"}}"#, message)); + (status, [("content-type", "application/json")], body).into_response() + } +} + +impl From for ApiError { + fn from(e: pinakes_core::error::PinakesError) -> Self { + Self(e) + } +} diff --git a/crates/pinakes-server/src/lib.rs b/crates/pinakes-server/src/lib.rs new file mode 100644 index 0000000..6f386df --- /dev/null +++ b/crates/pinakes-server/src/lib.rs @@ -0,0 +1,6 @@ +pub mod app; +pub mod auth; +pub mod dto; +pub mod error; +pub mod routes; +pub mod state; diff --git a/crates/pinakes-server/src/main.rs b/crates/pinakes-server/src/main.rs new file mode 100644 index 0000000..3727612 --- /dev/null +++ b/crates/pinakes-server/src/main.rs @@ -0,0 +1,448 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::Result; +use clap::Parser; +use tokio::sync::RwLock; +use tracing::info; +use tracing_subscriber::EnvFilter; + +use pinakes_core::config::Config; +use pinakes_core::storage::StorageBackend; + +use pinakes_server::app; +use pinakes_server::state::AppState; + +/// Pinakes media cataloging server +#[derive(Parser)] +#[command(name = "pinakes-server", version, about)] +struct Cli { + /// Path to configuration file + #[arg(short, long, env = "PINAKES_CONFIG")] + config: Option, + + /// Override listen host + #[arg(long)] + host: Option, + + /// Override listen port + #[arg(short, long)] + port: Option, + + /// Set log level (trace, debug, info, warn, error) + #[arg(long, default_value = "info")] + log_level: String, + + /// Log output format (compact, full, pretty, json) + #[arg(long, default_value = "compact")] + log_format: String, + + /// Run database migrations only, then exit + #[arg(long)] + migrate_only: bool, +} + +fn resolve_config_path(explicit: Option<&std::path::Path>) -> PathBuf { + if let Some(path) = explicit { + return path.to_path_buf(); + } + // Check current directory + let local = PathBuf::from("pinakes.toml"); + if local.exists() { + return local; + } + // XDG default + Config::default_config_path() +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + + // Initialize logging + let env_filter = EnvFilter::try_new(&cli.log_level).unwrap_or_else(|_| EnvFilter::new("info")); + + match cli.log_format.as_str() { + "json" => { + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .json() + .init(); + } + "pretty" => { + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .pretty() + .init(); + } + "full" => { + tracing_subscriber::fmt().with_env_filter(env_filter).init(); + } + _ => { + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .compact() + .init(); + } + } + + let config_path = resolve_config_path(cli.config.as_deref()); + info!(path = %config_path.display(), "loading configuration"); + + let mut config = Config::load_or_default(&config_path)?; + config.ensure_dirs()?; + config + .validate() + .map_err(|e| anyhow::anyhow!("invalid configuration: {e}"))?; + + // Apply CLI overrides + if let Some(host) = cli.host { + config.server.host = host; + } + if let Some(port) = cli.port { + config.server.port = port; + } + + // Storage backend initialization + let storage: pinakes_core::storage::DynStorageBackend = match config.storage.backend { + pinakes_core::config::StorageBackendType::Sqlite => { + let sqlite_config = config.storage.sqlite.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "sqlite storage selected but [storage.sqlite] config section missing" + ) + })?; + info!(path = %sqlite_config.path.display(), "initializing sqlite storage"); + let backend = pinakes_core::storage::sqlite::SqliteBackend::new(&sqlite_config.path)?; + backend.run_migrations().await?; + Arc::new(backend) + } + pinakes_core::config::StorageBackendType::Postgres => { + let pg_config = config.storage.postgres.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "postgres storage selected but [storage.postgres] config section missing" + ) + })?; + info!(host = %pg_config.host, port = pg_config.port, database = %pg_config.database, "initializing postgres storage"); + let backend = pinakes_core::storage::postgres::PostgresBackend::new(pg_config).await?; + backend.run_migrations().await?; + Arc::new(backend) + } + }; + + if cli.migrate_only { + info!("migrations complete, exiting"); + return Ok(()); + } + + // Register root directories + for root in &config.directories.roots { + if root.exists() { + storage.add_root_dir(root.clone()).await?; + info!(path = %root.display(), "registered root directory"); + } else { + tracing::warn!(path = %root.display(), "root directory does not exist, skipping"); + } + } + + // Start filesystem watcher if configured + if config.scanning.watch { + let watch_storage = storage.clone(); + let watch_dirs = config.directories.roots.clone(); + let watch_ignore = config.scanning.ignore_patterns.clone(); + tokio::spawn(async move { + if let Err(e) = + pinakes_core::scan::watch_and_import(watch_storage, watch_dirs, watch_ignore).await + { + tracing::error!(error = %e, "filesystem watcher failed"); + } + }); + info!("filesystem watcher started"); + } + + let addr = format!("{}:{}", config.server.host, config.server.port); + + // Initialize job queue with executor + let job_storage = storage.clone(); + let job_config = config.clone(); + let job_queue = pinakes_core::jobs::JobQueue::new( + config.jobs.worker_count, + move |job_id, kind, cancel, jobs| { + let storage = job_storage.clone(); + let config = job_config.clone(); + tokio::spawn(async move { + use pinakes_core::jobs::{JobKind, JobQueue}; + let result = match kind { + JobKind::Scan { path } => { + let ignore = config.scanning.ignore_patterns.clone(); + let res = if let Some(p) = path { + pinakes_core::scan::scan_directory(&storage, &p, &ignore).await + } else { + pinakes_core::scan::scan_all_roots(&storage, &ignore) + .await + .map(|statuses| { + let total_found: usize = + statuses.iter().map(|s| s.files_found).sum(); + let total_processed: usize = + statuses.iter().map(|s| s.files_processed).sum(); + let all_errors: Vec = + statuses.into_iter().flat_map(|s| s.errors).collect(); + pinakes_core::scan::ScanStatus { + scanning: false, + files_found: total_found, + files_processed: total_processed, + errors: all_errors, + } + }) + }; + match res { + Ok(status) => { + JobQueue::complete( + &jobs, + job_id, + serde_json::json!({ + "files_found": status.files_found, + "files_processed": status.files_processed, + "errors": status.errors, + }), + ) + .await; + } + Err(e) => { + JobQueue::fail(&jobs, job_id, e.to_string()).await; + } + } + } + JobKind::GenerateThumbnails { media_ids } => { + let thumb_dir = pinakes_core::thumbnail::default_thumbnail_dir(); + let thumb_config = config.thumbnails.clone(); + let total = media_ids.len(); + let mut generated = 0usize; + let mut errors = Vec::new(); + for (i, mid) in media_ids.iter().enumerate() { + if cancel.is_cancelled() { + break; + } + JobQueue::update_progress( + &jobs, + job_id, + i as f32 / total as f32, + format!("{}/{}", i, total), + ) + .await; + match storage.get_media(*mid).await { + Ok(item) => { + let source = item.path.clone(); + let mt = item.media_type; + let id = item.id; + let td = thumb_dir.clone(); + let tc = thumb_config.clone(); + let res = tokio::task::spawn_blocking(move || { + pinakes_core::thumbnail::generate_thumbnail_with_config( + id, &source, mt, &td, &tc, + ) + }) + .await; + match res { + Ok(Ok(Some(path))) => { + let mut updated = item; + updated.thumbnail_path = Some(path); + let _ = storage.update_media(&updated).await; + generated += 1; + } + Ok(Ok(None)) => {} + Ok(Err(e)) => errors.push(format!("{}: {}", mid, e)), + Err(e) => errors.push(format!("{}: {}", mid, e)), + } + } + Err(e) => errors.push(format!("{}: {}", mid, e)), + } + } + JobQueue::complete( + &jobs, + job_id, + serde_json::json!({ + "generated": generated, "errors": errors + }), + ) + .await; + } + JobKind::VerifyIntegrity { media_ids } => { + let ids = if media_ids.is_empty() { + None + } else { + Some(media_ids.as_slice()) + }; + match pinakes_core::integrity::verify_integrity(&storage, ids).await { + Ok(report) => { + JobQueue::complete( + &jobs, + job_id, + serde_json::to_value(&report).unwrap_or_default(), + ) + .await; + } + Err(e) => JobQueue::fail(&jobs, job_id, e.to_string()).await, + } + } + JobKind::OrphanDetection => { + match pinakes_core::integrity::detect_orphans(&storage).await { + Ok(report) => { + JobQueue::complete( + &jobs, + job_id, + serde_json::to_value(&report).unwrap_or_default(), + ) + .await; + } + Err(e) => JobQueue::fail(&jobs, job_id, e.to_string()).await, + } + } + JobKind::CleanupThumbnails => { + let thumb_dir = pinakes_core::thumbnail::default_thumbnail_dir(); + match pinakes_core::integrity::cleanup_orphaned_thumbnails( + &storage, &thumb_dir, + ) + .await + { + Ok(removed) => { + JobQueue::complete( + &jobs, + job_id, + serde_json::json!({ "removed": removed }), + ) + .await; + } + Err(e) => JobQueue::fail(&jobs, job_id, e.to_string()).await, + } + } + JobKind::Export { + format, + destination, + } => { + match pinakes_core::export::export_library(&storage, &format, &destination) + .await + { + Ok(result) => { + JobQueue::complete( + &jobs, + job_id, + serde_json::to_value(&result).unwrap_or_default(), + ) + .await; + } + Err(e) => JobQueue::fail(&jobs, job_id, e.to_string()).await, + } + } + }; + let _ = result; + drop(cancel); + }) + }, + ); + + // Initialize cache layer + let cache = std::sync::Arc::new(pinakes_core::cache::CacheLayer::new( + config.jobs.cache_ttl_secs, + )); + + // Initialize scheduler with cancellation support + let shutdown_token = tokio_util::sync::CancellationToken::new(); + let config_arc = Arc::new(RwLock::new(config)); + let scheduler = pinakes_core::scheduler::TaskScheduler::new( + job_queue.clone(), + shutdown_token.clone(), + config_arc.clone(), + Some(config_path.clone()), + ); + let scheduler = Arc::new(scheduler); + + // Restore saved scheduler state from config + scheduler.restore_state().await; + + // Spawn scheduler background loop + { + let scheduler = scheduler.clone(); + tokio::spawn(async move { + scheduler.run().await; + }); + } + + let state = AppState { + storage: storage.clone(), + config: config_arc, + config_path: Some(config_path), + scan_progress: pinakes_core::scan::ScanProgress::new(), + sessions: Arc::new(RwLock::new(std::collections::HashMap::new())), + job_queue, + cache, + scheduler, + }; + + // Periodic session cleanup (every 15 minutes) + { + let sessions = state.sessions.clone(); + let cancel = shutdown_token.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(15 * 60)); + loop { + tokio::select! { + _ = interval.tick() => { + pinakes_server::state::cleanup_expired_sessions(&sessions).await; + } + _ = cancel.cancelled() => { + break; + } + } + } + }); + } + + let router = app::create_router(state); + + info!(addr = %addr, "server listening"); + let listener = tokio::net::TcpListener::bind(&addr).await?; + + axum::serve( + listener, + router.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(shutdown_signal()) + .await?; + + shutdown_token.cancel(); + info!("server shut down"); + Ok(()) +} + +async fn shutdown_signal() { + let ctrl_c = async { + match tokio::signal::ctrl_c().await { + Ok(()) => {} + Err(e) => { + tracing::warn!(error = %e, "failed to install Ctrl+C handler"); + std::future::pending::<()>().await; + } + } + }; + + #[cfg(unix)] + let terminate = async { + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(mut signal) => { + signal.recv().await; + } + Err(e) => { + tracing::warn!(error = %e, "failed to install SIGTERM handler"); + std::future::pending::<()>().await; + } + } + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => info!("received Ctrl+C, shutting down"), + _ = terminate => info!("received SIGTERM, shutting down"), + } +} diff --git a/crates/pinakes-server/src/routes/audit.rs b/crates/pinakes-server/src/routes/audit.rs new file mode 100644 index 0000000..390f7bf --- /dev/null +++ b/crates/pinakes-server/src/routes/audit.rs @@ -0,0 +1,23 @@ +use axum::Json; +use axum::extract::{Query, State}; + +use crate::dto::*; +use crate::error::ApiError; +use crate::state::AppState; + +use pinakes_core::model::Pagination; + +pub async fn list_audit( + State(state): State, + Query(params): Query, +) -> Result>, ApiError> { + let pagination = Pagination::new( + params.offset.unwrap_or(0), + params.limit.unwrap_or(50).min(1000), + None, + ); + let entries = state.storage.list_audit_entries(None, &pagination).await?; + Ok(Json( + entries.into_iter().map(AuditEntryResponse::from).collect(), + )) +} diff --git a/crates/pinakes-server/src/routes/auth.rs b/crates/pinakes-server/src/routes/auth.rs new file mode 100644 index 0000000..b36f8b4 --- /dev/null +++ b/crates/pinakes-server/src/routes/auth.rs @@ -0,0 +1,119 @@ +use axum::Json; +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode}; + +use crate::dto::{LoginRequest, LoginResponse, UserInfoResponse}; +use crate::state::AppState; + +pub async fn login( + State(state): State, + Json(req): Json, +) -> Result, StatusCode> { + // Limit input sizes to prevent DoS + if req.username.len() > 255 || req.password.len() > 1024 { + return Err(StatusCode::BAD_REQUEST); + } + + let config = state.config.read().await; + if !config.accounts.enabled { + return Err(StatusCode::NOT_FOUND); + } + + let user = config + .accounts + .users + .iter() + .find(|u| u.username == req.username); + + let user = match user { + Some(u) => u, + None => { + tracing::warn!(username = %req.username, "login failed: unknown user"); + return Err(StatusCode::UNAUTHORIZED); + } + }; + + // Verify password using argon2 + use argon2::password_hash::PasswordVerifier; + let hash = &user.password_hash; + let parsed_hash = argon2::password_hash::PasswordHash::new(hash) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let valid = argon2::Argon2::default() + .verify_password(req.password.as_bytes(), &parsed_hash) + .is_ok(); + if !valid { + tracing::warn!(username = %req.username, "login failed: invalid password"); + return Err(StatusCode::UNAUTHORIZED); + } + + // Generate session token + use rand::Rng; + let token: String = rand::rng() + .sample_iter(&rand::distr::Alphanumeric) + .take(48) + .map(char::from) + .collect(); + + let role = user.role; + let username = user.username.clone(); + + // Store session + { + let mut sessions = state.sessions.write().await; + sessions.insert( + token.clone(), + crate::state::SessionInfo { + username: username.clone(), + role, + created_at: chrono::Utc::now(), + }, + ); + } + + tracing::info!(username = %username, role = %role, "login successful"); + + Ok(Json(LoginResponse { + token, + username, + role: role.to_string(), + })) +} + +pub async fn logout(State(state): State, headers: HeaderMap) -> StatusCode { + if let Some(token) = extract_bearer_token(&headers) { + let mut sessions = state.sessions.write().await; + sessions.remove(token); + } + StatusCode::OK +} + +pub async fn me( + State(state): State, + headers: HeaderMap, +) -> Result, StatusCode> { + let config = state.config.read().await; + if !config.accounts.enabled { + // When accounts are not enabled, return a default admin user + return Ok(Json(UserInfoResponse { + username: "admin".to_string(), + role: "admin".to_string(), + })); + } + drop(config); + + let token = extract_bearer_token(&headers).ok_or(StatusCode::UNAUTHORIZED)?; + let sessions = state.sessions.read().await; + let session = sessions.get(token).ok_or(StatusCode::UNAUTHORIZED)?; + + Ok(Json(UserInfoResponse { + username: session.username.clone(), + role: session.role.to_string(), + })) +} + +fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> { + headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.strip_prefix("Bearer ")) +} diff --git a/crates/pinakes-server/src/routes/collections.rs b/crates/pinakes-server/src/routes/collections.rs new file mode 100644 index 0000000..d7113b1 --- /dev/null +++ b/crates/pinakes-server/src/routes/collections.rs @@ -0,0 +1,101 @@ +use axum::Json; +use axum::extract::{Path, State}; +use uuid::Uuid; + +use crate::dto::*; +use crate::error::ApiError; +use crate::state::AppState; + +use pinakes_core::model::{CollectionKind, MediaId}; + +pub async fn create_collection( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + if req.name.is_empty() || req.name.len() > 255 { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "collection name must be 1-255 characters".into(), + ), + )); + } + if let Some(ref desc) = req.description + && desc.len() > 10_000 + { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "description exceeds 10000 characters".into(), + ), + )); + } + let kind = match req.kind.as_str() { + "virtual" => CollectionKind::Virtual, + _ => CollectionKind::Manual, + }; + let col = pinakes_core::collections::create_collection( + &state.storage, + &req.name, + kind, + req.description.as_deref(), + req.filter_query.as_deref(), + ) + .await?; + Ok(Json(CollectionResponse::from(col))) +} + +pub async fn list_collections( + State(state): State, +) -> Result>, ApiError> { + let cols = state.storage.list_collections().await?; + Ok(Json( + cols.into_iter().map(CollectionResponse::from).collect(), + )) +} + +pub async fn get_collection( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + let col = state.storage.get_collection(id).await?; + Ok(Json(CollectionResponse::from(col))) +} + +pub async fn delete_collection( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + state.storage.delete_collection(id).await?; + Ok(Json(serde_json::json!({"deleted": true}))) +} + +pub async fn add_member( + State(state): State, + Path(collection_id): Path, + Json(req): Json, +) -> Result, ApiError> { + pinakes_core::collections::add_member( + &state.storage, + collection_id, + MediaId(req.media_id), + req.position.unwrap_or(0), + ) + .await?; + Ok(Json(serde_json::json!({"added": true}))) +} + +pub async fn remove_member( + State(state): State, + Path((collection_id, media_id)): Path<(Uuid, Uuid)>, +) -> Result, ApiError> { + pinakes_core::collections::remove_member(&state.storage, collection_id, MediaId(media_id)) + .await?; + Ok(Json(serde_json::json!({"removed": true}))) +} + +pub async fn get_members( + State(state): State, + Path(collection_id): Path, +) -> Result>, ApiError> { + let items = pinakes_core::collections::get_members(&state.storage, collection_id).await?; + Ok(Json(items.into_iter().map(MediaResponse::from).collect())) +} diff --git a/crates/pinakes-server/src/routes/config.rs b/crates/pinakes-server/src/routes/config.rs new file mode 100644 index 0000000..f1e1279 --- /dev/null +++ b/crates/pinakes-server/src/routes/config.rs @@ -0,0 +1,217 @@ +use axum::Json; +use axum::extract::State; + +use crate::dto::*; +use crate::error::ApiError; +use crate::state::AppState; + +pub async fn get_config(State(state): State) -> Result, ApiError> { + let config = state.config.read().await; + let roots = state.storage.list_root_dirs().await?; + + let config_path = state + .config_path + .as_ref() + .map(|p| p.to_string_lossy().to_string()); + let config_writable = match &state.config_path { + Some(path) => { + if path.exists() { + std::fs::metadata(path) + .map(|m| !m.permissions().readonly()) + .unwrap_or(false) + } else { + path.parent() + .map(|parent| { + std::fs::metadata(parent) + .map(|m| !m.permissions().readonly()) + .unwrap_or(false) + }) + .unwrap_or(false) + } + } + None => false, + }; + + Ok(Json(ConfigResponse { + backend: format!("{:?}", config.storage.backend).to_lowercase(), + database_path: config + .storage + .sqlite + .as_ref() + .map(|s| s.path.to_string_lossy().to_string()), + roots: roots + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(), + scanning: ScanningConfigResponse { + watch: config.scanning.watch, + poll_interval_secs: config.scanning.poll_interval_secs, + ignore_patterns: config.scanning.ignore_patterns.clone(), + }, + server: ServerConfigResponse { + host: config.server.host.clone(), + port: config.server.port, + }, + ui: UiConfigResponse::from(&config.ui), + config_path, + config_writable, + })) +} + +pub async fn get_ui_config( + State(state): State, +) -> Result, ApiError> { + let config = state.config.read().await; + Ok(Json(UiConfigResponse::from(&config.ui))) +} + +pub async fn update_ui_config( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + let mut config = state.config.write().await; + if let Some(theme) = req.theme { + config.ui.theme = theme; + } + if let Some(default_view) = req.default_view { + config.ui.default_view = default_view; + } + if let Some(default_page_size) = req.default_page_size { + config.ui.default_page_size = default_page_size; + } + if let Some(default_view_mode) = req.default_view_mode { + config.ui.default_view_mode = default_view_mode; + } + if let Some(auto_play) = req.auto_play_media { + config.ui.auto_play_media = auto_play; + } + if let Some(show_thumbs) = req.show_thumbnails { + config.ui.show_thumbnails = show_thumbs; + } + if let Some(collapsed) = req.sidebar_collapsed { + config.ui.sidebar_collapsed = collapsed; + } + + if let Some(ref path) = state.config_path { + config.save_to_file(path).map_err(ApiError)?; + } + + Ok(Json(UiConfigResponse::from(&config.ui))) +} + +pub async fn update_scanning_config( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + let mut config = state.config.write().await; + if let Some(watch) = req.watch { + config.scanning.watch = watch; + } + if let Some(interval) = req.poll_interval_secs { + config.scanning.poll_interval_secs = interval; + } + if let Some(patterns) = req.ignore_patterns { + config.scanning.ignore_patterns = patterns; + } + + // Persist to disk if we have a config path + if let Some(ref path) = state.config_path { + config.save_to_file(path).map_err(ApiError)?; + } + + let roots = state.storage.list_root_dirs().await?; + + let config_path = state + .config_path + .as_ref() + .map(|p| p.to_string_lossy().to_string()); + let config_writable = match &state.config_path { + Some(path) => { + if path.exists() { + std::fs::metadata(path) + .map(|m| !m.permissions().readonly()) + .unwrap_or(false) + } else { + path.parent() + .map(|parent| { + std::fs::metadata(parent) + .map(|m| !m.permissions().readonly()) + .unwrap_or(false) + }) + .unwrap_or(false) + } + } + None => false, + }; + + Ok(Json(ConfigResponse { + backend: format!("{:?}", config.storage.backend).to_lowercase(), + database_path: config + .storage + .sqlite + .as_ref() + .map(|s| s.path.to_string_lossy().to_string()), + roots: roots + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(), + scanning: ScanningConfigResponse { + watch: config.scanning.watch, + poll_interval_secs: config.scanning.poll_interval_secs, + ignore_patterns: config.scanning.ignore_patterns.clone(), + }, + server: ServerConfigResponse { + host: config.server.host.clone(), + port: config.server.port, + }, + ui: UiConfigResponse::from(&config.ui), + config_path, + config_writable, + })) +} + +pub async fn add_root( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + let path = std::path::PathBuf::from(&req.path); + + if !path.exists() { + return Err(ApiError(pinakes_core::error::PinakesError::FileNotFound( + path, + ))); + } + + state.storage.add_root_dir(path.clone()).await?; + + { + let mut config = state.config.write().await; + if !config.directories.roots.contains(&path) { + config.directories.roots.push(path); + } + if let Some(ref config_path) = state.config_path { + config.save_to_file(config_path).map_err(ApiError)?; + } + } + + get_config(State(state)).await +} + +pub async fn remove_root( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + let path = std::path::PathBuf::from(&req.path); + + state.storage.remove_root_dir(&path).await?; + + { + let mut config = state.config.write().await; + config.directories.roots.retain(|r| r != &path); + if let Some(ref config_path) = state.config_path { + config.save_to_file(config_path).map_err(ApiError)?; + } + } + + get_config(State(state)).await +} diff --git a/crates/pinakes-server/src/routes/database.rs b/crates/pinakes-server/src/routes/database.rs new file mode 100644 index 0000000..ad393bc --- /dev/null +++ b/crates/pinakes-server/src/routes/database.rs @@ -0,0 +1,34 @@ +use axum::Json; +use axum::extract::State; + +use crate::dto::DatabaseStatsResponse; +use crate::error::ApiError; +use crate::state::AppState; + +pub async fn database_stats( + State(state): State, +) -> Result, ApiError> { + let stats = state.storage.database_stats().await?; + Ok(Json(DatabaseStatsResponse { + media_count: stats.media_count, + tag_count: stats.tag_count, + collection_count: stats.collection_count, + audit_count: stats.audit_count, + database_size_bytes: stats.database_size_bytes, + backend_name: stats.backend_name, + })) +} + +pub async fn vacuum_database( + State(state): State, +) -> Result, ApiError> { + state.storage.vacuum().await?; + Ok(Json(serde_json::json!({"status": "ok"}))) +} + +pub async fn clear_database( + State(state): State, +) -> Result, ApiError> { + state.storage.clear_all_data().await?; + Ok(Json(serde_json::json!({"status": "ok"}))) +} diff --git a/crates/pinakes-server/src/routes/duplicates.rs b/crates/pinakes-server/src/routes/duplicates.rs new file mode 100644 index 0000000..5a4b238 --- /dev/null +++ b/crates/pinakes-server/src/routes/duplicates.rs @@ -0,0 +1,30 @@ +use axum::Json; +use axum::extract::State; + +use crate::dto::{DuplicateGroupResponse, MediaResponse}; +use crate::error::ApiError; +use crate::state::AppState; + +pub async fn list_duplicates( + State(state): State, +) -> Result>, ApiError> { + let groups = state.storage.find_duplicates().await?; + + let response: Vec = groups + .into_iter() + .map(|items| { + let content_hash = items + .first() + .map(|i| i.content_hash.0.clone()) + .unwrap_or_default(); + let media_items: Vec = + items.into_iter().map(MediaResponse::from).collect(); + DuplicateGroupResponse { + content_hash, + items: media_items, + } + }) + .collect(); + + Ok(Json(response)) +} diff --git a/crates/pinakes-server/src/routes/export.rs b/crates/pinakes-server/src/routes/export.rs new file mode 100644 index 0000000..97f4728 --- /dev/null +++ b/crates/pinakes-server/src/routes/export.rs @@ -0,0 +1,42 @@ +use axum::Json; +use axum::extract::State; +use serde::Deserialize; +use std::path::PathBuf; + +use crate::error::ApiError; +use crate::state::AppState; + +#[derive(Debug, Deserialize)] +pub struct ExportRequest { + pub format: String, + pub destination: PathBuf, +} + +pub async fn trigger_export( + State(state): State, +) -> Result, ApiError> { + // Default export to JSON in data dir + let dest = pinakes_core::config::Config::default_data_dir().join("export.json"); + let kind = pinakes_core::jobs::JobKind::Export { + format: pinakes_core::jobs::ExportFormat::Json, + destination: dest, + }; + let job_id = state.job_queue.submit(kind).await; + Ok(Json(serde_json::json!({ "job_id": job_id.to_string() }))) +} + +pub async fn trigger_export_with_options( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + let format = match req.format.as_str() { + "csv" => pinakes_core::jobs::ExportFormat::Csv, + _ => pinakes_core::jobs::ExportFormat::Json, + }; + let kind = pinakes_core::jobs::JobKind::Export { + format, + destination: req.destination, + }; + let job_id = state.job_queue.submit(kind).await; + Ok(Json(serde_json::json!({ "job_id": job_id.to_string() }))) +} diff --git a/crates/pinakes-server/src/routes/health.rs b/crates/pinakes-server/src/routes/health.rs new file mode 100644 index 0000000..1d521fc --- /dev/null +++ b/crates/pinakes-server/src/routes/health.rs @@ -0,0 +1,8 @@ +use axum::Json; + +pub async fn health() -> Json { + Json(serde_json::json!({ + "status": "ok", + "version": env!("CARGO_PKG_VERSION"), + })) +} diff --git a/crates/pinakes-server/src/routes/integrity.rs b/crates/pinakes-server/src/routes/integrity.rs new file mode 100644 index 0000000..9ebd096 --- /dev/null +++ b/crates/pinakes-server/src/routes/integrity.rs @@ -0,0 +1,99 @@ +use axum::Json; +use axum::extract::State; +use serde::Deserialize; + +use crate::error::ApiError; +use crate::state::AppState; + +#[derive(Debug, Deserialize)] +pub struct OrphanResolveRequest { + pub action: String, + pub ids: Vec, +} + +pub async fn trigger_orphan_detection( + State(state): State, +) -> Result, ApiError> { + let kind = pinakes_core::jobs::JobKind::OrphanDetection; + let job_id = state.job_queue.submit(kind).await; + Ok(Json(serde_json::json!({ "job_id": job_id.to_string() }))) +} + +pub async fn trigger_verify_integrity( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + let media_ids = req + .media_ids + .into_iter() + .map(|id| pinakes_core::model::MediaId(id)) + .collect(); + let kind = pinakes_core::jobs::JobKind::VerifyIntegrity { media_ids }; + let job_id = state.job_queue.submit(kind).await; + Ok(Json(serde_json::json!({ "job_id": job_id.to_string() }))) +} + +#[derive(Debug, Deserialize)] +pub struct VerifyIntegrityRequest { + pub media_ids: Vec, +} + +pub async fn trigger_cleanup_thumbnails( + State(state): State, +) -> Result, ApiError> { + let kind = pinakes_core::jobs::JobKind::CleanupThumbnails; + let job_id = state.job_queue.submit(kind).await; + Ok(Json(serde_json::json!({ "job_id": job_id.to_string() }))) +} + +#[derive(Debug, Deserialize)] +pub struct GenerateThumbnailsRequest { + /// When true, only generate thumbnails for items that don't have one yet. + /// When false (default), regenerate all thumbnails. + #[serde(default)] + pub only_missing: bool, +} + +pub async fn generate_all_thumbnails( + State(state): State, + body: Option>, +) -> Result, ApiError> { + let only_missing = body.map(|b| b.only_missing).unwrap_or(false); + let media_ids = state + .storage + .list_media_ids_for_thumbnails(only_missing) + .await?; + let count = media_ids.len(); + if count == 0 { + return Ok(Json(serde_json::json!({ + "job_id": null, + "media_count": 0, + "message": "no media items to process" + }))); + } + let kind = pinakes_core::jobs::JobKind::GenerateThumbnails { media_ids }; + let job_id = state.job_queue.submit(kind).await; + Ok(Json(serde_json::json!({ + "job_id": job_id.to_string(), + "media_count": count + }))) +} + +pub async fn resolve_orphans( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + let action = match req.action.as_str() { + "delete" => pinakes_core::integrity::OrphanAction::Delete, + _ => pinakes_core::integrity::OrphanAction::Ignore, + }; + let ids: Vec = req + .ids + .into_iter() + .map(pinakes_core::model::MediaId) + .collect(); + let count = pinakes_core::integrity::resolve_orphans(&state.storage, action, &ids) + .await + .map_err(|e| ApiError(e))?; + Ok(Json(serde_json::json!({ "resolved": count }))) +} diff --git a/crates/pinakes-server/src/routes/jobs.rs b/crates/pinakes-server/src/routes/jobs.rs new file mode 100644 index 0000000..adb0599 --- /dev/null +++ b/crates/pinakes-server/src/routes/jobs.rs @@ -0,0 +1,34 @@ +use axum::Json; +use axum::extract::{Path, State}; + +use crate::error::ApiError; +use crate::state::AppState; +use pinakes_core::jobs::Job; + +pub async fn list_jobs(State(state): State) -> Json> { + Json(state.job_queue.list().await) +} + +pub async fn get_job( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + state.job_queue.status(id).await.map(Json).ok_or_else(|| { + pinakes_core::error::PinakesError::NotFound(format!("job not found: {id}")).into() + }) +} + +pub async fn cancel_job( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + let cancelled = state.job_queue.cancel(id).await; + if cancelled { + Ok(Json(serde_json::json!({ "cancelled": true }))) + } else { + Err(pinakes_core::error::PinakesError::NotFound(format!( + "job not found or already finished: {id}" + )) + .into()) + } +} diff --git a/crates/pinakes-server/src/routes/media.rs b/crates/pinakes-server/src/routes/media.rs new file mode 100644 index 0000000..abba968 --- /dev/null +++ b/crates/pinakes-server/src/routes/media.rs @@ -0,0 +1,795 @@ +use axum::Json; +use axum::extract::{Path, Query, State}; +use uuid::Uuid; + +use crate::dto::*; +use crate::error::ApiError; +use crate::state::AppState; + +use pinakes_core::model::{MediaId, Pagination}; +use pinakes_core::storage::DynStorageBackend; + +/// Apply tags and add to collection after a successful import. +/// Shared logic used by import_with_options, batch_import, and import_directory_endpoint. +async fn apply_import_post_processing( + storage: &DynStorageBackend, + media_id: MediaId, + tag_ids: Option<&[Uuid]>, + new_tags: Option<&[String]>, + collection_id: Option, +) { + if let Some(tag_ids) = tag_ids { + for tid in tag_ids { + if let Err(e) = pinakes_core::tags::tag_media(storage, media_id, *tid).await { + tracing::warn!(error = %e, "failed to apply tag during import"); + } + } + } + if let Some(new_tags) = new_tags { + for name in new_tags { + match pinakes_core::tags::create_tag(storage, name, None).await { + Ok(tag) => { + if let Err(e) = pinakes_core::tags::tag_media(storage, media_id, tag.id).await { + tracing::warn!(error = %e, "failed to apply new tag during import"); + } + } + Err(e) => { + tracing::warn!(tag_name = %name, error = %e, "failed to create tag during import"); + } + } + } + } + if let Some(col_id) = collection_id + && let Err(e) = pinakes_core::collections::add_member(storage, col_id, media_id, 0).await + { + tracing::warn!(error = %e, "failed to add to collection during import"); + } +} + +pub async fn import_media( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + let result = pinakes_core::import::import_file(&state.storage, &req.path).await?; + Ok(Json(ImportResponse { + media_id: result.media_id.0.to_string(), + was_duplicate: result.was_duplicate, + })) +} + +pub async fn list_media( + State(state): State, + Query(params): Query, +) -> Result>, ApiError> { + let pagination = Pagination::new( + params.offset.unwrap_or(0), + params.limit.unwrap_or(50).min(1000), + params.sort, + ); + let items = state.storage.list_media(&pagination).await?; + Ok(Json(items.into_iter().map(MediaResponse::from).collect())) +} + +pub async fn get_media( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + let item = state.storage.get_media(MediaId(id)).await?; + Ok(Json(MediaResponse::from(item))) +} + +/// Maximum length for short text fields (title, artist, album, genre). +const MAX_SHORT_TEXT: usize = 500; +/// Maximum length for long text fields (description). +const MAX_LONG_TEXT: usize = 10_000; + +fn validate_optional_text(field: &Option, name: &str, max: usize) -> Result<(), ApiError> { + if let Some(v) = field + && v.len() > max + { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation(format!( + "{name} exceeds {max} characters" + )), + )); + } + Ok(()) +} + +pub async fn update_media( + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result, ApiError> { + validate_optional_text(&req.title, "title", MAX_SHORT_TEXT)?; + validate_optional_text(&req.artist, "artist", MAX_SHORT_TEXT)?; + validate_optional_text(&req.album, "album", MAX_SHORT_TEXT)?; + validate_optional_text(&req.genre, "genre", MAX_SHORT_TEXT)?; + validate_optional_text(&req.description, "description", MAX_LONG_TEXT)?; + + let mut item = state.storage.get_media(MediaId(id)).await?; + + if let Some(title) = req.title { + item.title = Some(title); + } + if let Some(artist) = req.artist { + item.artist = Some(artist); + } + if let Some(album) = req.album { + item.album = Some(album); + } + if let Some(genre) = req.genre { + item.genre = Some(genre); + } + if let Some(year) = req.year { + item.year = Some(year); + } + if let Some(description) = req.description { + item.description = Some(description); + } + item.updated_at = chrono::Utc::now(); + + state.storage.update_media(&item).await?; + pinakes_core::audit::record_action( + &state.storage, + Some(item.id), + pinakes_core::model::AuditAction::Updated, + None, + ) + .await?; + + Ok(Json(MediaResponse::from(item))) +} + +pub async fn delete_media( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + let media_id = MediaId(id); + // Fetch item first to get thumbnail path for cleanup + let item = state.storage.get_media(media_id).await?; + + // Record audit BEFORE delete to avoid FK constraint violation + pinakes_core::audit::record_action( + &state.storage, + Some(media_id), + pinakes_core::model::AuditAction::Deleted, + None, + ) + .await?; + + state.storage.delete_media(media_id).await?; + + // Clean up thumbnail file if it exists + if let Some(ref thumb_path) = item.thumbnail_path + && let Err(e) = tokio::fs::remove_file(thumb_path).await + && e.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!(path = %thumb_path.display(), error = %e, "failed to remove thumbnail"); + } + + Ok(Json(serde_json::json!({"deleted": true}))) +} + +pub async fn open_media( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + let item = state.storage.get_media(MediaId(id)).await?; + let opener = pinakes_core::opener::default_opener(); + opener.open(&item.path)?; + pinakes_core::audit::record_action( + &state.storage, + Some(item.id), + pinakes_core::model::AuditAction::Opened, + None, + ) + .await?; + Ok(Json(serde_json::json!({"opened": true}))) +} + +pub async fn stream_media( + State(state): State, + Path(id): Path, + headers: axum::http::HeaderMap, +) -> Result { + use axum::body::Body; + use axum::http::{StatusCode, header}; + use tokio::io::{AsyncReadExt, AsyncSeekExt}; + use tokio_util::io::ReaderStream; + + let item = state.storage.get_media(MediaId(id)).await?; + + let file = tokio::fs::File::open(&item.path).await.map_err(|_e| { + ApiError(pinakes_core::error::PinakesError::FileNotFound( + item.path.clone(), + )) + })?; + + let metadata = file + .metadata() + .await + .map_err(|e| ApiError(pinakes_core::error::PinakesError::Io(e)))?; + let total_size = metadata.len(); + let content_type = item.media_type.mime_type(); + + // Parse Range header + if let Some(range_header) = headers.get(header::RANGE) + && let Ok(range_str) = range_header.to_str() + && let Some(range) = parse_range(range_str, total_size) + { + let (start, end) = range; + let content_length = end - start + 1; + + let mut file = file; + file.seek(std::io::SeekFrom::Start(start)) + .await + .map_err(|e| ApiError(pinakes_core::error::PinakesError::Io(e)))?; + + let limited = file.take(content_length); + let stream = ReaderStream::new(limited); + let body = Body::from_stream(stream); + + return axum::response::Response::builder() + .status(StatusCode::PARTIAL_CONTENT) + .header(header::CONTENT_TYPE, content_type) + .header(header::CONTENT_LENGTH, content_length) + .header(header::ACCEPT_RANGES, "bytes") + .header( + header::CONTENT_RANGE, + format!("bytes {start}-{end}/{total_size}"), + ) + .header( + header::CONTENT_DISPOSITION, + format!("inline; filename=\"{}\"", item.file_name), + ) + .body(body) + .map_err(|e| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + format!("failed to build response: {e}"), + )) + }); + } + + // Full response (no Range header) + let stream = ReaderStream::new(file); + let body = Body::from_stream(stream); + + axum::response::Response::builder() + .header(header::CONTENT_TYPE, content_type) + .header(header::CONTENT_LENGTH, total_size) + .header(header::ACCEPT_RANGES, "bytes") + .header( + header::CONTENT_DISPOSITION, + format!("inline; filename=\"{}\"", item.file_name), + ) + .body(body) + .map_err(|e| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + format!("failed to build response: {e}"), + )) + }) +} + +/// Parse a `Range: bytes=START-END` header value. +/// Returns `Some((start, end))` inclusive, or `None` if malformed. +fn parse_range(header: &str, total_size: u64) -> Option<(u64, u64)> { + let bytes_prefix = header.strip_prefix("bytes=")?; + let (start_str, end_str) = bytes_prefix.split_once('-')?; + + if start_str.is_empty() { + // Suffix range: bytes=-500 means last 500 bytes + let suffix_len: u64 = end_str.parse().ok()?; + let start = total_size.saturating_sub(suffix_len); + Some((start, total_size - 1)) + } else { + let start: u64 = start_str.parse().ok()?; + let end = if end_str.is_empty() { + total_size - 1 + } else { + end_str.parse::().ok()?.min(total_size - 1) + }; + if start > end || start >= total_size { + return None; + } + Some((start, end)) + } +} + +pub async fn import_with_options( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + let result = pinakes_core::import::import_file(&state.storage, &req.path).await?; + + if !result.was_duplicate { + apply_import_post_processing( + &state.storage, + result.media_id, + req.tag_ids.as_deref(), + req.new_tags.as_deref(), + req.collection_id, + ) + .await; + } + + Ok(Json(ImportResponse { + media_id: result.media_id.0.to_string(), + was_duplicate: result.was_duplicate, + })) +} + +pub async fn batch_import( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + if req.paths.len() > 10_000 { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "batch size exceeds limit of 10000".into(), + ), + )); + } + + let mut results = Vec::new(); + let mut imported = 0usize; + let mut duplicates = 0usize; + let mut errors = 0usize; + + for path in &req.paths { + match pinakes_core::import::import_file(&state.storage, path).await { + Ok(result) => { + if result.was_duplicate { + duplicates += 1; + } else { + imported += 1; + apply_import_post_processing( + &state.storage, + result.media_id, + req.tag_ids.as_deref(), + req.new_tags.as_deref(), + req.collection_id, + ) + .await; + } + results.push(BatchImportItemResult { + path: path.to_string_lossy().to_string(), + media_id: Some(result.media_id.0.to_string()), + was_duplicate: result.was_duplicate, + error: None, + }); + } + Err(e) => { + errors += 1; + results.push(BatchImportItemResult { + path: path.to_string_lossy().to_string(), + media_id: None, + was_duplicate: false, + error: Some(e.to_string()), + }); + } + } + } + + let total = results.len(); + Ok(Json(BatchImportResponse { + results, + total, + imported, + duplicates, + errors, + })) +} + +pub async fn import_directory_endpoint( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + let config = state.config.read().await; + let ignore_patterns = config.scanning.ignore_patterns.clone(); + let concurrency = config.scanning.import_concurrency; + drop(config); + + let import_results = pinakes_core::import::import_directory_with_concurrency( + &state.storage, + &req.path, + &ignore_patterns, + concurrency, + ) + .await?; + + let mut results = Vec::new(); + let mut imported = 0usize; + let mut duplicates = 0usize; + let mut errors = 0usize; + + for r in import_results { + match r { + Ok(result) => { + if result.was_duplicate { + duplicates += 1; + } else { + imported += 1; + apply_import_post_processing( + &state.storage, + result.media_id, + req.tag_ids.as_deref(), + req.new_tags.as_deref(), + req.collection_id, + ) + .await; + } + results.push(BatchImportItemResult { + path: result.path.to_string_lossy().to_string(), + media_id: Some(result.media_id.0.to_string()), + was_duplicate: result.was_duplicate, + error: None, + }); + } + Err(e) => { + errors += 1; + results.push(BatchImportItemResult { + path: String::new(), + media_id: None, + was_duplicate: false, + error: Some(e.to_string()), + }); + } + } + } + + let total = results.len(); + Ok(Json(BatchImportResponse { + results, + total, + imported, + duplicates, + errors, + })) +} + +pub async fn preview_directory( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + let path_str = req.get("path").and_then(|v| v.as_str()).ok_or_else(|| { + pinakes_core::error::PinakesError::InvalidOperation("path required".into()) + })?; + let recursive = req + .get("recursive") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + let dir = std::path::PathBuf::from(path_str); + if !dir.is_dir() { + return Err(pinakes_core::error::PinakesError::FileNotFound(dir).into()); + } + + // Validate the directory is under a configured root (if roots are configured) + let roots = state.storage.list_root_dirs().await?; + if !roots.is_empty() { + let canonical = dir.canonicalize().map_err(|_| { + pinakes_core::error::PinakesError::InvalidOperation("cannot resolve path".into()) + })?; + let allowed = roots.iter().any(|root| canonical.starts_with(root)); + if !allowed { + return Err(pinakes_core::error::PinakesError::InvalidOperation( + "path is not under a configured root directory".into(), + ) + .into()); + } + } + + let files: Vec = tokio::task::spawn_blocking(move || { + let mut result = Vec::new(); + fn walk_dir( + dir: &std::path::Path, + recursive: bool, + result: &mut Vec, + ) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + // Skip hidden files/dirs + if path + .file_name() + .map(|n| n.to_string_lossy().starts_with('.')) + .unwrap_or(false) + { + continue; + } + if path.is_dir() { + if recursive { + walk_dir(&path, recursive, result); + } + } else if path.is_file() + && let Some(mt) = pinakes_core::media_type::MediaType::from_path(&path) + { + let size = entry.metadata().ok().map(|m| m.len()).unwrap_or(0); + let file_name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + let media_type = serde_json::to_value(mt) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_default(); + result.push(DirectoryPreviewFile { + path: path.to_string_lossy().to_string(), + file_name, + media_type, + file_size: size, + }); + } + } + } + walk_dir(&dir, recursive, &mut result); + result + }) + .await + .map_err(|e| pinakes_core::error::PinakesError::Io(std::io::Error::other(e)))?; + + let total_count = files.len(); + let total_size = files.iter().map(|f| f.file_size).sum(); + + Ok(Json(DirectoryPreviewResponse { + files, + total_count, + total_size, + })) +} + +pub async fn set_custom_field( + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result, ApiError> { + if req.name.is_empty() || req.name.len() > 255 { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "field name must be 1-255 characters".into(), + ), + )); + } + if req.value.len() > MAX_LONG_TEXT { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation(format!( + "field value exceeds {} characters", + MAX_LONG_TEXT + )), + )); + } + use pinakes_core::model::{CustomField, CustomFieldType}; + let field_type = match req.field_type.as_str() { + "number" => CustomFieldType::Number, + "date" => CustomFieldType::Date, + "boolean" => CustomFieldType::Boolean, + _ => CustomFieldType::Text, + }; + let field = CustomField { + field_type, + value: req.value, + }; + state + .storage + .set_custom_field(MediaId(id), &req.name, &field) + .await?; + Ok(Json(serde_json::json!({"set": true}))) +} + +pub async fn delete_custom_field( + State(state): State, + Path((id, name)): Path<(Uuid, String)>, +) -> Result, ApiError> { + state + .storage + .delete_custom_field(MediaId(id), &name) + .await?; + Ok(Json(serde_json::json!({"deleted": true}))) +} + +pub async fn batch_tag( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + if req.media_ids.len() > 10_000 { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "batch size exceeds limit of 10000".into(), + ), + )); + } + + let media_ids: Vec = req.media_ids.iter().map(|id| MediaId(*id)).collect(); + match state + .storage + .batch_tag_media(&media_ids, &req.tag_ids) + .await + { + Ok(count) => Ok(Json(BatchOperationResponse { + processed: count as usize, + errors: Vec::new(), + })), + Err(e) => Ok(Json(BatchOperationResponse { + processed: 0, + errors: vec![e.to_string()], + })), + } +} + +pub async fn delete_all_media( + State(state): State, +) -> Result, ApiError> { + // Record audit entry before deletion + if let Err(e) = pinakes_core::audit::record_action( + &state.storage, + None, + pinakes_core::model::AuditAction::Deleted, + Some("delete all media".to_string()), + ) + .await + { + tracing::warn!(error = %e, "failed to record audit entry"); + } + + match state.storage.delete_all_media().await { + Ok(count) => Ok(Json(BatchOperationResponse { + processed: count as usize, + errors: Vec::new(), + })), + Err(e) => Ok(Json(BatchOperationResponse { + processed: 0, + errors: vec![e.to_string()], + })), + } +} + +pub async fn batch_delete( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + if req.media_ids.len() > 10_000 { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "batch size exceeds limit of 10000".into(), + ), + )); + } + + let media_ids: Vec = req.media_ids.iter().map(|id| MediaId(*id)).collect(); + + // Record audit entries BEFORE delete to avoid FK constraint violation. + // Use None for media_id since they'll be deleted; include ID in details. + for id in &media_ids { + if let Err(e) = pinakes_core::audit::record_action( + &state.storage, + None, + pinakes_core::model::AuditAction::Deleted, + Some(format!("batch delete: media_id={}", id.0)), + ) + .await + { + tracing::warn!(error = %e, "failed to record audit entry"); + } + } + + match state.storage.batch_delete_media(&media_ids).await { + Ok(count) => Ok(Json(BatchOperationResponse { + processed: count as usize, + errors: Vec::new(), + })), + Err(e) => Ok(Json(BatchOperationResponse { + processed: 0, + errors: vec![e.to_string()], + })), + } +} + +pub async fn batch_add_to_collection( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + if req.media_ids.len() > 10_000 { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "batch size exceeds limit of 10000".into(), + ), + )); + } + + let mut processed = 0; + let mut errors = Vec::new(); + for (i, media_id) in req.media_ids.iter().enumerate() { + match pinakes_core::collections::add_member( + &state.storage, + req.collection_id, + MediaId(*media_id), + i as i32, + ) + .await + { + Ok(_) => processed += 1, + Err(e) => errors.push(format!("{media_id}: {e}")), + } + } + Ok(Json(BatchOperationResponse { processed, errors })) +} + +pub async fn batch_update( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + if req.media_ids.len() > 10_000 { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "batch size exceeds limit of 10000".into(), + ), + )); + } + + let media_ids: Vec = req.media_ids.iter().map(|id| MediaId(*id)).collect(); + match state + .storage + .batch_update_media( + &media_ids, + req.title.as_deref(), + req.artist.as_deref(), + req.album.as_deref(), + req.genre.as_deref(), + req.year, + req.description.as_deref(), + ) + .await + { + Ok(count) => Ok(Json(BatchOperationResponse { + processed: count as usize, + errors: Vec::new(), + })), + Err(e) => Ok(Json(BatchOperationResponse { + processed: 0, + errors: vec![e.to_string()], + })), + } +} + +pub async fn get_thumbnail( + State(state): State, + Path(id): Path, +) -> Result { + use axum::body::Body; + use axum::http::header; + use tokio_util::io::ReaderStream; + + let item = state.storage.get_media(MediaId(id)).await?; + + let thumb_path = item.thumbnail_path.ok_or_else(|| { + ApiError(pinakes_core::error::PinakesError::NotFound( + "no thumbnail available".into(), + )) + })?; + + let file = tokio::fs::File::open(&thumb_path) + .await + .map_err(|_e| ApiError(pinakes_core::error::PinakesError::FileNotFound(thumb_path)))?; + + let stream = ReaderStream::new(file); + let body = Body::from_stream(stream); + + axum::response::Response::builder() + .header(header::CONTENT_TYPE, "image/jpeg") + .header(header::CACHE_CONTROL, "public, max-age=86400") + .body(body) + .map_err(|e| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + format!("failed to build response: {e}"), + )) + }) +} + +pub async fn get_media_count( + State(state): State, +) -> Result, ApiError> { + let count = state.storage.count_media().await?; + Ok(Json(MediaCountResponse { count })) +} diff --git a/crates/pinakes-server/src/routes/mod.rs b/crates/pinakes-server/src/routes/mod.rs new file mode 100644 index 0000000..f4ab83e --- /dev/null +++ b/crates/pinakes-server/src/routes/mod.rs @@ -0,0 +1,18 @@ +pub mod audit; +pub mod auth; +pub mod collections; +pub mod config; +pub mod database; +pub mod duplicates; +pub mod export; +pub mod health; +pub mod integrity; +pub mod jobs; +pub mod media; +pub mod saved_searches; +pub mod scan; +pub mod scheduled_tasks; +pub mod search; +pub mod statistics; +pub mod tags; +pub mod webhooks; diff --git a/crates/pinakes-server/src/routes/saved_searches.rs b/crates/pinakes-server/src/routes/saved_searches.rs new file mode 100644 index 0000000..98e3c59 --- /dev/null +++ b/crates/pinakes-server/src/routes/saved_searches.rs @@ -0,0 +1,76 @@ +use axum::Json; +use axum::extract::{Path, State}; +use serde::{Deserialize, Serialize}; + +use crate::error::ApiError; +use crate::state::AppState; + +#[derive(Debug, Deserialize)] +pub struct CreateSavedSearchRequest { + pub name: String, + pub query: String, + pub sort_order: Option, +} + +#[derive(Debug, Serialize)] +pub struct SavedSearchResponse { + pub id: String, + pub name: String, + pub query: String, + pub sort_order: Option, + pub created_at: chrono::DateTime, +} + +pub async fn create_saved_search( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + let id = uuid::Uuid::now_v7(); + state + .storage + .save_search(id, &req.name, &req.query, req.sort_order.as_deref()) + .await + .map_err(ApiError)?; + + Ok(Json(SavedSearchResponse { + id: id.to_string(), + name: req.name, + query: req.query, + sort_order: req.sort_order, + created_at: chrono::Utc::now(), + })) +} + +pub async fn list_saved_searches( + State(state): State, +) -> Result>, ApiError> { + let searches = state + .storage + .list_saved_searches() + .await + .map_err(ApiError)?; + Ok(Json( + searches + .into_iter() + .map(|s| SavedSearchResponse { + id: s.id.to_string(), + name: s.name, + query: s.query, + sort_order: s.sort_order, + created_at: s.created_at, + }) + .collect(), + )) +} + +pub async fn delete_saved_search( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + state + .storage + .delete_saved_search(id) + .await + .map_err(ApiError)?; + Ok(Json(serde_json::json!({ "deleted": true }))) +} diff --git a/crates/pinakes-server/src/routes/scan.rs b/crates/pinakes-server/src/routes/scan.rs new file mode 100644 index 0000000..be2a192 --- /dev/null +++ b/crates/pinakes-server/src/routes/scan.rs @@ -0,0 +1,30 @@ +use axum::Json; +use axum::extract::State; + +use crate::dto::*; +use crate::error::ApiError; +use crate::state::AppState; + +/// Trigger a scan as a background job. Returns the job ID immediately. +pub async fn trigger_scan( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + let kind = pinakes_core::jobs::JobKind::Scan { path: req.path }; + let job_id = state.job_queue.submit(kind).await; + Ok(Json(ScanJobResponse { + job_id: job_id.to_string(), + })) +} + +pub async fn scan_status(State(state): State) -> Json { + let snapshot = state.scan_progress.snapshot(); + let error_count = snapshot.errors.len(); + Json(ScanStatusResponse { + scanning: snapshot.scanning, + files_found: snapshot.files_found, + files_processed: snapshot.files_processed, + error_count, + errors: snapshot.errors, + }) +} diff --git a/crates/pinakes-server/src/routes/scheduled_tasks.rs b/crates/pinakes-server/src/routes/scheduled_tasks.rs new file mode 100644 index 0000000..6784a78 --- /dev/null +++ b/crates/pinakes-server/src/routes/scheduled_tasks.rs @@ -0,0 +1,55 @@ +use axum::Json; +use axum::extract::{Path, State}; + +use crate::dto::ScheduledTaskResponse; +use crate::error::ApiError; +use crate::state::AppState; + +pub async fn list_scheduled_tasks( + State(state): State, +) -> Result>, ApiError> { + let tasks = state.scheduler.list_tasks().await; + let responses: Vec = tasks + .into_iter() + .map(|t| ScheduledTaskResponse { + id: t.id, + name: t.name, + schedule: t.schedule.display_string(), + enabled: t.enabled, + last_run: t.last_run.map(|dt| dt.to_rfc3339()), + next_run: t.next_run.map(|dt| dt.to_rfc3339()), + last_status: t.last_status, + }) + .collect(); + Ok(Json(responses)) +} + +pub async fn toggle_scheduled_task( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + match state.scheduler.toggle_task(&id).await { + Some(enabled) => Ok(Json(serde_json::json!({ + "id": id, + "enabled": enabled, + }))), + None => Err(ApiError(pinakes_core::error::PinakesError::NotFound( + format!("scheduled task not found: {id}"), + ))), + } +} + +pub async fn run_scheduled_task_now( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + match state.scheduler.run_now(&id).await { + Some(job_id) => Ok(Json(serde_json::json!({ + "id": id, + "job_id": job_id, + }))), + None => Err(ApiError(pinakes_core::error::PinakesError::NotFound( + format!("scheduled task not found: {id}"), + ))), + } +} diff --git a/crates/pinakes-server/src/routes/search.rs b/crates/pinakes-server/src/routes/search.rs new file mode 100644 index 0000000..a86694a --- /dev/null +++ b/crates/pinakes-server/src/routes/search.rs @@ -0,0 +1,87 @@ +use axum::Json; +use axum::extract::{Query, State}; + +use crate::dto::*; +use crate::error::ApiError; +use crate::state::AppState; + +use pinakes_core::model::Pagination; +use pinakes_core::search::{SearchRequest, SortOrder, parse_search_query}; + +fn resolve_sort(sort: Option<&str>) -> SortOrder { + match sort { + Some("date_asc") => SortOrder::DateAsc, + Some("date_desc") => SortOrder::DateDesc, + Some("name_asc") => SortOrder::NameAsc, + Some("name_desc") => SortOrder::NameDesc, + Some("size_asc") => SortOrder::SizeAsc, + Some("size_desc") => SortOrder::SizeDesc, + _ => SortOrder::Relevance, + } +} + +pub async fn search( + State(state): State, + Query(params): Query, +) -> Result, ApiError> { + if params.q.len() > 2048 { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "search query exceeds maximum length of 2048 characters".into(), + ), + )); + } + + let query = parse_search_query(¶ms.q)?; + let sort = resolve_sort(params.sort.as_deref()); + + let request = SearchRequest { + query, + sort, + pagination: Pagination::new( + params.offset.unwrap_or(0), + params.limit.unwrap_or(50).min(1000), + None, + ), + }; + + let results = state.storage.search(&request).await?; + + Ok(Json(SearchResponse { + items: results.items.into_iter().map(MediaResponse::from).collect(), + total_count: results.total_count, + })) +} + +pub async fn search_post( + State(state): State, + Json(body): Json, +) -> Result, ApiError> { + if body.q.len() > 2048 { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "search query exceeds maximum length of 2048 characters".into(), + ), + )); + } + + let query = parse_search_query(&body.q)?; + let sort = resolve_sort(body.sort.as_deref()); + + let request = SearchRequest { + query, + sort, + pagination: Pagination::new( + body.offset.unwrap_or(0), + body.limit.unwrap_or(50).min(1000), + None, + ), + }; + + let results = state.storage.search(&request).await?; + + Ok(Json(SearchResponse { + items: results.items.into_iter().map(MediaResponse::from).collect(), + total_count: results.total_count, + })) +} diff --git a/crates/pinakes-server/src/routes/statistics.rs b/crates/pinakes-server/src/routes/statistics.rs new file mode 100644 index 0000000..06db6b6 --- /dev/null +++ b/crates/pinakes-server/src/routes/statistics.rs @@ -0,0 +1,13 @@ +use axum::Json; +use axum::extract::State; + +use crate::dto::LibraryStatisticsResponse; +use crate::error::ApiError; +use crate::state::AppState; + +pub async fn library_statistics( + State(state): State, +) -> Result, ApiError> { + let stats = state.storage.library_statistics().await?; + Ok(Json(LibraryStatisticsResponse::from(stats))) +} diff --git a/crates/pinakes-server/src/routes/tags.rs b/crates/pinakes-server/src/routes/tags.rs new file mode 100644 index 0000000..9bfec60 --- /dev/null +++ b/crates/pinakes-server/src/routes/tags.rs @@ -0,0 +1,70 @@ +use axum::Json; +use axum::extract::{Path, State}; +use uuid::Uuid; + +use crate::dto::*; +use crate::error::ApiError; +use crate::state::AppState; + +use pinakes_core::model::MediaId; + +pub async fn create_tag( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + if req.name.is_empty() || req.name.len() > 255 { + return Err(ApiError( + pinakes_core::error::PinakesError::InvalidOperation( + "tag name must be 1-255 characters".into(), + ), + )); + } + let tag = pinakes_core::tags::create_tag(&state.storage, &req.name, req.parent_id).await?; + Ok(Json(TagResponse::from(tag))) +} + +pub async fn list_tags(State(state): State) -> Result>, ApiError> { + let tags = state.storage.list_tags().await?; + Ok(Json(tags.into_iter().map(TagResponse::from).collect())) +} + +pub async fn get_tag( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + let tag = state.storage.get_tag(id).await?; + Ok(Json(TagResponse::from(tag))) +} + +pub async fn delete_tag( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + state.storage.delete_tag(id).await?; + Ok(Json(serde_json::json!({"deleted": true}))) +} + +pub async fn tag_media( + State(state): State, + Path(media_id): Path, + Json(req): Json, +) -> Result, ApiError> { + pinakes_core::tags::tag_media(&state.storage, MediaId(media_id), req.tag_id).await?; + Ok(Json(serde_json::json!({"tagged": true}))) +} + +pub async fn untag_media( + State(state): State, + Path((media_id, tag_id)): Path<(Uuid, Uuid)>, +) -> Result, ApiError> { + pinakes_core::tags::untag_media(&state.storage, MediaId(media_id), tag_id).await?; + Ok(Json(serde_json::json!({"untagged": true}))) +} + +pub async fn get_media_tags( + State(state): State, + Path(media_id): Path, +) -> Result>, ApiError> { + let tags = state.storage.get_media_tags(MediaId(media_id)).await?; + Ok(Json(tags.into_iter().map(TagResponse::from).collect())) +} diff --git a/crates/pinakes-server/src/routes/webhooks.rs b/crates/pinakes-server/src/routes/webhooks.rs new file mode 100644 index 0000000..ce024df --- /dev/null +++ b/crates/pinakes-server/src/routes/webhooks.rs @@ -0,0 +1,40 @@ +use axum::Json; +use axum::extract::State; +use serde::Serialize; + +use crate::error::ApiError; +use crate::state::AppState; + +#[derive(Debug, Serialize)] +pub struct WebhookInfo { + pub url: String, + pub events: Vec, +} + +pub async fn list_webhooks( + State(state): State, +) -> Result>, ApiError> { + let config = state.config.read().await; + let hooks: Vec = config + .webhooks + .iter() + .map(|h| WebhookInfo { + url: h.url.clone(), + events: h.events.clone(), + }) + .collect(); + Ok(Json(hooks)) +} + +pub async fn test_webhook( + State(state): State, +) -> Result, ApiError> { + let config = state.config.read().await; + let count = config.webhooks.len(); + // Emit a test event to all configured webhooks + // In production, the event bus would handle delivery + Ok(Json(serde_json::json!({ + "webhooks_configured": count, + "test_sent": true + }))) +} diff --git a/crates/pinakes-server/src/state.rs b/crates/pinakes-server/src/state.rs new file mode 100644 index 0000000..aa5d367 --- /dev/null +++ b/crates/pinakes-server/src/state.rs @@ -0,0 +1,50 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use tokio::sync::RwLock; + +use pinakes_core::cache::CacheLayer; +use pinakes_core::config::{Config, UserRole}; +use pinakes_core::jobs::JobQueue; +use pinakes_core::scan::ScanProgress; +use pinakes_core::scheduler::TaskScheduler; +use pinakes_core::storage::DynStorageBackend; + +/// Default session TTL: 24 hours. +pub const SESSION_TTL_SECS: i64 = 24 * 60 * 60; + +#[derive(Debug, Clone)] +pub struct SessionInfo { + pub username: String, + pub role: UserRole, + pub created_at: chrono::DateTime, +} + +impl SessionInfo { + /// Returns true if this session has exceeded its TTL. + pub fn is_expired(&self) -> bool { + let age = chrono::Utc::now() - self.created_at; + age.num_seconds() > SESSION_TTL_SECS + } +} + +pub type SessionStore = Arc>>; + +/// Remove all expired sessions from the store. +pub async fn cleanup_expired_sessions(sessions: &SessionStore) { + let mut store = sessions.write().await; + store.retain(|_, info| !info.is_expired()); +} + +#[derive(Clone)] +pub struct AppState { + pub storage: DynStorageBackend, + pub config: Arc>, + pub config_path: Option, + pub scan_progress: ScanProgress, + pub sessions: SessionStore, + pub job_queue: Arc, + pub cache: Arc, + pub scheduler: Arc, +} diff --git a/crates/pinakes-server/tests/api_test.rs b/crates/pinakes-server/tests/api_test.rs new file mode 100644 index 0000000..dc65955 --- /dev/null +++ b/crates/pinakes-server/tests/api_test.rs @@ -0,0 +1,212 @@ +use std::net::SocketAddr; +use std::sync::Arc; + +use axum::body::Body; +use axum::extract::ConnectInfo; +use axum::http::{Request, StatusCode}; +use http_body_util::BodyExt; +use tokio::sync::RwLock; +use tower::ServiceExt; + +use pinakes_core::cache::CacheLayer; +use pinakes_core::config::{ + AccountsConfig, Config, DirectoryConfig, JobsConfig, ScanningConfig, ServerConfig, + SqliteConfig, StorageBackendType, StorageConfig, ThumbnailConfig, UiConfig, WebhookConfig, +}; +use pinakes_core::jobs::JobQueue; +use pinakes_core::storage::StorageBackend; +use pinakes_core::storage::sqlite::SqliteBackend; + +/// Fake socket address for tests (governor needs ConnectInfo) +fn test_addr() -> ConnectInfo { + ConnectInfo("127.0.0.1:9999".parse().unwrap()) +} + +/// Build a GET request with ConnectInfo for rate limiter compatibility +fn get(uri: &str) -> Request { + let mut req = Request::builder().uri(uri).body(Body::empty()).unwrap(); + req.extensions_mut().insert(test_addr()); + req +} + +/// Build a POST request with ConnectInfo +fn post_json(uri: &str, body: &str) -> Request { + let mut req = Request::builder() + .method("POST") + .uri(uri) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + req.extensions_mut().insert(test_addr()); + req +} + +async fn setup_app() -> axum::Router { + let backend = SqliteBackend::in_memory().expect("in-memory SQLite"); + backend.run_migrations().await.expect("migrations"); + let storage = Arc::new(backend) as pinakes_core::storage::DynStorageBackend; + + let config = Config { + storage: StorageConfig { + backend: StorageBackendType::Sqlite, + sqlite: Some(SqliteConfig { + path: ":memory:".into(), + }), + postgres: None, + }, + directories: DirectoryConfig { roots: vec![] }, + scanning: ScanningConfig { + watch: false, + poll_interval_secs: 300, + ignore_patterns: vec![], + import_concurrency: 8, + }, + server: ServerConfig { + host: "127.0.0.1".to_string(), + port: 3000, + api_key: None, + }, + ui: UiConfig::default(), + accounts: AccountsConfig::default(), + jobs: JobsConfig::default(), + thumbnails: ThumbnailConfig::default(), + webhooks: Vec::::new(), + scheduled_tasks: vec![], + }; + + let job_queue = JobQueue::new(1, |_id, _kind, _cancel, _jobs| tokio::spawn(async {})); + let config = Arc::new(RwLock::new(config)); + let scheduler = pinakes_core::scheduler::TaskScheduler::new( + job_queue.clone(), + tokio_util::sync::CancellationToken::new(), + config.clone(), + None, + ); + + let state = pinakes_server::state::AppState { + storage, + config, + config_path: None, + scan_progress: pinakes_core::scan::ScanProgress::new(), + sessions: Arc::new(RwLock::new(std::collections::HashMap::new())), + job_queue, + cache: Arc::new(CacheLayer::new(60)), + scheduler: Arc::new(scheduler), + }; + + pinakes_server::app::create_router(state) +} + +#[tokio::test] +async fn test_list_media_empty() { + let app = setup_app().await; + + let response = app.oneshot(get("/api/v1/media")).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let items: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(items.len(), 0); +} + +#[tokio::test] +async fn test_create_and_list_tags() { + let app = setup_app().await; + + // Create a tag + let response = app + .clone() + .oneshot(post_json("/api/v1/tags", r#"{"name":"Music"}"#)) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + // List tags + let response = app.oneshot(get("/api/v1/tags")).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let tags: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(tags.len(), 1); + assert_eq!(tags[0]["name"], "Music"); +} + +#[tokio::test] +async fn test_search_empty() { + let app = setup_app().await; + + let response = app.oneshot(get("/api/v1/search?q=test")).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let result: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(result["total_count"], 0); +} + +#[tokio::test] +async fn test_media_not_found() { + let app = setup_app().await; + + let response = app + .oneshot(get("/api/v1/media/00000000-0000-0000-0000-000000000000")) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_collections_crud() { + let app = setup_app().await; + + // Create collection + let response = app + .clone() + .oneshot(post_json( + "/api/v1/collections", + r#"{"name":"Favorites","kind":"manual"}"#, + )) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + // List collections + let response = app.oneshot(get("/api/v1/collections")).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let cols: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(cols.len(), 1); + assert_eq!(cols[0]["name"], "Favorites"); +} + +#[tokio::test] +async fn test_statistics_endpoint() { + let app = setup_app().await; + + let response = app.oneshot(get("/api/v1/statistics")).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let stats: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(stats["total_media"], 0); + assert_eq!(stats["total_size_bytes"], 0); +} + +#[tokio::test] +async fn test_scheduled_tasks_endpoint() { + let app = setup_app().await; + + let response = app.oneshot(get("/api/v1/tasks/scheduled")).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let tasks: Vec = serde_json::from_slice(&body).unwrap(); + assert!(!tasks.is_empty(), "should have default scheduled tasks"); + // Verify structure of first task + assert!(tasks[0]["id"].is_string()); + assert!(tasks[0]["name"].is_string()); + assert!(tasks[0]["schedule"].is_string()); +} diff --git a/crates/pinakes-tui/Cargo.toml b/crates/pinakes-tui/Cargo.toml new file mode 100644 index 0000000..2d43d40 --- /dev/null +++ b/crates/pinakes-tui/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "pinakes-tui" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } +chrono = { workspace = true } +uuid = { workspace = true } +anyhow = { workspace = true } +clap = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +reqwest = { workspace = true } +ratatui = { workspace = true } +crossterm = { workspace = true } diff --git a/crates/pinakes-tui/src/app.rs b/crates/pinakes-tui/src/app.rs new file mode 100644 index 0000000..04edd4d --- /dev/null +++ b/crates/pinakes-tui/src/app.rs @@ -0,0 +1,1029 @@ +use std::time::Duration; + +use anyhow::Result; +use crossterm::execute; +use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; + +use crate::client::{ApiClient, AuditEntryResponse}; +use crate::event::{ApiResult, AppEvent, EventHandler}; +use crate::input::{self, Action}; +use crate::ui; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum View { + Library, + Search, + Detail, + Tags, + Collections, + Audit, + Import, + Settings, + Duplicates, + Database, + MetadataEdit, + Queue, + Statistics, + Tasks, +} + +pub struct AppState { + pub current_view: View, + pub media_list: Vec, + pub selected_index: Option, + pub selected_media: Option, + pub search_input: String, + pub search_results: Vec, + pub search_selected: Option, + pub search_total_count: u64, + pub tags: Vec, + pub all_tags: Vec, + pub tag_selected: Option, + pub collections: Vec, + pub collection_selected: Option, + pub audit_log: Vec, + pub audit_selected: Option, + pub input_mode: bool, + pub import_input: String, + pub status_message: Option, + pub should_quit: bool, + pub page_offset: u64, + pub page_size: u64, + pub total_media_count: u64, + pub server_url: String, + // Duplicates view + pub duplicate_groups: Vec>, + pub duplicates_selected: Option, + // Database view + pub database_stats: Option>, + // Metadata edit view + pub edit_title: String, + pub edit_artist: String, + pub edit_album: String, + pub edit_genre: String, + pub edit_year: String, + pub edit_description: String, + pub edit_field_index: Option, + // Queue view + pub play_queue: Vec, + pub queue_current_index: Option, + pub queue_selected: Option, + pub queue_repeat: u8, + pub queue_shuffle: bool, + // Statistics view + pub library_stats: Option, + // Scheduled tasks view + pub scheduled_tasks: Vec, + pub scheduled_tasks_selected: Option, +} + +#[derive(Clone)] +pub struct QueueItem { + pub media_id: String, + pub title: String, + pub artist: Option, + pub media_type: String, +} + +impl AppState { + fn new(server_url: &str) -> Self { + Self { + current_view: View::Library, + media_list: Vec::new(), + selected_index: None, + selected_media: None, + search_input: String::new(), + search_results: Vec::new(), + search_selected: None, + search_total_count: 0, + tags: Vec::new(), + all_tags: Vec::new(), + tag_selected: None, + collections: Vec::new(), + collection_selected: None, + audit_log: Vec::new(), + audit_selected: None, + input_mode: false, + import_input: String::new(), + status_message: None, + should_quit: false, + duplicate_groups: Vec::new(), + duplicates_selected: None, + database_stats: None, + edit_title: String::new(), + edit_artist: String::new(), + edit_album: String::new(), + edit_genre: String::new(), + edit_year: String::new(), + edit_description: String::new(), + edit_field_index: None, + play_queue: Vec::new(), + queue_current_index: None, + queue_selected: None, + queue_repeat: 0, + queue_shuffle: false, + library_stats: None, + scheduled_tasks: Vec::new(), + scheduled_tasks_selected: None, + page_offset: 0, + page_size: 50, + total_media_count: 0, + server_url: server_url.to_string(), + } + } +} + +pub async fn run(server_url: &str) -> Result<()> { + let client = ApiClient::new(server_url); + let mut state = AppState::new(server_url); + + // Initial data load + match client.list_media(0, state.page_size).await { + Ok(items) => { + state.total_media_count = items.len() as u64; + if !items.is_empty() { + state.selected_index = Some(0); + } + state.media_list = items; + } + Err(e) => { + state.status_message = Some(format!("Failed to connect: {e}")); + } + } + + // Setup terminal + terminal::enable_raw_mode()?; + let mut stdout = std::io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let mut events = EventHandler::new(Duration::from_millis(250)); + let event_sender = events.sender(); + + // Main loop + while !state.should_quit { + terminal.draw(|f| ui::render(f, &state))?; + + if let Some(event) = events.next().await { + match event { + AppEvent::Key(key) => { + let action = input::handle_key(key, state.input_mode, &state.current_view); + handle_action(&client, &mut state, action, &event_sender).await; + } + AppEvent::Tick => {} + AppEvent::ApiResult(result) => { + handle_api_result(&mut state, result); + } + } + } + } + + // Restore terminal + terminal::disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + + Ok(()) +} + +fn handle_api_result(state: &mut AppState, result: ApiResult) { + match result { + ApiResult::MediaList(items) => { + if !items.is_empty() && state.selected_index.is_none() { + state.selected_index = Some(0); + } + state.total_media_count = state.page_offset + items.len() as u64; + state.media_list = items; + } + ApiResult::SearchResults(resp) => { + state.search_total_count = resp.total_count; + state.search_results = resp.items; + if !state.search_results.is_empty() { + state.search_selected = Some(0); + } + } + ApiResult::Tags(tags) => { + state.tags = tags; + if !state.tags.is_empty() { + state.tag_selected = Some(0); + } + } + ApiResult::AllTags(tags) => { + state.all_tags = tags; + } + ApiResult::Collections(cols) => { + state.collections = cols; + if !state.collections.is_empty() { + state.collection_selected = Some(0); + } + } + ApiResult::ImportDone(resp) => { + if resp.was_duplicate { + state.status_message = + Some(format!("Import: file already exists ({})", resp.media_id)); + } else { + state.status_message = Some(format!("Imported: {}", resp.media_id)); + } + } + ApiResult::ScanDone(results) => { + let total: usize = results.iter().map(|r| r.files_processed).sum(); + let found: usize = results.iter().map(|r| r.files_found).sum(); + let errors: Vec = results.into_iter().flat_map(|r| r.errors).collect(); + if errors.is_empty() { + state.status_message = + Some(format!("Scan complete: {total}/{found} files processed")); + } else { + state.status_message = Some(format!( + "Scan complete: {total}/{found} files, {} errors", + errors.len() + )); + } + } + ApiResult::AuditLog(entries) => { + state.audit_log = entries; + if !state.audit_log.is_empty() { + state.audit_selected = Some(0); + } + } + ApiResult::Duplicates(groups) => { + let flat: Vec> = + groups.into_iter().map(|g| g.items).collect(); + state.duplicate_groups = flat; + if !state.duplicate_groups.is_empty() { + state.duplicates_selected = Some(0); + } + state.status_message = Some(format!( + "Found {} duplicate groups", + state.duplicate_groups.len() + )); + } + ApiResult::DatabaseStats(stats) => { + state.database_stats = Some(vec![ + ("Media".to_string(), stats.media_count.to_string()), + ("Tags".to_string(), stats.tag_count.to_string()), + ( + "Collections".to_string(), + stats.collection_count.to_string(), + ), + ("Audit entries".to_string(), stats.audit_count.to_string()), + ( + "Database size".to_string(), + crate::ui::format_size(stats.database_size_bytes), + ), + ("Backend".to_string(), stats.backend_name), + ]); + state.status_message = None; + } + ApiResult::Statistics(stats) => { + state.library_stats = Some(stats); + state.status_message = None; + } + ApiResult::ScheduledTasks(tasks) => { + if !tasks.is_empty() && state.scheduled_tasks_selected.is_none() { + state.scheduled_tasks_selected = Some(0); + } + state.scheduled_tasks = tasks; + state.status_message = None; + } + ApiResult::MediaUpdated => { + state.status_message = Some("Media updated".into()); + } + ApiResult::Error(msg) => { + state.status_message = Some(format!("Error: {msg}")); + } + } +} + +async fn handle_action( + client: &ApiClient, + state: &mut AppState, + action: Action, + event_sender: &tokio::sync::mpsc::UnboundedSender, +) { + match action { + Action::Quit => state.should_quit = true, + Action::NavigateDown => { + let len = match state.current_view { + View::Search => state.search_results.len(), + View::Tags => state.tags.len(), + View::Collections => state.collections.len(), + View::Audit => state.audit_log.len(), + _ => state.media_list.len(), + }; + if len > 0 { + let idx = match state.current_view { + View::Search => &mut state.search_selected, + View::Tags => &mut state.tag_selected, + View::Collections => &mut state.collection_selected, + View::Audit => &mut state.audit_selected, + _ => &mut state.selected_index, + }; + *idx = Some(idx.map(|i| (i + 1).min(len - 1)).unwrap_or(0)); + } + } + Action::NavigateUp => { + let idx = match state.current_view { + View::Search => &mut state.search_selected, + View::Tags => &mut state.tag_selected, + View::Collections => &mut state.collection_selected, + View::Audit => &mut state.audit_selected, + _ => &mut state.selected_index, + }; + *idx = Some(idx.map(|i| i.saturating_sub(1)).unwrap_or(0)); + } + Action::GoTop => { + let idx = match state.current_view { + View::Search => &mut state.search_selected, + View::Tags => &mut state.tag_selected, + View::Collections => &mut state.collection_selected, + View::Audit => &mut state.audit_selected, + _ => &mut state.selected_index, + }; + *idx = Some(0); + } + Action::GoBottom => { + let len = match state.current_view { + View::Search => state.search_results.len(), + View::Tags => state.tags.len(), + View::Collections => state.collections.len(), + View::Audit => state.audit_log.len(), + _ => state.media_list.len(), + }; + if len > 0 { + let idx = match state.current_view { + View::Search => &mut state.search_selected, + View::Tags => &mut state.tag_selected, + View::Collections => &mut state.collection_selected, + View::Audit => &mut state.audit_selected, + _ => &mut state.selected_index, + }; + *idx = Some(len - 1); + } + } + Action::Select => { + if state.input_mode { + state.input_mode = false; + match state.current_view { + View::Search => { + let query = state.search_input.clone(); + state.status_message = Some("Searching...".into()); + let client = client.clone(); + let tx = event_sender.clone(); + let page_size = state.page_size; + tokio::spawn(async move { + match client.search(&query, 0, page_size).await { + Ok(results) => { + if let Err(e) = tx.send(AppEvent::ApiResult( + ApiResult::SearchResults(results), + )) { + tracing::warn!("failed to send event: {e}"); + } + } + Err(e) => { + if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error( + format!("Search: {e}"), + ))) { + tracing::warn!("failed to send event: {e}"); + } + } + } + }); + } + View::Import => { + let path = state.import_input.clone(); + if !path.is_empty() { + state.status_message = Some("Importing...".into()); + let client = client.clone(); + let tx = event_sender.clone(); + let page_size = state.page_size; + tokio::spawn(async move { + match client.import_file(&path).await { + Ok(resp) => { + if let Err(e) = tx + .send(AppEvent::ApiResult(ApiResult::ImportDone(resp))) + { + tracing::warn!("failed to send event: {e}"); + } + // Also refresh the media list + if let Ok(items) = client.list_media(0, page_size).await + && let Err(e) = tx.send(AppEvent::ApiResult( + ApiResult::MediaList(items), + )) + { + tracing::warn!("failed to send event: {e}"); + } + } + Err(e) => { + if let Err(e) = tx.send(AppEvent::ApiResult( + ApiResult::Error(format!("Import: {e}")), + )) { + tracing::warn!("failed to send event: {e}"); + } + } + } + }); + state.import_input.clear(); + } + state.current_view = View::Library; + } + View::Tags => { + // Create a new tag using the entered name + let name = state.search_input.clone(); + if !name.is_empty() { + match client.create_tag(&name, None).await { + Ok(tag) => { + state.tags.push(tag); + state.status_message = Some(format!("Created tag: {name}")); + } + Err(e) => { + state.status_message = Some(format!("Create tag error: {e}")); + } + } + state.search_input.clear(); + } + } + _ => {} + } + } else { + // Open detail view for the selected item + let item = match state.current_view { + View::Search => state + .search_selected + .and_then(|i| state.search_results.get(i)) + .cloned(), + _ => state + .selected_index + .and_then(|i| state.media_list.get(i)) + .cloned(), + }; + if let Some(media) = item { + match client.get_media(&media.id).await { + Ok(full_media) => { + // Fetch tags for this media item + let media_tags = client.get_media_tags(&full_media.id).await.ok(); + // Also fetch all tags for tag/untag operations + let all_tags = client.list_tags().await.ok(); + state.selected_media = Some(full_media); + if let Some(tags) = media_tags { + state.tags = tags; + } + if let Some(all) = all_tags { + state.all_tags = all; + } + state.current_view = View::Detail; + } + Err(_) => { + state.selected_media = Some(media); + state.current_view = View::Detail; + } + } + } + } + } + Action::Back => { + if state.input_mode { + state.input_mode = false; + } else { + state.current_view = View::Library; + state.status_message = None; + } + } + Action::Search => { + state.current_view = View::Search; + state.input_mode = true; + } + Action::Import => { + state.current_view = View::Import; + state.input_mode = true; + state.import_input.clear(); + } + Action::Open => { + if let Some(ref media) = state.selected_media { + match client.open_media(&media.id).await { + Ok(_) => state.status_message = Some("Opened file".into()), + Err(e) => state.status_message = Some(format!("Open error: {e}")), + } + } else if let Some(idx) = state.selected_index + && let Some(media) = state.media_list.get(idx) + { + match client.open_media(&media.id).await { + Ok(_) => state.status_message = Some("Opened file".into()), + Err(e) => state.status_message = Some(format!("Open error: {e}")), + } + } + } + Action::Delete => { + if let Some(idx) = state.selected_index + && let Some(media) = state.media_list.get(idx).cloned() + { + match client.delete_media(&media.id).await { + Ok(_) => { + state.media_list.remove(idx); + if state.media_list.is_empty() { + state.selected_index = None; + } else if idx >= state.media_list.len() { + state.selected_index = Some(state.media_list.len() - 1); + } + state.status_message = Some("Deleted".into()); + } + Err(e) => state.status_message = Some(format!("Delete error: {e}")), + } + } + } + Action::TagView => { + state.current_view = View::Tags; + match client.list_tags().await { + Ok(tags) => { + if !tags.is_empty() { + state.tag_selected = Some(0); + } + state.tags = tags; + } + Err(e) => state.status_message = Some(format!("Tags error: {e}")), + } + } + Action::CollectionView => { + state.current_view = View::Collections; + match client.list_collections().await { + Ok(cols) => { + if !cols.is_empty() { + state.collection_selected = Some(0); + } + state.collections = cols; + } + Err(e) => state.status_message = Some(format!("Collections error: {e}")), + } + } + Action::AuditView => { + state.current_view = View::Audit; + match client.list_audit(0, state.page_size).await { + Ok(entries) => { + if !entries.is_empty() { + state.audit_selected = Some(0); + } + state.audit_log = entries; + } + Err(e) => state.status_message = Some(format!("Audit error: {e}")), + } + } + Action::SettingsView => { + state.current_view = View::Settings; + } + Action::DuplicatesView => { + state.current_view = View::Duplicates; + state.status_message = Some("Loading duplicates...".into()); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.find_duplicates().await { + Ok(groups) => { + if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Duplicates(groups))) + { + tracing::warn!("failed to send event: {e}"); + } + } + Err(e) => { + if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Duplicates: {e}" + )))) { + tracing::warn!("failed to send event: {e}"); + } + } + } + }); + } + Action::DatabaseView => { + state.current_view = View::Database; + state.status_message = Some("Loading stats...".into()); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.database_stats().await { + Ok(stats) => { + if let Err(e) = + tx.send(AppEvent::ApiResult(ApiResult::DatabaseStats(stats))) + { + tracing::warn!("failed to send event: {e}"); + } + } + Err(e) => { + if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Database stats: {e}" + )))) { + tracing::warn!("failed to send event: {e}"); + } + } + } + }); + } + Action::QueueView => { + state.current_view = View::Queue; + } + Action::StatisticsView => { + state.current_view = View::Statistics; + state.status_message = Some("Loading statistics...".into()); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.library_statistics().await { + Ok(stats) => { + if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Statistics(stats))) { + tracing::warn!("failed to send event: {e}"); + } + } + Err(e) => { + if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Statistics: {e}" + )))) { + tracing::warn!("failed to send event: {e}"); + } + } + } + }); + } + Action::TasksView => { + state.current_view = View::Tasks; + state.status_message = Some("Loading tasks...".into()); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.list_scheduled_tasks().await { + Ok(tasks) => { + if let Err(e) = + tx.send(AppEvent::ApiResult(ApiResult::ScheduledTasks(tasks))) + { + tracing::warn!("failed to send event: {e}"); + } + } + Err(e) => { + if let Err(e) = + tx.send(AppEvent::ApiResult(ApiResult::Error(format!("Tasks: {e}")))) + { + tracing::warn!("failed to send event: {e}"); + } + } + } + }); + } + Action::ScanTrigger => { + state.status_message = Some("Scanning...".into()); + let client = client.clone(); + let tx = event_sender.clone(); + tokio::spawn(async move { + match client.trigger_scan(None).await { + Ok(results) => { + if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::ScanDone(results))) { + tracing::warn!("failed to send event: {e}"); + } + } + Err(e) => { + if let Err(e) = + tx.send(AppEvent::ApiResult(ApiResult::Error(format!("Scan: {e}")))) + { + tracing::warn!("failed to send event: {e}"); + } + } + } + }); + } + Action::Refresh => { + // Reload data for the current view asynchronously + state.status_message = Some("Refreshing...".into()); + let client = client.clone(); + let tx = event_sender.clone(); + let page_offset = state.page_offset; + let page_size = state.page_size; + let view = state.current_view; + tokio::spawn(async move { + match view { + View::Library | View::Detail | View::Import | View::Settings => { + match client.list_media(page_offset, page_size).await { + Ok(items) => { + if let Err(e) = + tx.send(AppEvent::ApiResult(ApiResult::MediaList(items))) + { + tracing::warn!("failed to send event: {e}"); + } + } + Err(e) => { + if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error( + format!("Refresh: {e}"), + ))) { + tracing::warn!("failed to send event: {e}"); + } + } + } + } + View::Tags => match client.list_tags().await { + Ok(tags) => { + if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Tags(tags))) { + tracing::warn!("failed to send event: {e}"); + } + } + Err(e) => { + if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Refresh: {e}" + )))) { + tracing::warn!("failed to send event: {e}"); + } + } + }, + View::Collections => match client.list_collections().await { + Ok(cols) => { + if let Err(e) = + tx.send(AppEvent::ApiResult(ApiResult::Collections(cols))) + { + tracing::warn!("failed to send event: {e}"); + } + } + Err(e) => { + if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Refresh: {e}" + )))) { + tracing::warn!("failed to send event: {e}"); + } + } + }, + View::Audit => match client.list_audit(0, page_size).await { + Ok(entries) => { + if let Err(e) = + tx.send(AppEvent::ApiResult(ApiResult::AuditLog(entries))) + { + tracing::warn!("failed to send event: {e}"); + } + } + Err(e) => { + if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Refresh: {e}" + )))) { + tracing::warn!("failed to send event: {e}"); + } + } + }, + View::Search => { + // Nothing to refresh for search without a query + } + View::Duplicates => match client.find_duplicates().await { + Ok(groups) => { + if let Err(e) = + tx.send(AppEvent::ApiResult(ApiResult::Duplicates(groups))) + { + tracing::warn!("failed to send event: {e}"); + } + } + Err(e) => { + if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Refresh: {e}" + )))) { + tracing::warn!("failed to send event: {e}"); + } + } + }, + View::Database => match client.database_stats().await { + Ok(stats) => { + if let Err(e) = + tx.send(AppEvent::ApiResult(ApiResult::DatabaseStats(stats))) + { + tracing::warn!("failed to send event: {e}"); + } + } + Err(e) => { + if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Refresh: {e}" + )))) { + tracing::warn!("failed to send event: {e}"); + } + } + }, + View::Statistics => match client.library_statistics().await { + Ok(stats) => { + if let Err(e) = + tx.send(AppEvent::ApiResult(ApiResult::Statistics(stats))) + { + tracing::warn!("failed to send event: {e}"); + } + } + Err(e) => { + if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Refresh: {e}" + )))) { + tracing::warn!("failed to send event: {e}"); + } + } + }, + View::Tasks => match client.list_scheduled_tasks().await { + Ok(tasks) => { + if let Err(e) = + tx.send(AppEvent::ApiResult(ApiResult::ScheduledTasks(tasks))) + { + tracing::warn!("failed to send event: {e}"); + } + } + Err(e) => { + if let Err(e) = tx.send(AppEvent::ApiResult(ApiResult::Error(format!( + "Refresh: {e}" + )))) { + tracing::warn!("failed to send event: {e}"); + } + } + }, + View::MetadataEdit | View::Queue => { + // No generic refresh for these views + } + } + }); + } + Action::NextTab => { + state.current_view = match state.current_view { + View::Library => View::Search, + View::Search => View::Tags, + View::Tags => View::Collections, + View::Collections => View::Audit, + View::Audit => View::Queue, + View::Queue => View::Statistics, + View::Statistics => View::Tasks, + View::Tasks => View::Library, + View::Detail + | View::Import + | View::Settings + | View::Duplicates + | View::Database + | View::MetadataEdit => View::Library, + }; + } + Action::PrevTab => { + state.current_view = match state.current_view { + View::Library => View::Tasks, + View::Search => View::Library, + View::Tags => View::Search, + View::Collections => View::Tags, + View::Audit => View::Collections, + View::Queue => View::Audit, + View::Statistics => View::Queue, + View::Tasks => View::Statistics, + View::Detail + | View::Import + | View::Settings + | View::Duplicates + | View::Database + | View::MetadataEdit => View::Library, + }; + } + Action::PageDown => { + state.page_offset += state.page_size; + match client.list_media(state.page_offset, state.page_size).await { + Ok(items) => { + if items.is_empty() { + state.page_offset = state.page_offset.saturating_sub(state.page_size); + } else { + state.total_media_count = state.page_offset + items.len() as u64; + state.media_list = items; + state.selected_index = Some(0); + } + } + Err(e) => state.status_message = Some(format!("Load error: {e}")), + } + } + Action::PageUp => { + if state.page_offset > 0 { + state.page_offset = state.page_offset.saturating_sub(state.page_size); + match client.list_media(state.page_offset, state.page_size).await { + Ok(items) => { + state.total_media_count = state.page_offset + items.len() as u64; + state.media_list = items; + state.selected_index = Some(0); + } + Err(e) => state.status_message = Some(format!("Load error: {e}")), + } + } + } + Action::CreateTag => { + if state.current_view == View::Tags { + state.input_mode = true; + state.search_input.clear(); + state.status_message = Some("Enter tag name:".into()); + } + } + Action::DeleteSelected => match state.current_view { + View::Tags => { + if let Some(idx) = state.tag_selected + && let Some(tag) = state.tags.get(idx).cloned() + { + match client.delete_tag(&tag.id).await { + Ok(_) => { + state.tags.remove(idx); + if state.tags.is_empty() { + state.tag_selected = None; + } else if idx >= state.tags.len() { + state.tag_selected = Some(state.tags.len() - 1); + } + state.status_message = Some(format!("Deleted tag: {}", tag.name)); + } + Err(e) => state.status_message = Some(format!("Delete error: {e}")), + } + } + } + View::Collections => { + if let Some(idx) = state.collection_selected + && let Some(col) = state.collections.get(idx).cloned() + { + match client.delete_collection(&col.id).await { + Ok(_) => { + state.collections.remove(idx); + if state.collections.is_empty() { + state.collection_selected = None; + } else if idx >= state.collections.len() { + state.collection_selected = Some(state.collections.len() - 1); + } + state.status_message = + Some(format!("Deleted collection: {}", col.name)); + } + Err(e) => state.status_message = Some(format!("Delete error: {e}")), + } + } + } + _ => {} + }, + Action::Char(c) => { + if state.input_mode { + match state.current_view { + View::Import => state.import_input.push(c), + _ => state.search_input.push(c), + } + } + } + Action::Backspace => { + if state.input_mode { + match state.current_view { + View::Import => { + state.import_input.pop(); + } + _ => { + state.search_input.pop(); + } + } + } + } + Action::TagMedia => { + // Tag the currently selected media with the currently selected tag + if state.current_view == View::Detail { + if let (Some(media), Some(tag_idx)) = (&state.selected_media, state.tag_selected) { + if let Some(tag) = state.all_tags.get(tag_idx) { + let media_id = media.id.clone(); + let tag_id = tag.id.clone(); + let tag_name = tag.name.clone(); + match client.tag_media(&media_id, &tag_id).await { + Ok(_) => { + state.status_message = Some(format!("Tagged with: {tag_name}")); + // Refresh media tags + if let Ok(tags) = client.get_media_tags(&media_id).await { + state.tags = tags; + } + } + Err(e) => { + state.status_message = Some(format!("Tag error: {e}")); + } + } + } + } else { + state.status_message = Some("Select a media item and tag first".into()); + } + } + } + Action::UntagMedia => { + // Untag the currently selected media from the currently selected tag + if state.current_view == View::Detail { + if let (Some(media), Some(tag_idx)) = (&state.selected_media, state.tag_selected) { + if let Some(tag) = state.tags.get(tag_idx) { + let media_id = media.id.clone(); + let tag_id = tag.id.clone(); + let tag_name = tag.name.clone(); + match client.untag_media(&media_id, &tag_id).await { + Ok(_) => { + state.status_message = Some(format!("Removed tag: {tag_name}")); + // Refresh media tags + if let Ok(tags) = client.get_media_tags(&media_id).await { + state.tags = tags; + } + } + Err(e) => { + state.status_message = Some(format!("Untag error: {e}")); + } + } + } + } else { + state.status_message = Some("Select a media item and tag first".into()); + } + } + } + Action::Help => { + state.status_message = Some( + "?: Help q: Quit /: Search i: Import o: Open t: Tags c: Collections a: Audit s: Scan S: Settings r: Refresh Home/End: Top/Bottom".into() + ); + } + Action::NavigateLeft | Action::NavigateRight | Action::None => {} + } +} diff --git a/crates/pinakes-tui/src/client.rs b/crates/pinakes-tui/src/client.rs new file mode 100644 index 0000000..24480d9 --- /dev/null +++ b/crates/pinakes-tui/src/client.rs @@ -0,0 +1,455 @@ +use anyhow::Result; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Clone)] +pub struct ApiClient { + client: Client, + base_url: String, +} + +// Response types (mirror server DTOs) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct MediaResponse { + pub id: String, + pub path: String, + pub file_name: String, + pub media_type: String, + pub content_hash: String, + pub file_size: u64, + pub title: Option, + pub artist: Option, + pub album: Option, + pub genre: Option, + pub year: Option, + pub duration_secs: Option, + pub description: Option, + #[serde(default)] + pub has_thumbnail: bool, + pub custom_fields: HashMap, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CustomFieldResponse { + pub field_type: String, + pub value: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ImportResponse { + pub media_id: String, + pub was_duplicate: bool, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TagResponse { + pub id: String, + pub name: String, + pub parent_id: Option, + pub created_at: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CollectionResponse { + pub id: String, + pub name: String, + pub description: Option, + pub kind: String, + pub filter_query: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SearchResponse { + pub items: Vec, + pub total_count: u64, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AuditEntryResponse { + pub id: String, + pub media_id: Option, + pub action: String, + pub details: Option, + pub timestamp: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ScanResponse { + pub files_found: usize, + pub files_processed: usize, + pub errors: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct DatabaseStatsResponse { + pub media_count: u64, + pub tag_count: u64, + pub collection_count: u64, + pub audit_count: u64, + pub database_size_bytes: u64, + pub backend_name: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct DuplicateGroupResponse { + pub content_hash: String, + pub items: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct JobResponse { + pub id: String, + pub kind: serde_json::Value, + pub status: serde_json::Value, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ScheduledTaskResponse { + pub id: String, + pub name: String, + pub schedule: String, + pub enabled: bool, + pub last_run: Option, + pub next_run: Option, + pub last_status: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct LibraryStatisticsResponse { + pub total_media: u64, + pub total_size_bytes: u64, + pub avg_file_size_bytes: u64, + pub media_by_type: Vec, + pub storage_by_type: Vec, + pub newest_item: Option, + pub oldest_item: Option, + pub top_tags: Vec, + pub top_collections: Vec, + pub total_tags: u64, + pub total_collections: u64, + pub total_duplicates: u64, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct TypeCount { + pub name: String, + pub count: u64, +} + +impl ApiClient { + pub fn new(base_url: &str) -> Self { + Self { + client: Client::new(), + base_url: base_url.trim_end_matches('/').to_string(), + } + } + + fn url(&self, path: &str) -> String { + format!("{}/api/v1{}", self.base_url, path) + } + + pub async fn list_media(&self, offset: u64, limit: u64) -> Result> { + let resp = self + .client + .get(self.url("/media")) + .query(&[("offset", offset.to_string()), ("limit", limit.to_string())]) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn get_media(&self, id: &str) -> Result { + let resp = self + .client + .get(self.url(&format!("/media/{id}"))) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn import_file(&self, path: &str) -> Result { + let resp = self + .client + .post(self.url("/media/import")) + .json(&serde_json::json!({"path": path})) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn delete_media(&self, id: &str) -> Result<()> { + self.client + .delete(self.url(&format!("/media/{id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn open_media(&self, id: &str) -> Result<()> { + self.client + .post(self.url(&format!("/media/{id}/open"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn search(&self, query: &str, offset: u64, limit: u64) -> Result { + let resp = self + .client + .get(self.url("/search")) + .query(&[ + ("q", query.to_string()), + ("offset", offset.to_string()), + ("limit", limit.to_string()), + ]) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn list_tags(&self) -> Result> { + let resp = self + .client + .get(self.url("/tags")) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn create_tag(&self, name: &str, parent_id: Option<&str>) -> Result { + let mut body = serde_json::json!({"name": name}); + if let Some(pid) = parent_id { + body["parent_id"] = serde_json::Value::String(pid.to_string()); + } + let resp = self + .client + .post(self.url("/tags")) + .json(&body) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn delete_tag(&self, id: &str) -> Result<()> { + self.client + .delete(self.url(&format!("/tags/{id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn tag_media(&self, media_id: &str, tag_id: &str) -> Result<()> { + self.client + .post(self.url(&format!("/media/{media_id}/tags"))) + .json(&serde_json::json!({"tag_id": tag_id})) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn untag_media(&self, media_id: &str, tag_id: &str) -> Result<()> { + self.client + .delete(self.url(&format!("/media/{media_id}/tags/{tag_id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn get_media_tags(&self, media_id: &str) -> Result> { + let resp = self + .client + .get(self.url(&format!("/media/{media_id}/tags"))) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn list_collections(&self) -> Result> { + let resp = self + .client + .get(self.url("/collections")) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn delete_collection(&self, id: &str) -> Result<()> { + self.client + .delete(self.url(&format!("/collections/{id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn trigger_scan(&self, path: Option<&str>) -> Result> { + let body = match path { + Some(p) => serde_json::json!({"path": p}), + None => serde_json::json!({"path": null}), + }; + let resp = self + .client + .post(self.url("/scan")) + .json(&body) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn list_audit(&self, offset: u64, limit: u64) -> Result> { + let resp = self + .client + .get(self.url("/audit")) + .query(&[("offset", offset.to_string()), ("limit", limit.to_string())]) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn find_duplicates(&self) -> Result> { + let resp = self + .client + .get(self.url("/duplicates")) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn database_stats(&self) -> Result { + let resp = self + .client + .get(self.url("/database/stats")) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn list_jobs(&self) -> Result> { + let resp = self + .client + .get(self.url("/jobs")) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn vacuum_database(&self) -> Result<()> { + self.client + .post(self.url("/database/vacuum")) + .json(&serde_json::json!({})) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn update_media( + &self, + id: &str, + updates: serde_json::Value, + ) -> Result { + let resp = self + .client + .patch(self.url(&format!("/media/{id}"))) + .json(&updates) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn library_statistics(&self) -> Result { + let resp = self + .client + .get(self.url("/statistics")) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn list_scheduled_tasks(&self) -> Result> { + let resp = self + .client + .get(self.url("/tasks/scheduled")) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + pub async fn toggle_scheduled_task(&self, id: &str) -> Result<()> { + self.client + .post(self.url(&format!("/tasks/scheduled/{id}/toggle"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn run_task_now(&self, id: &str) -> Result<()> { + self.client + .post(self.url(&format!("/tasks/scheduled/{id}/run-now"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } +} diff --git a/crates/pinakes-tui/src/event.rs b/crates/pinakes-tui/src/event.rs new file mode 100644 index 0000000..bc0d8f6 --- /dev/null +++ b/crates/pinakes-tui/src/event.rs @@ -0,0 +1,74 @@ +use std::time::Duration; + +use crossterm::event::{self, Event as CrosstermEvent, KeyEvent}; +use tokio::sync::mpsc; + +#[derive(Debug)] +pub enum AppEvent { + Key(KeyEvent), + Tick, + ApiResult(ApiResult), +} + +#[derive(Debug)] +#[allow(dead_code)] +pub enum ApiResult { + MediaList(Vec), + SearchResults(crate::client::SearchResponse), + Tags(Vec), + AllTags(Vec), + Collections(Vec), + ImportDone(crate::client::ImportResponse), + ScanDone(Vec), + AuditLog(Vec), + Duplicates(Vec), + DatabaseStats(crate::client::DatabaseStatsResponse), + Statistics(crate::client::LibraryStatisticsResponse), + ScheduledTasks(Vec), + MediaUpdated, + Error(String), +} + +pub struct EventHandler { + tx: mpsc::UnboundedSender, + rx: mpsc::UnboundedReceiver, +} + +impl EventHandler { + pub fn new(tick_rate: Duration) -> Self { + let (tx, rx) = mpsc::unbounded_channel(); + let event_tx = tx.clone(); + + std::thread::spawn(move || { + loop { + match event::poll(tick_rate) { + Ok(true) => { + if let Ok(CrosstermEvent::Key(key)) = event::read() + && event_tx.send(AppEvent::Key(key)).is_err() + { + break; + } + } + Ok(false) => { + if event_tx.send(AppEvent::Tick).is_err() { + break; + } + } + Err(e) => { + tracing::warn!(error = %e, "event poll failed"); + } + } + } + }); + + Self { tx, rx } + } + + pub fn sender(&self) -> mpsc::UnboundedSender { + self.tx.clone() + } + + pub async fn next(&mut self) -> Option { + self.rx.recv().await + } +} diff --git a/crates/pinakes-tui/src/input.rs b/crates/pinakes-tui/src/input.rs new file mode 100644 index 0000000..e21823b --- /dev/null +++ b/crates/pinakes-tui/src/input.rs @@ -0,0 +1,97 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use crate::app::View; + +pub enum Action { + Quit, + NavigateUp, + NavigateDown, + NavigateLeft, + NavigateRight, + Select, + Back, + Search, + Import, + Delete, + DeleteSelected, + Open, + TagView, + CollectionView, + AuditView, + SettingsView, + DuplicatesView, + DatabaseView, + QueueView, + StatisticsView, + TasksView, + ScanTrigger, + Refresh, + NextTab, + PrevTab, + PageUp, + PageDown, + GoTop, + GoBottom, + CreateTag, + TagMedia, + UntagMedia, + Help, + Char(char), + Backspace, + None, +} + +pub fn handle_key(key: KeyEvent, in_input_mode: bool, current_view: &View) -> Action { + if in_input_mode { + match key.code { + KeyCode::Esc => Action::Back, + KeyCode::Enter => Action::Select, + KeyCode::Char(c) => Action::Char(c), + KeyCode::Backspace => Action::Backspace, + _ => Action::None, + } + } else { + match (key.code, key.modifiers) { + (KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => Action::Quit, + (KeyCode::Up | KeyCode::Char('k'), _) => Action::NavigateUp, + (KeyCode::Down | KeyCode::Char('j'), _) => Action::NavigateDown, + (KeyCode::Left | KeyCode::Char('h'), _) => Action::NavigateLeft, + (KeyCode::Right | KeyCode::Char('l'), _) => Action::NavigateRight, + (KeyCode::Home, _) => Action::GoTop, + (KeyCode::End, _) => Action::GoBottom, + (KeyCode::Enter, _) => Action::Select, + (KeyCode::Esc, _) => Action::Back, + (KeyCode::Char('/'), _) => Action::Search, + (KeyCode::Char('?'), _) => Action::Help, + (KeyCode::Char('i'), _) => Action::Import, + (KeyCode::Char('d'), _) => match current_view { + View::Tags | View::Collections => Action::DeleteSelected, + _ => Action::Delete, + }, + (KeyCode::Char('o'), _) => Action::Open, + (KeyCode::Char('e'), _) => match current_view { + View::Detail => Action::Select, + _ => Action::None, + }, + (KeyCode::Char('t'), _) => Action::TagView, + (KeyCode::Char('c'), _) => Action::CollectionView, + (KeyCode::Char('a'), _) => Action::AuditView, + (KeyCode::Char('S'), _) => Action::SettingsView, + (KeyCode::Char('D'), _) => Action::DuplicatesView, + (KeyCode::Char('B'), _) => Action::DatabaseView, + (KeyCode::Char('Q'), _) => Action::QueueView, + (KeyCode::Char('X'), _) => Action::StatisticsView, + (KeyCode::Char('T'), _) => Action::TasksView, + (KeyCode::Char('s'), _) => Action::ScanTrigger, + (KeyCode::Char('r'), _) => Action::Refresh, + (KeyCode::Char('n'), _) => Action::CreateTag, + (KeyCode::Char('+'), _) => Action::TagMedia, + (KeyCode::Char('-'), _) => Action::UntagMedia, + (KeyCode::Tab, _) => Action::NextTab, + (KeyCode::BackTab, _) => Action::PrevTab, + (KeyCode::PageUp, _) => Action::PageUp, + (KeyCode::PageDown, _) => Action::PageDown, + _ => Action::None, + } + } +} diff --git a/crates/pinakes-tui/src/main.rs b/crates/pinakes-tui/src/main.rs new file mode 100644 index 0000000..6fa207c --- /dev/null +++ b/crates/pinakes-tui/src/main.rs @@ -0,0 +1,55 @@ +use anyhow::Result; +use clap::Parser; +use tracing_subscriber::EnvFilter; + +mod app; +mod client; +mod event; +mod input; +mod ui; + +/// Pinakes terminal UI client +#[derive(Parser)] +#[command(name = "pinakes-tui", version, about)] +struct Cli { + /// Server URL to connect to + #[arg( + short, + long, + env = "PINAKES_SERVER_URL", + default_value = "http://localhost:3000" + )] + server: String, + + /// Set log level (trace, debug, info, warn, error) + #[arg(long, default_value = "warn")] + log_level: String, + + /// Log to file instead of stderr (avoids corrupting TUI display) + #[arg(long)] + log_file: Option, +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + + // Initialize logging - for TUI, must log to file to avoid corrupting the display + let env_filter = EnvFilter::try_new(&cli.log_level).unwrap_or_else(|_| EnvFilter::new("warn")); + + if let Some(log_path) = &cli.log_file { + let file = std::fs::File::create(log_path)?; + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .with_writer(file) + .with_ansi(false) + .init(); + } else { + // When no log file specified, suppress all output to avoid TUI corruption + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::new("off")) + .init(); + } + + app::run(&cli.server).await +} diff --git a/crates/pinakes-tui/src/ui/audit.rs b/crates/pinakes-tui/src/ui/audit.rs new file mode 100644 index 0000000..386ebda --- /dev/null +++ b/crates/pinakes-tui/src/ui/audit.rs @@ -0,0 +1,85 @@ +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::Span; +use ratatui::widgets::{Block, Borders, Cell, Row, Table}; + +use super::format_date; +use crate::app::AppState; + +/// Return a color for an audit action string. +fn action_color(action: &str) -> Color { + match action { + "imported" | "import" | "created" => Color::Green, + "deleted" | "delete" | "removed" => Color::Red, + "tagged" | "tag_added" => Color::Cyan, + "untagged" | "tag_removed" => Color::Yellow, + "updated" | "modified" | "edited" => Color::Blue, + "scanned" | "scan" => Color::Magenta, + _ => Color::White, + } +} + +pub fn render(f: &mut Frame, state: &AppState, area: Rect) { + let header = Row::new(vec!["Action", "Media ID", "Details", "Date"]).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + + let rows: Vec = state + .audit_log + .iter() + .enumerate() + .map(|(i, entry)| { + let style = if Some(i) == state.audit_selected { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + Style::default() + }; + + let color = action_color(&entry.action); + let action_cell = Cell::from(Span::styled( + entry.action.clone(), + Style::default().fg(color).add_modifier(Modifier::BOLD), + )); + + // Truncate media ID for display + let media_display = entry + .media_id + .as_deref() + .map(|id| { + if id.len() > 12 { + format!("{}...", &id[..12]) + } else { + id.to_string() + } + }) + .unwrap_or_else(|| "-".into()); + + Row::new(vec![ + action_cell, + Cell::from(media_display), + Cell::from(entry.details.clone().unwrap_or_else(|| "-".into())), + Cell::from(format_date(&entry.timestamp).to_string()), + ]) + .style(style) + }) + .collect(); + + let title = format!(" Audit Log ({}) ", state.audit_log.len()); + + let table = Table::new( + rows, + [ + ratatui::layout::Constraint::Percentage(18), + ratatui::layout::Constraint::Percentage(22), + ratatui::layout::Constraint::Percentage(40), + ratatui::layout::Constraint::Percentage(20), + ], + ) + .header(header) + .block(Block::default().borders(Borders::ALL).title(title)); + + f.render_widget(table, area); +} diff --git a/crates/pinakes-tui/src/ui/collections.rs b/crates/pinakes-tui/src/ui/collections.rs new file mode 100644 index 0000000..b528c23 --- /dev/null +++ b/crates/pinakes-tui/src/ui/collections.rs @@ -0,0 +1,64 @@ +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::widgets::{Block, Borders, Row, Table}; + +use super::format_date; +use crate::app::AppState; + +pub fn render(f: &mut Frame, state: &AppState, area: Rect) { + let header = Row::new(vec!["Name", "Kind", "Description", "Members", "Created"]).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + + let rows: Vec = state + .collections + .iter() + .enumerate() + .map(|(i, col)| { + let style = if Some(i) == state.collection_selected { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + Style::default() + }; + + // We show the filter_query as a proxy for member info when kind is "smart" + let members_display = if col.kind == "smart" { + col.filter_query + .as_deref() + .map(|q| format!("filter: {q}")) + .unwrap_or_else(|| "-".to_string()) + } else { + "-".to_string() + }; + + Row::new(vec![ + col.name.clone(), + col.kind.clone(), + col.description.clone().unwrap_or_else(|| "-".into()), + members_display, + format_date(&col.created_at).to_string(), + ]) + .style(style) + }) + .collect(); + + let title = format!(" Collections ({}) ", state.collections.len()); + + let table = Table::new( + rows, + [ + ratatui::layout::Constraint::Percentage(25), + ratatui::layout::Constraint::Percentage(12), + ratatui::layout::Constraint::Percentage(28), + ratatui::layout::Constraint::Percentage(15), + ratatui::layout::Constraint::Percentage(20), + ], + ) + .header(header) + .block(Block::default().borders(Borders::ALL).title(title)); + + f.render_widget(table, area); +} diff --git a/crates/pinakes-tui/src/ui/database.rs b/crates/pinakes-tui/src/ui/database.rs new file mode 100644 index 0000000..abfab94 --- /dev/null +++ b/crates/pinakes-tui/src/ui/database.rs @@ -0,0 +1,55 @@ +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; + +use crate::app::AppState; + +pub fn render(f: &mut Frame, state: &AppState, area: Rect) { + let label_style = Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD); + let value_style = Style::default().fg(Color::White); + let section_style = Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD); + + let pad = " "; + + let mut lines = vec![ + Line::default(), + Line::from(Span::styled("--- Database Statistics ---", section_style)), + ]; + + if let Some(ref stats) = state.database_stats { + for (key, value) in stats { + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(format!("{key:<20}"), label_style), + Span::styled(value.to_string(), value_style), + ])); + } + } else { + lines.push(Line::from(vec![ + Span::raw(pad), + Span::raw("Press 'r' to load database statistics"), + ])); + } + + lines.push(Line::default()); + lines.push(Line::from(Span::styled("--- Actions ---", section_style))); + lines.push(Line::from(vec![ + Span::raw(pad), + Span::raw("v: Vacuum database"), + ])); + lines.push(Line::from(vec![ + Span::raw(pad), + Span::raw("Esc: Return to library"), + ])); + + let paragraph = + Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" Database ")); + + f.render_widget(paragraph, area); +} diff --git a/crates/pinakes-tui/src/ui/detail.rs b/crates/pinakes-tui/src/ui/detail.rs new file mode 100644 index 0000000..f43058a --- /dev/null +++ b/crates/pinakes-tui/src/ui/detail.rs @@ -0,0 +1,223 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; + +use super::{format_date, format_duration, format_size, media_type_color}; +use crate::app::AppState; + +pub fn render(f: &mut Frame, state: &AppState, area: Rect) { + let item = match &state.selected_media { + Some(item) => item, + None => { + let msg = Paragraph::new("No item selected") + .block(Block::default().borders(Borders::ALL).title(" Detail ")); + f.render_widget(msg, area); + return; + } + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0)]) + .split(area); + + let label_style = Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD); + let value_style = Style::default().fg(Color::White); + let dim_style = Style::default().fg(Color::DarkGray); + + let pad = " "; + let label_width = 14; + let make_label = |name: &str| -> String { format!("{name: = Vec::new(); + + // Section: File Info + lines.push(Line::from(Span::styled( + "--- File Info ---", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ))); + + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Name"), label_style), + Span::styled(&item.file_name, value_style), + ])); + + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Path"), label_style), + Span::styled(&item.path, dim_style), + ])); + + let type_color = media_type_color(&item.media_type); + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Type"), label_style), + Span::styled(&item.media_type, Style::default().fg(type_color)), + ])); + + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Size"), label_style), + Span::styled(format_size(item.file_size), value_style), + ])); + + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Hash"), label_style), + Span::styled(&item.content_hash, dim_style), + ])); + + if item.has_thumbnail { + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Thumbnail"), label_style), + Span::styled("Yes", Style::default().fg(Color::Green)), + ])); + } + + lines.push(Line::default()); // blank line + + // Section: Metadata + lines.push(Line::from(Span::styled( + "--- Metadata ---", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ))); + + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Title"), label_style), + Span::styled(item.title.as_deref().unwrap_or("-"), value_style), + ])); + + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Artist"), label_style), + Span::styled(item.artist.as_deref().unwrap_or("-"), value_style), + ])); + + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Album"), label_style), + Span::styled(item.album.as_deref().unwrap_or("-"), value_style), + ])); + + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Genre"), label_style), + Span::styled(item.genre.as_deref().unwrap_or("-"), value_style), + ])); + + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Year"), label_style), + Span::styled( + item.year + .map(|y| y.to_string()) + .unwrap_or_else(|| "-".to_string()), + value_style, + ), + ])); + + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Duration"), label_style), + Span::styled( + item.duration_secs + .map(format_duration) + .unwrap_or_else(|| "-".to_string()), + value_style, + ), + ])); + + // Description + if let Some(ref desc) = item.description + && !desc.is_empty() + { + lines.push(Line::default()); + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Description"), label_style), + Span::styled(desc.as_str(), value_style), + ])); + } + + // Custom fields + if !item.custom_fields.is_empty() { + lines.push(Line::default()); + lines.push(Line::from(Span::styled( + "--- Custom Fields ---", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ))); + let mut fields: Vec<_> = item.custom_fields.iter().collect(); + fields.sort_by_key(|(k, _)| k.as_str()); + for (key, field) in fields { + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(format!("{key: = state.tags.iter().map(|t| t.name.as_str()).collect(); + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(tag_names.join(", "), Style::default().fg(Color::Green)), + ])); + } + + lines.push(Line::default()); + + // Section: Timestamps + lines.push(Line::from(Span::styled( + "--- Timestamps ---", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ))); + + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Created"), label_style), + Span::styled(format_date(&item.created_at), dim_style), + ])); + + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled(make_label("Updated"), label_style), + Span::styled(format_date(&item.updated_at), dim_style), + ])); + + let title = if let Some(ref title_str) = item.title { + format!(" Detail: {} ", title_str) + } else { + format!(" Detail: {} ", item.file_name) + }; + + let detail = Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(title)); + + f.render_widget(detail, chunks[0]); +} diff --git a/crates/pinakes-tui/src/ui/duplicates.rs b/crates/pinakes-tui/src/ui/duplicates.rs new file mode 100644 index 0000000..724ef6f --- /dev/null +++ b/crates/pinakes-tui/src/ui/duplicates.rs @@ -0,0 +1,56 @@ +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, List, ListItem}; + +use crate::app::AppState; + +pub fn render(f: &mut Frame, state: &AppState, area: Rect) { + let items: Vec = if state.duplicate_groups.is_empty() { + vec![ListItem::new(Line::from(Span::styled( + " No duplicates found. Press 'r' to refresh.", + Style::default().fg(Color::DarkGray), + )))] + } else { + let mut list_items = Vec::new(); + for (i, group) in state.duplicate_groups.iter().enumerate() { + let header = format!( + "Group {} ({} items, hash: {})", + i + 1, + group.len(), + group + .first() + .map(|m| m.content_hash.as_str()) + .unwrap_or("?") + ); + list_items.push(ListItem::new(Line::from(Span::styled( + header, + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )))); + for item in group { + let line = format!(" {} - {}", item.file_name, item.path); + let is_selected = state + .duplicates_selected + .map(|sel| sel == list_items.len()) + .unwrap_or(false); + let style = if is_selected { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + list_items.push(ListItem::new(Line::from(Span::styled(line, style)))); + } + list_items.push(ListItem::new(Line::default())); + } + list_items + }; + + let list = List::new(items).block(Block::default().borders(Borders::ALL).title(" Duplicates ")); + + f.render_widget(list, area); +} diff --git a/crates/pinakes-tui/src/ui/import.rs b/crates/pinakes-tui/src/ui/import.rs new file mode 100644 index 0000000..ce6079e --- /dev/null +++ b/crates/pinakes-tui/src/ui/import.rs @@ -0,0 +1,65 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; + +use crate::app::AppState; + +pub fn render(f: &mut Frame, state: &AppState, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(0)]) + .split(area); + + let input = Paragraph::new(state.import_input.as_str()) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Import File (enter path and press Enter) "), + ) + .style(if state.input_mode { + Style::default().fg(Color::Cyan) + } else { + Style::default() + }); + f.render_widget(input, chunks[0]); + + let label_style = Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD); + let key_style = Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD); + + let help_lines = vec![ + Line::default(), + Line::from(Span::styled( + " Import a file or trigger a library scan", + label_style, + )), + Line::default(), + Line::from(vec![ + Span::styled(" Enter", key_style), + Span::raw(" Import the file at the entered path"), + ]), + Line::from(vec![ + Span::styled(" Esc", key_style), + Span::raw(" Cancel and return to library"), + ]), + Line::from(vec![ + Span::styled(" s", key_style), + Span::raw(" Trigger a full library scan (scans all configured directories)"), + ]), + Line::default(), + Line::from(Span::styled(" Tips:", label_style)), + Line::from(" - Enter an absolute path to a media file (e.g. /home/user/music/song.mp3)"), + Line::from(" - The file will be copied into the managed library"), + Line::from(" - Duplicates are detected by content hash and will be skipped"), + Line::from(" - Press 's' (without typing a path) to scan all library directories"), + ]; + + let help = + Paragraph::new(help_lines).block(Block::default().borders(Borders::ALL).title(" Help ")); + f.render_widget(help, chunks[1]); +} diff --git a/crates/pinakes-tui/src/ui/library.rs b/crates/pinakes-tui/src/ui/library.rs new file mode 100644 index 0000000..4740bf3 --- /dev/null +++ b/crates/pinakes-tui/src/ui/library.rs @@ -0,0 +1,75 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::Span; +use ratatui::widgets::{Block, Borders, Cell, Row, Table}; + +use super::{format_duration, format_size, media_type_color}; +use crate::app::AppState; + +pub fn render(f: &mut Frame, state: &AppState, area: Rect) { + let header = Row::new(vec!["Title / Name", "Type", "Duration", "Year", "Size"]).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + + let rows: Vec = state + .media_list + .iter() + .enumerate() + .map(|(i, item)| { + let style = if Some(i) == state.selected_index { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + Style::default() + }; + + let display_name = item.title.as_deref().unwrap_or(&item.file_name).to_string(); + + let type_color = media_type_color(&item.media_type); + let type_cell = Cell::from(Span::styled( + item.media_type.clone(), + Style::default().fg(type_color), + )); + + let duration = item + .duration_secs + .map(format_duration) + .unwrap_or_else(|| "-".to_string()); + + let year = item + .year + .map(|y| y.to_string()) + .unwrap_or_else(|| "-".to_string()); + + Row::new(vec![ + Cell::from(display_name), + type_cell, + Cell::from(duration), + Cell::from(year), + Cell::from(format_size(item.file_size)), + ]) + .style(style) + }) + .collect(); + + let page = (state.page_offset / state.page_size) + 1; + let item_count = state.media_list.len(); + let title = format!(" Library (page {page}, {item_count} items) "); + + let table = Table::new( + rows, + [ + Constraint::Percentage(35), + Constraint::Percentage(20), + Constraint::Percentage(15), + Constraint::Percentage(10), + Constraint::Percentage(20), + ], + ) + .header(header) + .block(Block::default().borders(Borders::ALL).title(title)); + + f.render_widget(table, area); +} diff --git a/crates/pinakes-tui/src/ui/metadata_edit.rs b/crates/pinakes-tui/src/ui/metadata_edit.rs new file mode 100644 index 0000000..3422bd9 --- /dev/null +++ b/crates/pinakes-tui/src/ui/metadata_edit.rs @@ -0,0 +1,83 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; + +use crate::app::AppState; + +pub fn render(f: &mut Frame, state: &AppState, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(0)]) + .split(area); + + // Header + let title = if let Some(ref media) = state.selected_media { + format!(" Edit: {} ", media.file_name) + } else { + " Edit Metadata ".to_string() + }; + + let header = Paragraph::new(Line::from(Span::styled( + &title, + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ))) + .block(Block::default().borders(Borders::ALL)); + + f.render_widget(header, chunks[0]); + + // Edit fields + let label_style = Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD); + let value_style = Style::default().fg(Color::White); + let active_style = Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD); + let pad = " "; + + let fields = [ + ("Title", &state.edit_title), + ("Artist", &state.edit_artist), + ("Album", &state.edit_album), + ("Genre", &state.edit_genre), + ("Year", &state.edit_year), + ("Description", &state.edit_description), + ]; + + let mut lines = Vec::new(); + lines.push(Line::default()); + + for (i, (label, value)) in fields.iter().enumerate() { + let is_active = state.edit_field_index == Some(i); + let style = if is_active { active_style } else { label_style }; + let cursor = if is_active { "> " } else { pad }; + lines.push(Line::from(vec![ + Span::raw(cursor), + Span::styled(format!("{label:<14}"), style), + Span::styled(value.as_str(), value_style), + if is_active { + Span::styled("_", Style::default().fg(Color::Green)) + } else { + Span::raw("") + }, + ])); + } + + lines.push(Line::default()); + lines.push(Line::from(vec![ + Span::raw(pad), + Span::styled( + "Tab: Next field Enter: Save Esc: Cancel", + Style::default().fg(Color::DarkGray), + ), + ])); + + let editor = + Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" Fields ")); + + f.render_widget(editor, chunks[1]); +} diff --git a/crates/pinakes-tui/src/ui/mod.rs b/crates/pinakes-tui/src/ui/mod.rs new file mode 100644 index 0000000..6055cae --- /dev/null +++ b/crates/pinakes-tui/src/ui/mod.rs @@ -0,0 +1,190 @@ +pub mod audit; +pub mod collections; +pub mod database; +pub mod detail; +pub mod duplicates; +pub mod import; +pub mod library; +pub mod metadata_edit; +pub mod queue; +pub mod search; +pub mod settings; +pub mod statistics; +pub mod tags; +pub mod tasks; + +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph, Tabs}; + +use crate::app::{AppState, View}; + +/// Format a file size in bytes into a human-readable string. +pub fn format_size(bytes: u64) -> String { + if bytes < 1024 { + format!("{bytes} B") + } else if bytes < 1024 * 1024 { + format!("{:.1} KB", bytes as f64 / 1024.0) + } else if bytes < 1024 * 1024 * 1024 { + format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) + } else { + format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) + } +} + +/// Format duration in seconds into hh:mm:ss format. +pub fn format_duration(secs: f64) -> String { + let total = secs as u64; + let h = total / 3600; + let m = (total % 3600) / 60; + let s = total % 60; + if h > 0 { + format!("{h:02}:{m:02}:{s:02}") + } else { + format!("{m:02}:{s:02}") + } +} + +/// Trim a timestamp string to just the date portion (YYYY-MM-DD). +pub fn format_date(timestamp: &str) -> &str { + // Timestamps are typically "2024-01-15T10:30:00Z" or similar + if timestamp.len() >= 10 { + ×tamp[..10] + } else { + timestamp + } +} + +/// Return a color based on media type string. +pub fn media_type_color(media_type: &str) -> Color { + match media_type { + t if t.starts_with("audio") => Color::Green, + t if t.starts_with("video") => Color::Magenta, + t if t.starts_with("image") => Color::Yellow, + t if t.starts_with("application/pdf") => Color::Red, + t if t.starts_with("text") => Color::Cyan, + _ => Color::White, + } +} + +pub fn render(f: &mut Frame, state: &AppState) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(0), + Constraint::Length(1), + ]) + .split(f.area()); + + render_tabs(f, state, chunks[0]); + + match state.current_view { + View::Library => library::render(f, state, chunks[1]), + View::Search => search::render(f, state, chunks[1]), + View::Detail => detail::render(f, state, chunks[1]), + View::Tags => tags::render(f, state, chunks[1]), + View::Collections => collections::render(f, state, chunks[1]), + View::Audit => audit::render(f, state, chunks[1]), + View::Import => import::render(f, state, chunks[1]), + View::Settings => settings::render(f, state, chunks[1]), + View::Duplicates => duplicates::render(f, state, chunks[1]), + View::Database => database::render(f, state, chunks[1]), + View::MetadataEdit => metadata_edit::render(f, state, chunks[1]), + View::Queue => queue::render(f, state, chunks[1]), + View::Statistics => statistics::render(f, state, chunks[1]), + View::Tasks => tasks::render(f, state, chunks[1]), + } + + render_status_bar(f, state, chunks[2]); +} + +fn render_tabs(f: &mut Frame, state: &AppState, area: Rect) { + let titles: Vec = vec![ + "Library", + "Search", + "Tags", + "Collections", + "Audit", + "Queue", + "Stats", + "Tasks", + ] + .into_iter() + .map(|t| Line::from(Span::styled(t, Style::default().fg(Color::White)))) + .collect(); + + let selected = match state.current_view { + View::Library | View::Detail | View::Import | View::Settings | View::MetadataEdit => 0, + View::Search => 1, + View::Tags => 2, + View::Collections => 3, + View::Audit | View::Duplicates | View::Database => 4, + View::Queue => 5, + View::Statistics => 6, + View::Tasks => 7, + }; + + let tabs = Tabs::new(titles) + .block(Block::default().borders(Borders::ALL).title(" Pinakes ")) + .select(selected) + .style(Style::default().fg(Color::Gray)) + .highlight_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ); + + f.render_widget(tabs, area); +} + +fn render_status_bar(f: &mut Frame, state: &AppState, area: Rect) { + let status = if let Some(ref msg) = state.status_message { + msg.clone() + } else { + match state.current_view { + View::Tags => { + " q:Quit j/k:Nav Home/End:Top/Bot n:New d:Delete r:Refresh Tab:Switch" + .to_string() + } + View::Collections => { + " q:Quit j/k:Nav Home/End:Top/Bot d:Delete r:Refresh Tab:Switch".to_string() + } + View::Audit => { + " q:Quit j/k:Nav Home/End:Top/Bot r:Refresh Tab:Switch".to_string() + } + View::Detail => { + " q:Quit Esc:Back o:Open e:Edit +:Tag -:Untag r:Refresh ?:Help".to_string() + } + View::Import => { + " Enter:Import Esc:Cancel s:Scan libraries ?:Help".to_string() + } + View::Settings => " q:Quit Esc:Back ?:Help".to_string(), + View::Duplicates => " q:Quit j/k:Nav r:Refresh Esc:Back".to_string(), + View::Database => " q:Quit v:Vacuum r:Refresh Esc:Back".to_string(), + View::MetadataEdit => { + " Tab:Next field Enter:Save Esc:Cancel".to_string() + } + View::Queue => { + " q:Quit j/k:Nav Enter:Play d:Remove N:Next P:Prev R:Repeat S:Shuffle C:Clear" + .to_string() + } + View::Statistics => " q:Quit r:Refresh Esc:Back ?:Help".to_string(), + View::Tasks => { + " q:Quit j/k:Nav Enter:Toggle R:Run Now r:Refresh Esc:Back".to_string() + } + _ => { + " q:Quit /:Search i:Import o:Open t:Tags c:Coll a:Audit D:Dupes B:DB Q:Queue X:Stats T:Tasks ?:Help" + .to_string() + } + } + }; + + let paragraph = Paragraph::new(Line::from(Span::styled( + status, + Style::default().fg(Color::DarkGray), + ))); + f.render_widget(paragraph, area); +} diff --git a/crates/pinakes-tui/src/ui/queue.rs b/crates/pinakes-tui/src/ui/queue.rs new file mode 100644 index 0000000..fcb4537 --- /dev/null +++ b/crates/pinakes-tui/src/ui/queue.rs @@ -0,0 +1,69 @@ +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, List, ListItem}; + +use crate::app::AppState; + +pub fn render(f: &mut Frame, state: &AppState, area: Rect) { + let items: Vec = if state.play_queue.is_empty() { + vec![ListItem::new(Line::from(Span::styled( + " Queue is empty. Select items in the library and press 'q' to add.", + Style::default().fg(Color::DarkGray), + )))] + } else { + state + .play_queue + .iter() + .enumerate() + .map(|(i, item)| { + let is_current = state.queue_current_index == Some(i); + let is_selected = state.queue_selected == Some(i); + let prefix = if is_current { ">> " } else { " " }; + let type_color = super::media_type_color(&item.media_type); + let id_suffix = if item.media_id.len() > 8 { + &item.media_id[item.media_id.len() - 8..] + } else { + &item.media_id + }; + let text = if let Some(ref artist) = item.artist { + format!("{prefix}{} - {} [{}]", item.title, artist, id_suffix) + } else { + format!("{prefix}{} [{}]", item.title, id_suffix) + }; + + let style = if is_selected { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else if is_current { + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(type_color) + }; + + ListItem::new(Line::from(Span::styled(text, style))) + }) + .collect() + }; + + let repeat_str = match state.queue_repeat { + 0 => "Off", + 1 => "One", + _ => "All", + }; + let shuffle_str = if state.queue_shuffle { "On" } else { "Off" }; + let title = format!( + " Queue ({}) | Repeat: {} | Shuffle: {} ", + state.play_queue.len(), + repeat_str, + shuffle_str, + ); + + let list = List::new(items).block(Block::default().borders(Borders::ALL).title(title)); + + f.render_widget(list, area); +} diff --git a/crates/pinakes-tui/src/ui/search.rs b/crates/pinakes-tui/src/ui/search.rs new file mode 100644 index 0000000..ba46984 --- /dev/null +++ b/crates/pinakes-tui/src/ui/search.rs @@ -0,0 +1,81 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::Span; +use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table}; + +use super::{format_size, media_type_color}; +use crate::app::AppState; + +pub fn render(f: &mut Frame, state: &AppState, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(0)]) + .split(area); + + // Search input + let input = Paragraph::new(state.search_input.as_str()) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Search (type and press Enter) "), + ) + .style(if state.input_mode { + Style::default().fg(Color::Cyan) + } else { + Style::default() + }); + f.render_widget(input, chunks[0]); + + // Results + let header = Row::new(vec!["Name", "Type", "Artist", "Size"]).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + + let rows: Vec = state + .search_results + .iter() + .enumerate() + .map(|(i, item)| { + let style = if Some(i) == state.search_selected { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + Style::default() + }; + + let type_color = media_type_color(&item.media_type); + let type_cell = Cell::from(Span::styled( + item.media_type.clone(), + Style::default().fg(type_color), + )); + + Row::new(vec![ + Cell::from(item.file_name.clone()), + type_cell, + Cell::from(item.artist.clone().unwrap_or_default()), + Cell::from(format_size(item.file_size)), + ]) + .style(style) + }) + .collect(); + + let shown = state.search_results.len(); + let total = state.search_total_count; + let results_title = format!(" Results: {shown} shown, {total} total "); + + let table = Table::new( + rows, + [ + Constraint::Percentage(35), + Constraint::Percentage(20), + Constraint::Percentage(25), + Constraint::Percentage(20), + ], + ) + .header(header) + .block(Block::default().borders(Borders::ALL).title(results_title)); + + f.render_widget(table, chunks[1]); +} diff --git a/crates/pinakes-tui/src/ui/settings.rs b/crates/pinakes-tui/src/ui/settings.rs new file mode 100644 index 0000000..b2394a5 --- /dev/null +++ b/crates/pinakes-tui/src/ui/settings.rs @@ -0,0 +1,82 @@ +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; + +use crate::app::AppState; + +pub fn render(f: &mut Frame, state: &AppState, area: Rect) { + let label_style = Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD); + let value_style = Style::default().fg(Color::White); + let section_style = Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD); + + let pad = " "; + + let lines = vec![ + Line::default(), + Line::from(Span::styled("--- Connection ---", section_style)), + Line::from(vec![ + Span::raw(pad), + Span::styled("Server URL: ", label_style), + Span::styled(&state.server_url, value_style), + ]), + Line::default(), + Line::from(Span::styled("--- Library ---", section_style)), + Line::from(vec![ + Span::raw(pad), + Span::styled("Total items: ", label_style), + Span::styled(state.total_media_count.to_string(), value_style), + ]), + Line::from(vec![ + Span::raw(pad), + Span::styled("Page size: ", label_style), + Span::styled(state.page_size.to_string(), value_style), + ]), + Line::from(vec![ + Span::raw(pad), + Span::styled("Current page: ", label_style), + Span::styled( + ((state.page_offset / state.page_size) + 1).to_string(), + value_style, + ), + ]), + Line::default(), + Line::from(Span::styled("--- State ---", section_style)), + Line::from(vec![ + Span::raw(pad), + Span::styled("Tags loaded: ", label_style), + Span::styled(state.tags.len().to_string(), value_style), + ]), + Line::from(vec![ + Span::raw(pad), + Span::styled("All tags: ", label_style), + Span::styled(state.all_tags.len().to_string(), value_style), + ]), + Line::from(vec![ + Span::raw(pad), + Span::styled("Collections: ", label_style), + Span::styled(state.collections.len().to_string(), value_style), + ]), + Line::from(vec![ + Span::raw(pad), + Span::styled("Audit entries: ", label_style), + Span::styled(state.audit_log.len().to_string(), value_style), + ]), + Line::default(), + Line::from(Span::styled("--- Shortcuts ---", section_style)), + Line::from(vec![ + Span::raw(pad), + Span::raw("Press Esc to return to the library view"), + ]), + ]; + + let settings = + Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" Settings ")); + + f.render_widget(settings, area); +} diff --git a/crates/pinakes-tui/src/ui/statistics.rs b/crates/pinakes-tui/src/ui/statistics.rs new file mode 100644 index 0000000..542d89d --- /dev/null +++ b/crates/pinakes-tui/src/ui/statistics.rs @@ -0,0 +1,183 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph, Row, Table}; + +use crate::app::AppState; + +pub fn render(f: &mut Frame, state: &AppState, area: Rect) { + let Some(ref stats) = state.library_stats else { + let msg = Paragraph::new("Loading statistics... (press X to refresh)") + .block(Block::default().borders(Borders::ALL).title(" Statistics ")); + f.render_widget(msg, area); + return; + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(8), // Overview + Constraint::Length(10), // Media by type + Constraint::Min(6), // Top tags & collections + ]) + .split(area); + + // Overview section + let overview_lines = vec![ + Line::from(vec![ + Span::styled(" Total Media: ", Style::default().fg(Color::Gray)), + Span::styled( + stats.total_media.to_string(), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled("Total Size: ", Style::default().fg(Color::Gray)), + Span::styled( + super::format_size(stats.total_size_bytes), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(vec![ + Span::styled(" Avg Size: ", Style::default().fg(Color::Gray)), + Span::styled( + super::format_size(stats.avg_file_size_bytes), + Style::default().fg(Color::White), + ), + ]), + Line::from(vec![ + Span::styled(" Tags: ", Style::default().fg(Color::Gray)), + Span::styled( + stats.total_tags.to_string(), + Style::default().fg(Color::Green), + ), + Span::raw(" "), + Span::styled("Collections: ", Style::default().fg(Color::Gray)), + Span::styled( + stats.total_collections.to_string(), + Style::default().fg(Color::Green), + ), + Span::raw(" "), + Span::styled("Duplicates: ", Style::default().fg(Color::Gray)), + Span::styled( + stats.total_duplicates.to_string(), + Style::default().fg(Color::Yellow), + ), + ]), + Line::from(vec![ + Span::styled(" Newest: ", Style::default().fg(Color::Gray)), + Span::styled( + stats + .newest_item + .as_deref() + .map(super::format_date) + .unwrap_or("-"), + Style::default().fg(Color::White), + ), + Span::raw(" "), + Span::styled("Oldest: ", Style::default().fg(Color::Gray)), + Span::styled( + stats + .oldest_item + .as_deref() + .map(super::format_date) + .unwrap_or("-"), + Style::default().fg(Color::White), + ), + ]), + ]; + + let overview = Paragraph::new(overview_lines) + .block(Block::default().borders(Borders::ALL).title(" Overview ")); + f.render_widget(overview, chunks[0]); + + // Media by Type table + let type_rows: Vec = stats + .media_by_type + .iter() + .map(|tc| { + let color = super::media_type_color(&tc.name); + Row::new(vec![ + Span::styled(tc.name.clone(), Style::default().fg(color)), + Span::styled(tc.count.to_string(), Style::default().fg(Color::White)), + ]) + }) + .collect(); + + let storage_rows: Vec = stats + .storage_by_type + .iter() + .map(|tc| { + Row::new(vec![ + Span::styled(tc.name.clone(), Style::default().fg(Color::Gray)), + Span::styled( + super::format_size(tc.count), + Style::default().fg(Color::White), + ), + ]) + }) + .collect(); + + let type_cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(chunks[1]); + + let type_table = Table::new(type_rows, [Constraint::Min(20), Constraint::Length(10)]).block( + Block::default() + .borders(Borders::ALL) + .title(" Media by Type "), + ); + f.render_widget(type_table, type_cols[0]); + + let storage_table = Table::new(storage_rows, [Constraint::Min(20), Constraint::Length(12)]) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Storage by Type "), + ); + f.render_widget(storage_table, type_cols[1]); + + // Top tags and collections + let bottom_cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(chunks[2]); + + let tag_rows: Vec = stats + .top_tags + .iter() + .map(|tc| { + Row::new(vec![ + Span::styled(tc.name.clone(), Style::default().fg(Color::Green)), + Span::styled(tc.count.to_string(), Style::default().fg(Color::White)), + ]) + }) + .collect(); + + let tags_table = Table::new(tag_rows, [Constraint::Min(20), Constraint::Length(10)]) + .block(Block::default().borders(Borders::ALL).title(" Top Tags ")); + f.render_widget(tags_table, bottom_cols[0]); + + let col_rows: Vec = stats + .top_collections + .iter() + .map(|tc| { + Row::new(vec![ + Span::styled(tc.name.clone(), Style::default().fg(Color::Magenta)), + Span::styled(tc.count.to_string(), Style::default().fg(Color::White)), + ]) + }) + .collect(); + + let cols_table = Table::new(col_rows, [Constraint::Min(20), Constraint::Length(10)]).block( + Block::default() + .borders(Borders::ALL) + .title(" Top Collections "), + ); + f.render_widget(cols_table, bottom_cols[1]); +} diff --git a/crates/pinakes-tui/src/ui/tags.rs b/crates/pinakes-tui/src/ui/tags.rs new file mode 100644 index 0000000..4b092ae --- /dev/null +++ b/crates/pinakes-tui/src/ui/tags.rs @@ -0,0 +1,61 @@ +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::widgets::{Block, Borders, Row, Table}; + +use super::format_date; +use crate::app::AppState; + +pub fn render(f: &mut Frame, state: &AppState, area: Rect) { + let header = Row::new(vec!["Name", "Parent", "Created"]).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + + let rows: Vec = state + .tags + .iter() + .enumerate() + .map(|(i, tag)| { + let style = if Some(i) == state.tag_selected { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + Style::default() + }; + + // Resolve parent tag name from the tags list itself + let parent_display = match &tag.parent_id { + Some(pid) => state + .tags + .iter() + .find(|t| t.id == *pid) + .map(|t| t.name.clone()) + .unwrap_or_else(|| pid.chars().take(8).collect::() + "..."), + None => "-".to_string(), + }; + + Row::new(vec![ + tag.name.clone(), + parent_display, + format_date(&tag.created_at).to_string(), + ]) + .style(style) + }) + .collect(); + + let title = format!(" Tags ({}) ", state.tags.len()); + + let table = Table::new( + rows, + [ + ratatui::layout::Constraint::Percentage(40), + ratatui::layout::Constraint::Percentage(30), + ratatui::layout::Constraint::Percentage(30), + ], + ) + .header(header) + .block(Block::default().borders(Borders::ALL).title(title)); + + f.render_widget(table, area); +} diff --git a/crates/pinakes-tui/src/ui/tasks.rs b/crates/pinakes-tui/src/ui/tasks.rs new file mode 100644 index 0000000..e35c75f --- /dev/null +++ b/crates/pinakes-tui/src/ui/tasks.rs @@ -0,0 +1,63 @@ +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, List, ListItem}; + +use crate::app::AppState; + +pub fn render(f: &mut Frame, state: &AppState, area: Rect) { + let items: Vec = if state.scheduled_tasks.is_empty() { + vec![ListItem::new(Line::from(Span::styled( + " No scheduled tasks. Press T to refresh.", + Style::default().fg(Color::DarkGray), + )))] + } else { + state + .scheduled_tasks + .iter() + .enumerate() + .map(|(i, task)| { + let is_selected = state.scheduled_tasks_selected == Some(i); + let enabled_marker = if task.enabled { "[ON] " } else { "[OFF]" }; + let enabled_color = if task.enabled { + Color::Green + } else { + Color::DarkGray + }; + + let last_run = task + .last_run + .as_deref() + .map(super::format_date) + .unwrap_or("-"); + let next_run = task + .next_run + .as_deref() + .map(super::format_date) + .unwrap_or("-"); + let status = task.last_status.as_deref().unwrap_or("-"); + + let text = format!( + " {enabled_marker} {:<20} {:<16} Last: {:<12} Next: {:<12} Status: {}", + task.name, task.schedule, last_run, next_run, status + ); + + let style = if is_selected { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(enabled_color) + }; + + ListItem::new(Line::from(Span::styled(text, style))) + }) + .collect() + }; + + let title = format!(" Scheduled Tasks ({}) ", state.scheduled_tasks.len()); + let list = List::new(items).block(Block::default().borders(Borders::ALL).title(title)); + + f.render_widget(list, area); +} diff --git a/crates/pinakes-ui/Cargo.toml b/crates/pinakes-ui/Cargo.toml new file mode 100644 index 0000000..46095c2 --- /dev/null +++ b/crates/pinakes-ui/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "pinakes-ui" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +chrono = { workspace = true } +uuid = { workspace = true } +anyhow = { workspace = true } +clap = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +reqwest = { workspace = true } +dioxus = { workspace = true } +tokio = { workspace = true } +rfd = "0.17" +pulldown-cmark = { workspace = true } +gray_matter = { workspace = true } diff --git a/crates/pinakes-ui/src/app.rs b/crates/pinakes-ui/src/app.rs new file mode 100644 index 0000000..b439acb --- /dev/null +++ b/crates/pinakes-ui/src/app.rs @@ -0,0 +1,1577 @@ +use std::sync::atomic::{AtomicUsize, Ordering}; + +use dioxus::prelude::*; + +use crate::client::*; +use crate::components::{ + audit, collections, database, detail, duplicates, import, library, search, settings, tags, +}; +// Login component available via crate::components::login when auth gating is needed +use crate::styles; + +static TOAST_ID_COUNTER: AtomicUsize = AtomicUsize::new(0); + +#[derive(Debug, Clone, PartialEq)] +enum View { + Library, + Search, + Detail, + Tags, + Collections, + Audit, + Import, + Duplicates, + Settings, + Database, +} + +impl View { + fn title(&self) -> &'static str { + match self { + Self::Library => "Library", + Self::Search => "Search", + Self::Detail => "Detail", + Self::Tags => "Tags", + Self::Collections => "Collections", + Self::Audit => "Audit Log", + Self::Import => "Import", + Self::Duplicates => "Duplicates", + Self::Settings => "Settings", + Self::Database => "Database", + } + } +} + +#[component] +pub fn App() -> Element { + // Phase 1.3: Auth support + let base_url = + std::env::var("PINAKES_SERVER_URL").unwrap_or_else(|_| "http://localhost:3000".into()); + let api_key = std::env::var("PINAKES_API_KEY").ok(); + let client = use_signal(|| ApiClient::new(&base_url, api_key.as_deref())); + let server_url = use_signal(|| base_url.clone()); + + let mut current_view = use_signal(|| View::Library); + let mut media_list = use_signal(Vec::::new); + let mut media_total_count = use_signal(|| 0u64); + let mut media_page = use_signal(|| 0u64); + let mut media_page_size = use_signal(|| 48u64); + let mut media_sort = use_signal(|| "created_at_desc".to_string()); + let mut search_results = use_signal(Vec::::new); + let mut search_total = use_signal(|| 0u64); + let mut selected_media = use_signal(|| Option::::None); + let mut media_tags = use_signal(Vec::::new); + let mut tags_list = use_signal(Vec::::new); + let mut collections_list = use_signal(Vec::::new); + let mut audit_list = use_signal(Vec::::new); + let mut config_data = use_signal(|| Option::::None); + let mut db_stats = use_signal(|| Option::::None); + let mut duplicate_groups = use_signal(Vec::::new); + let mut preview_files = use_signal(Vec::::new); + let mut preview_total_size = use_signal(|| 0u64); + let mut viewing_collection = use_signal(|| Option::::None); + let mut collection_members = use_signal(Vec::::new); + let mut server_connected = use_signal(|| false); + let mut server_checking = use_signal(|| true); + let mut loading = use_signal(|| true); + let mut load_error = use_signal(|| Option::::None); + + // Phase 1.4: Toast queue + let mut toast_queue = use_signal(Vec::<(String, bool, usize)>::new); + + // Phase 5.1: Search pagination + let mut search_page = use_signal(|| 0u64); + let search_page_size = use_signal(|| 50u64); + let mut last_search_query = use_signal(String::new); + let mut last_search_sort = use_signal(|| Option::::None); + + // Phase 6.1: Audit pagination & filter + let mut audit_page = use_signal(|| 0u64); + let audit_page_size = use_signal(|| 200u64); + let audit_total_count = use_signal(|| 0u64); + let mut audit_filter = use_signal(|| "All".to_string()); + + // Phase 6.2: Scan progress + let mut scan_progress = use_signal(|| Option::::None); + + // Phase 7.1: Help overlay + let mut show_help = use_signal(|| false); + + // Phase 8: Sidebar collapse + let mut sidebar_collapsed = use_signal(|| false); + + // Auth state + let mut auth_required = use_signal(|| false); + let mut current_user = use_signal(|| Option::::None); + let _login_error = use_signal(|| Option::::None); + let _login_loading = use_signal(|| false); + let mut auto_play_media = use_signal(|| false); + + // Check auth on startup + let client_auth = client.read().clone(); + use_effect(move || { + let client = client_auth.clone(); + spawn(async move { + match client.get_current_user().await { + Ok(user) => { + current_user.set(Some(user)); + auth_required.set(false); + } + Err(_) => { + // Check if server has accounts enabled by trying login endpoint + // If we get a 401 on /auth/me, accounts may be enabled + auth_required.set(false); // Will be set to true if needed + } + } + // Load UI config + if let Ok(cfg) = client.get_config().await { + auto_play_media.set(cfg.ui.auto_play_media); + sidebar_collapsed.set(cfg.ui.sidebar_collapsed); + if cfg.ui.default_page_size > 0 { + media_page_size.set(cfg.ui.default_page_size as u64); + } + config_data.set(Some(cfg)); + } + }); + }); + + // Health check polling + let client_health = client.read().clone(); + use_effect(move || { + let client = client_health.clone(); + spawn(async move { + loop { + server_checking.set(true); + let ok = client.health_check().await; + server_connected.set(ok); + server_checking.set(false); + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + } + }); + }); + + // Load initial data (Phase 2.2: pass sort to list_media) + let client_init = client.read().clone(); + let init_sort = media_sort.read().clone(); + use_effect(move || { + let client = client_init.clone(); + let sort = init_sort.clone(); + spawn(async move { + loading.set(true); + load_error.set(None); + match client.list_media(0, 48, Some(&sort)).await { + Ok(items) => media_list.set(items), + Err(e) => { + load_error.set(Some(format!("Failed to load media: {e}"))); + } + } + if let Ok(count) = client.get_media_count().await { + media_total_count.set(count); + } + if let Ok(t) = client.list_tags().await { + tags_list.set(t); + } + if let Ok(c) = client.list_collections().await { + collections_list.set(c); + } + loading.set(false); + }); + }); + + // Phase 1.4: Toast helper with queue support + let mut show_toast = move |msg: String, is_error: bool| { + let id = TOAST_ID_COUNTER.fetch_add(1, Ordering::Relaxed); + toast_queue.write().push((msg, is_error, id)); + // Keep at most 3 toasts + let len = toast_queue.read().len(); + if len > 3 { + toast_queue.write().drain(0..len - 3); + } + spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + toast_queue.write().retain(|(_, _, tid)| *tid != id); + }); + }; + + // Helper: refresh media list with current pagination (Phase 2.2: pass sort) + let refresh_media = { + let client = client.read().clone(); + move || { + let client = client.clone(); + spawn(async move { + loading.set(true); + let offset = *media_page.read() * *media_page_size.read(); + let limit = *media_page_size.read(); + let sort = media_sort.read().clone(); + if let Ok(items) = client.list_media(offset, limit, Some(&sort)).await { + media_list.set(items); + } + if let Ok(count) = client.get_media_count().await { + media_total_count.set(count); + } + loading.set(false); + }); + } + }; + + // Helper: refresh tags + let refresh_tags = { + let client = client.read().clone(); + move || { + let client = client.clone(); + spawn(async move { + if let Ok(t) = client.list_tags().await { + tags_list.set(t); + } + }); + } + }; + + // Helper: refresh collections + let refresh_collections = { + let client = client.read().clone(); + move || { + let client = client.clone(); + spawn(async move { + if let Ok(c) = client.list_collections().await { + collections_list.set(c); + } + }); + } + }; + + // Helper: refresh audit with pagination and filter (Phase 6.1) + let refresh_audit = { + let client = client.read().clone(); + move || { + let client = client.clone(); + spawn(async move { + let offset = *audit_page.read() * *audit_page_size.read(); + let limit = *audit_page_size.read(); + if let Ok(entries) = client.list_audit(offset, limit).await { + audit_list.set(entries); + } + }); + } + }; + + let view_title = use_memo(move || current_view.read().title()); + let _total_pages = use_memo(move || { + let ps = *media_page_size.read(); + let tc = *media_total_count.read(); + if ps > 0 { tc.div_ceil(ps) } else { 1 } + }); + + rsx! { + style { {styles::CSS} } + + // Phase 7.1: Keyboard shortcuts + div { class: "app", + tabindex: "0", + onkeydown: { + move |evt: KeyboardEvent| { + let key = evt.key(); + let ctrl = evt.modifiers().contains(Modifiers::CONTROL); + let meta = evt.modifiers().contains(Modifiers::META); + match key { + Key::Escape => { + if *show_help.read() { + show_help.set(false); + } else if *current_view.read() == View::Detail { + current_view.set(View::Library); + } + } + Key::Character(ref c) if c == "/" && !ctrl && !meta => { + evt.prevent_default(); + current_view.set(View::Search); + } + Key::Character(ref c) if c == "k" && (ctrl || meta) => { + evt.prevent_default(); + current_view.set(View::Search); + } + Key::Character(ref c) if c == "?" && !ctrl && !meta => { + show_help.toggle(); + } + _ => {} + } + } + }, + + // Sidebar + div { class: if *sidebar_collapsed.read() { "sidebar collapsed" } else { "sidebar" }, + div { class: "sidebar-header", + span { class: "logo", "Pinakes" } + span { class: "version", "v0.1" } + } + + div { class: "nav-section", + div { class: "nav-label", "Media" } + button { + class: if *current_view.read() == View::Library { "nav-item active" } else { "nav-item" }, + onclick: { + let refresh_media = refresh_media.clone(); + move |_| { + current_view.set(View::Library); + refresh_media(); + } + }, + span { class: "nav-icon", "\u{25a6}" } + "Library" + // Phase 7.2: Badge + span { class: "nav-badge", "{media_total_count}" } + } + button { + class: if *current_view.read() == View::Search { "nav-item active" } else { "nav-item" }, + onclick: move |_| current_view.set(View::Search), + span { class: "nav-icon", "\u{2315}" } + "Search" + } + button { + class: if *current_view.read() == View::Import { "nav-item active" } else { "nav-item" }, + onclick: { + let refresh_tags = refresh_tags.clone(); + let refresh_collections = refresh_collections.clone(); + move |_| { + current_view.set(View::Import); + preview_files.set(Vec::new()); + preview_total_size.set(0); + scan_progress.set(None); + refresh_tags(); + refresh_collections(); + } + }, + span { class: "nav-icon", "\u{2912}" } + "Import" + } + } + + div { class: "nav-section", + div { class: "nav-label", "Organize" } + button { + class: if *current_view.read() == View::Tags { "nav-item active" } else { "nav-item" }, + onclick: { + let refresh_tags = refresh_tags.clone(); + move |_| { + current_view.set(View::Tags); + refresh_tags(); + } + }, + span { class: "nav-icon", "\u{2605}" } + "Tags" + // Phase 7.2: Badge + span { class: "nav-badge", "{tags_list.read().len()}" } + } + button { + class: if *current_view.read() == View::Collections { "nav-item active" } else { "nav-item" }, + onclick: { + let refresh_collections = refresh_collections.clone(); + move |_| { + current_view.set(View::Collections); + viewing_collection.set(None); + collection_members.set(Vec::new()); + refresh_collections(); + } + }, + span { class: "nav-icon", "\u{2630}" } + "Collections" + // Phase 7.2: Badge + span { class: "nav-badge", "{collections_list.read().len()}" } + } + } + + div { class: "nav-section", + div { class: "nav-label", "System" } + button { + class: if *current_view.read() == View::Audit { "nav-item active" } else { "nav-item" }, + onclick: { + let refresh_audit = refresh_audit.clone(); + move |_| { + current_view.set(View::Audit); + refresh_audit(); + } + }, + span { class: "nav-icon", "\u{2637}" } + "Audit" + } + button { + class: if *current_view.read() == View::Duplicates { "nav-item active" } else { "nav-item" }, + onclick: { + let client = client.read().clone(); + move |_| { + current_view.set(View::Duplicates); + let client = client.clone(); + spawn(async move { + if let Ok(groups) = client.list_duplicates().await { + duplicate_groups.set(groups); + } + }); + } + }, + span { class: "nav-icon", "\u{2261}" } + "Duplicates" + } + button { + class: if *current_view.read() == View::Settings { "nav-item active" } else { "nav-item" }, + onclick: { + let client = client.read().clone(); + move |_| { + current_view.set(View::Settings); + let client = client.clone(); + spawn(async move { + if let Ok(cfg) = client.get_config().await { + config_data.set(Some(cfg)); + } + }); + } + }, + span { class: "nav-icon", "\u{2699}" } + "Settings" + } + button { + class: if *current_view.read() == View::Database { "nav-item active" } else { "nav-item" }, + onclick: { + let client = client.read().clone(); + move |_| { + current_view.set(View::Database); + let client = client.clone(); + spawn(async move { + if let Ok(stats) = client.database_stats().await { + db_stats.set(Some(stats)); + } + }); + } + }, + span { class: "nav-icon", "\u{2750}" } + "Database" + } + } + + div { class: "sidebar-spacer" } + + // Sidebar collapse toggle + button { + class: "sidebar-toggle", + onclick: move |_| sidebar_collapsed.toggle(), + if *sidebar_collapsed.read() { "\u{25b6}" } else { "\u{25c0}" } + } + + // User info (when logged in) + if let Some(ref user) = *current_user.read() { + div { class: "sidebar-footer user-info", + span { class: "user-name", "{user.username}" } + span { class: "role-badge role-{user.role}", "{user.role}" } + button { + class: "btn btn-ghost btn-sm", + onclick: { + let client = client.read().clone(); + move |_| { + let client = client.clone(); + spawn(async move { + let _ = client.logout().await; + current_user.set(None); + auth_required.set(true); + }); + } + }, + "Logout" + } + } + } + + // Server status indicator + div { class: "sidebar-footer", + div { class: "status-indicator", + { + let is_checking = *server_checking.read(); + let is_connected = *server_connected.read(); + let dot_class = if is_checking { + "status-dot checking" + } else if is_connected { + "status-dot connected" + } else { + "status-dot disconnected" + }; + let label = if is_checking { + "Checking..." + } else if is_connected { + "Server connected" + } else { + "Server offline" + }; + rsx! { + span { class: "{dot_class}" } + span { class: "status-text", "{label}" } + } + } + } + } + } + + // Main content + div { class: "main", + div { class: "header", + span { class: "page-title", "{view_title}" } + div { class: "header-spacer" } + } + + div { class: "content", + // Offline banner + if !*server_checking.read() && !*server_connected.read() { + div { class: "offline-banner", + span { class: "offline-icon", "\u{26a0}" } + "Cannot reach the server. Make sure pinakes-server is running." + } + } + + // Error banner + if let Some(ref err) = *load_error.read() { + div { class: "error-banner", + span { class: "error-icon", "\u{26a0}" } + "{err}" + } + } + + // Loading indicator + if *loading.read() { + div { class: "loading-overlay", + div { class: "spinner" } + "Loading..." + } + } + + {match *current_view.read() { + View::Library => rsx! { + div { class: "stats-grid", + div { class: "stat-card", + div { class: "stat-value", "{media_total_count}" } + div { class: "stat-label", "Media Files" } + } + div { class: "stat-card", + div { class: "stat-value", "{tags_list.read().len()}" } + div { class: "stat-label", "Tags" } + } + div { class: "stat-card", + div { class: "stat-value", "{collections_list.read().len()}" } + div { class: "stat-label", "Collections" } + } + } + library::Library { + media: media_list.read().clone(), + tags: tags_list.read().clone(), + collections: collections_list.read().clone(), + total_count: *media_total_count.read(), + current_page: *media_page.read(), + page_size: *media_page_size.read(), + server_url: server_url.read().clone(), + on_select: { + let client = client.read().clone(); + move |id: String| { + let client = client.clone(); + spawn(async move { + match client.get_media(&id).await { + Ok(item) => { + let mtags = client.get_media_tags(&id).await.unwrap_or_default(); + media_tags.set(mtags); + selected_media.set(Some(item)); + current_view.set(View::Detail); + } + Err(e) => show_toast(format!("Failed to load: {e}"), true), + } + }); + } + }, + on_delete: { + let client = client.read().clone(); + let refresh_media = refresh_media.clone(); + move |id: String| { + let client = client.clone(); + let refresh_media = refresh_media.clone(); + spawn(async move { + match client.delete_media(&id).await { + Ok(_) => { + show_toast("Media deleted".into(), false); + refresh_media(); + } + Err(e) => show_toast(format!("Delete failed: {e}"), true), + } + }); + } + }, + on_batch_delete: { + let client = client.read().clone(); + let refresh_media = refresh_media.clone(); + move |ids: Vec| { + let client = client.clone(); + let refresh_media = refresh_media.clone(); + spawn(async move { + match client.batch_delete(&ids).await { + Ok(resp) => { + show_toast(format!("Deleted {} items", resp.processed), false); + refresh_media(); + } + Err(e) => show_toast(format!("Batch delete failed: {e}"), true), + } + }); + } + }, + on_batch_tag: { + let client = client.read().clone(); + let refresh_media = refresh_media.clone(); + move |(ids, tag_ids): (Vec, Vec)| { + let client = client.clone(); + let refresh_media = refresh_media.clone(); + spawn(async move { + match client.batch_tag(&ids, &tag_ids).await { + Ok(resp) => { + show_toast(format!("Tagged {} items", resp.processed), false); + refresh_media(); + } + Err(e) => show_toast(format!("Batch tag failed: {e}"), true), + } + }); + } + }, + on_batch_collection: { + let client = client.read().clone(); + let refresh_media = refresh_media.clone(); + move |(ids, col_id): (Vec, String)| { + let client = client.clone(); + let refresh_media = refresh_media.clone(); + spawn(async move { + match client.batch_add_to_collection(&ids, &col_id).await { + Ok(resp) => { + show_toast(format!("Added {} items to collection", resp.processed), false); + refresh_media(); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_page_change: { + let client = client.read().clone(); + move |page: u64| { + media_page.set(page); + let client = client.clone(); + spawn(async move { + loading.set(true); + let offset = page * *media_page_size.read(); + let limit = *media_page_size.read(); + let sort = media_sort.read().clone(); + if let Ok(items) = client.list_media(offset, limit, Some(&sort)).await { + media_list.set(items); + } + loading.set(false); + }); + } + }, + on_page_size_change: { + let client = client.read().clone(); + move |size: u64| { + media_page_size.set(size); + media_page.set(0); + let client = client.clone(); + spawn(async move { + loading.set(true); + let sort = media_sort.read().clone(); + if let Ok(items) = client.list_media(0, size, Some(&sort)).await { + media_list.set(items); + } + loading.set(false); + }); + } + }, + // Phase 2.2: Sort wiring - actually refetch with sort + on_sort_change: { + let client = client.read().clone(); + move |sort: String| { + media_sort.set(sort.clone()); + media_page.set(0); + let client = client.clone(); + spawn(async move { + loading.set(true); + let limit = *media_page_size.read(); + if let Ok(items) = client.list_media(0, limit, Some(&sort)).await { + media_list.set(items); + } + loading.set(false); + }); + } + }, + on_select_all_global: { + let client = client.read().clone(); + move |callback: EventHandler>| { + let client = client.clone(); + spawn(async move { + let total = *media_total_count.read(); + let sort = media_sort.read().clone(); + match client.list_media(0, total, Some(&sort)).await { + Ok(items) => { + let all_ids: Vec = items.iter().map(|m| m.id.clone()).collect(); + callback.call(all_ids); + } + Err(e) => show_toast(format!("Failed to select all: {e}"), true), + } + }); + } + }, + on_delete_all: { + let client = client.read().clone(); + let refresh_media = refresh_media.clone(); + move |_: ()| { + let client = client.clone(); + let refresh_media = refresh_media.clone(); + spawn(async move { + match client.delete_all_media().await { + Ok(resp) => { + show_toast(format!("Deleted {} items", resp.processed), false); + refresh_media(); + } + Err(e) => show_toast(format!("Delete all failed: {e}"), true), + } + }); + } + }, + } + }, + // Phase 4.1 + 4.2: Search improvements + View::Search => rsx! { + search::Search { + results: search_results.read().clone(), + total_count: *search_total.read(), + search_page: *search_page.read(), + page_size: *search_page_size.read(), + server_url: server_url.read().clone(), + on_search: { + let client = client.read().clone(); + move |(q, sort): (String, Option)| { + let client = client.clone(); + search_page.set(0); + last_search_query.set(q.clone()); + last_search_sort.set(sort.clone()); + spawn(async move { + loading.set(true); + let offset = 0; + let limit = *search_page_size.read(); + match client.search(&q, sort.as_deref(), offset, limit).await { + Ok(resp) => { + search_total.set(resp.total_count); + search_results.set(resp.items); + } + Err(e) => show_toast(format!("Search failed: {e}"), true), + } + loading.set(false); + }); + } + }, + on_select: { + let client = client.read().clone(); + move |id: String| { + let client = client.clone(); + spawn(async move { + if let Ok(item) = client.get_media(&id).await { + let mtags = client.get_media_tags(&id).await.unwrap_or_default(); + media_tags.set(mtags); + selected_media.set(Some(item)); + current_view.set(View::Detail); + } + }); + } + }, + on_page_change: { + let client = client.read().clone(); + move |page: u64| { + search_page.set(page); + let client = client.clone(); + spawn(async move { + loading.set(true); + let offset = page * *search_page_size.read(); + let limit = *search_page_size.read(); + let q = last_search_query.read().clone(); + let sort = last_search_sort.read().clone(); + match client.search(&q, sort.as_deref(), offset, limit).await { + Ok(resp) => { + search_total.set(resp.total_count); + search_results.set(resp.items); + } + Err(e) => show_toast(format!("Search failed: {e}"), true), + } + loading.set(false); + }); + } + }, + } + }, + // Phase 3.1 + 3.2: Detail view enhancements + View::Detail => { + let media_ref = selected_media.read(); + match media_ref.as_ref() { + Some(media) => rsx! { + detail::Detail { + media: media.clone(), + media_tags: media_tags.read().clone(), + all_tags: tags_list.read().clone(), + server_url: server_url.read().clone(), + autoplay: *auto_play_media.read(), + on_back: move |_| current_view.set(View::Library), + on_open: { + let client = client.read().clone(); + move |id: String| { + let client = client.clone(); + spawn(async move { + match client.open_media(&id).await { + Ok(_) => show_toast("File opened".into(), false), + Err(e) => show_toast(format!("Open failed: {e}"), true), + } + }); + } + }, + on_update: { + let client = client.read().clone(); + move |event: MediaUpdateEvent| { + let client = client.clone(); + spawn(async move { + match client.update_media(&event).await { + Ok(updated) => { + selected_media.set(Some(updated)); + show_toast("Metadata updated".into(), false); + } + Err(e) => show_toast(format!("Update failed: {e}"), true), + } + }); + } + }, + on_tag: { + let client = client.read().clone(); + move |(media_id, tag_id): (String, String)| { + let client = client.clone(); + spawn(async move { + match client.tag_media(&media_id, &tag_id).await { + Ok(_) => { + if let Ok(mtags) = client.get_media_tags(&media_id).await { + media_tags.set(mtags); + } + show_toast("Tag added".into(), false); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_untag: { + let client = client.read().clone(); + move |(media_id, tag_id): (String, String)| { + let client = client.clone(); + spawn(async move { + match client.untag_media(&media_id, &tag_id).await { + Ok(_) => { + if let Ok(mtags) = client.get_media_tags(&media_id).await { + media_tags.set(mtags); + } + show_toast("Tag removed".into(), false); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_set_custom_field: { + let client = client.read().clone(); + move |(media_id, name, field_type, value): (String, String, String, String)| { + let client = client.clone(); + spawn(async move { + match client.set_custom_field(&media_id, &name, &field_type, &value).await { + Ok(_) => { + if let Ok(updated) = client.get_media(&media_id).await { + selected_media.set(Some(updated)); + } + show_toast("Field added".into(), false); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_delete_custom_field: { + let client = client.read().clone(); + move |(media_id, name): (String, String)| { + let client = client.clone(); + spawn(async move { + match client.delete_custom_field(&media_id, &name).await { + Ok(_) => { + if let Ok(updated) = client.get_media(&media_id).await { + selected_media.set(Some(updated)); + } + show_toast("Field removed".into(), false); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + // Phase 3.2: Delete from detail navigates back and refreshes + on_delete: { + let client = client.read().clone(); + let refresh_media = refresh_media.clone(); + move |id: String| { + let client = client.clone(); + let refresh_media = refresh_media.clone(); + spawn(async move { + match client.delete_media(&id).await { + Ok(_) => { + show_toast("Media deleted".into(), false); + selected_media.set(None); + current_view.set(View::Library); + refresh_media(); + } + Err(e) => show_toast(format!("Delete failed: {e}"), true), + } + }); + } + }, + } + }, + None => rsx! { + div { class: "empty-state", + h3 { class: "empty-title", "No media selected" } + } + }, + } + }, + View::Tags => rsx! { + tags::Tags { + tags: tags_list.read().clone(), + on_create: { + let client = client.read().clone(); + let refresh_tags = refresh_tags.clone(); + move |(name, parent_id): (String, Option)| { + let client = client.clone(); + let refresh_tags = refresh_tags.clone(); + spawn(async move { + match client.create_tag(&name, parent_id.as_deref()).await { + Ok(_) => { + show_toast("Tag created".into(), false); + refresh_tags(); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + // Phase 5.1: Tags on_delete - confirmation handled inside Tags component + on_delete: { + let client = client.read().clone(); + let refresh_tags = refresh_tags.clone(); + move |id: String| { + let client = client.clone(); + let refresh_tags = refresh_tags.clone(); + spawn(async move { + match client.delete_tag(&id).await { + Ok(_) => { + show_toast("Tag deleted".into(), false); + refresh_tags(); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + } + }, + // Phase 5.2: Collections enhancements + View::Collections => rsx! { + collections::Collections { + collections: collections_list.read().clone(), + collection_members: collection_members.read().clone(), + viewing_collection: viewing_collection.read().clone(), + all_media: media_list.read().clone(), + on_create: { + let client = client.read().clone(); + let refresh_collections = refresh_collections.clone(); + move |(name, kind, desc, filter): (String, String, Option, Option)| { + let client = client.clone(); + let refresh_collections = refresh_collections.clone(); + spawn(async move { + match client.create_collection(&name, &kind, desc.as_deref(), filter.as_deref()).await { + Ok(_) => { + show_toast("Collection created".into(), false); + refresh_collections(); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_delete: { + let client = client.read().clone(); + let refresh_collections = refresh_collections.clone(); + move |id: String| { + let client = client.clone(); + let refresh_collections = refresh_collections.clone(); + spawn(async move { + match client.delete_collection(&id).await { + Ok(_) => { + show_toast("Collection deleted".into(), false); + refresh_collections(); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_view_members: { + let client = client.read().clone(); + move |col_id: String| { + let client = client.clone(); + let col_id2 = col_id.clone(); + spawn(async move { + match client.get_collection_members(&col_id2).await { + Ok(members) => { + collection_members.set(members); + viewing_collection.set(Some(col_id2)); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_back_to_list: move |_| { + viewing_collection.set(None); + collection_members.set(Vec::new()); + }, + on_remove_member: { + let client = client.read().clone(); + move |(col_id, media_id): (String, String)| { + let client = client.clone(); + let col_id2 = col_id.clone(); + spawn(async move { + match client.remove_from_collection(&col_id, &media_id).await { + Ok(_) => { + show_toast("Removed from collection".into(), false); + if let Ok(members) = client.get_collection_members(&col_id2).await { + collection_members.set(members); + } + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + // Phase 5.2: Navigate to detail when clicking a collection member + on_select: { + let client = client.read().clone(); + move |id: String| { + let client = client.clone(); + spawn(async move { + if let Ok(item) = client.get_media(&id).await { + let mtags = client.get_media_tags(&id).await.unwrap_or_default(); + media_tags.set(mtags); + selected_media.set(Some(item)); + current_view.set(View::Detail); + } + }); + } + }, + // Phase 5.2: Add member to collection + on_add_member: { + let client = client.read().clone(); + move |(col_id, media_id): (String, String)| { + let client = client.clone(); + let col_id2 = col_id.clone(); + spawn(async move { + match client.add_to_collection(&col_id, &media_id, 0).await { + Ok(_) => { + show_toast("Added to collection".into(), false); + if let Ok(members) = client.get_collection_members(&col_id2).await { + collection_members.set(members); + } + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + } + }, + // Phase 6.1: Audit improvements + View::Audit => { + let page_size = *audit_page_size.read(); + let total = *audit_total_count.read(); + let total_pages = if page_size > 0 { total.div_ceil(page_size) } else { 1 }; + rsx! { + audit::AuditLog { + entries: audit_list.read().clone(), + audit_page: *audit_page.read(), + total_pages: total_pages, + audit_filter: audit_filter.read().clone(), + on_select: { + let client = client.read().clone(); + move |id: String| { + let client = client.clone(); + spawn(async move { + if let Ok(item) = client.get_media(&id).await { + let mtags = client.get_media_tags(&id).await.unwrap_or_default(); + media_tags.set(mtags); + selected_media.set(Some(item)); + current_view.set(View::Detail); + } + }); + } + }, + on_page_change: { + let refresh_audit = refresh_audit.clone(); + move |page: u64| { + audit_page.set(page); + refresh_audit(); + } + }, + on_filter_change: { + let refresh_audit = refresh_audit.clone(); + move |filter: String| { + audit_filter.set(filter); + audit_page.set(0); + refresh_audit(); + } + }, + } + } + }, + // Phase 6.2: Scan progress + View::Import => rsx! { + import::Import { + tags: tags_list.read().clone(), + collections: collections_list.read().clone(), + scan_progress: scan_progress.read().clone(), + on_import_file: { + let client = client.read().clone(); + let refresh_media = refresh_media.clone(); + let refresh_tags = refresh_tags.clone(); + move |(path, tag_ids, new_tags, col_id): ImportEvent| { + let client = client.clone(); + let refresh_media = refresh_media.clone(); + let refresh_tags = refresh_tags.clone(); + spawn(async move { + if tag_ids.is_empty() && new_tags.is_empty() && col_id.is_none() { + match client.import_file(&path).await { + Ok(resp) => { + if resp.was_duplicate { + show_toast("Duplicate file (already imported)".into(), false); + } else { + show_toast(format!("Imported: {}", resp.media_id), false); + } + refresh_media(); + } + Err(e) => show_toast(format!("Import failed: {e}"), true), + } + } else { + match client.import_with_options(&path, &tag_ids, &new_tags, col_id.as_deref()).await { + Ok(resp) => { + if resp.was_duplicate { + show_toast("Duplicate file (already imported)".into(), false); + } else { + show_toast(format!("Imported with tags/collection: {}", resp.media_id), false); + } + refresh_media(); + if !new_tags.is_empty() { + refresh_tags(); + } + } + Err(e) => show_toast(format!("Import failed: {e}"), true), + } + } + }); + } + }, + on_import_directory: { + let client = client.read().clone(); + let refresh_media = refresh_media.clone(); + let refresh_tags = refresh_tags.clone(); + move |(path, tag_ids, new_tags, col_id): ImportEvent| { + let client = client.clone(); + let refresh_media = refresh_media.clone(); + let refresh_tags = refresh_tags.clone(); + spawn(async move { + show_toast("Importing directory...".into(), false); + match client.import_directory(&path, &tag_ids, &new_tags, col_id.as_deref()).await { + Ok(resp) => { + show_toast( + format!("Done: {} imported, {} duplicates, {} errors", + resp.imported, resp.duplicates, resp.errors), + resp.errors > 0, + ); + refresh_media(); + if !new_tags.is_empty() { + refresh_tags(); + } + preview_files.set(Vec::new()); + preview_total_size.set(0); + } + Err(e) => show_toast(format!("Directory import failed: {e}"), true), + } + }); + } + }, + // Phase 6.2: Scan with polling for progress + on_scan: { + let client = client.read().clone(); + let refresh_media = refresh_media.clone(); + move |_| { + let client = client.clone(); + let refresh_media = refresh_media.clone(); + spawn(async move { + show_toast("Scanning...".into(), false); + match client.trigger_scan().await { + Ok(_results) => { + // Poll scan status until done + loop { + match client.scan_status().await { + Ok(status) => { + let done = !status.scanning; + scan_progress.set(Some(status.clone())); + if done { + let total = status.files_processed; + show_toast(format!("Scan complete: {total} files processed"), false); + break; + } + } + Err(_) => break, + } + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + refresh_media(); + } + Err(e) => show_toast(format!("Scan failed: {e}"), true), + } + }); + } + }, + on_import_batch: { + let client = client.read().clone(); + let refresh_media = refresh_media.clone(); + let refresh_tags = refresh_tags.clone(); + move |(paths, tag_ids, new_tags, col_id): import::BatchImportEvent| { + let client = client.clone(); + let refresh_media = refresh_media.clone(); + let refresh_tags = refresh_tags.clone(); + spawn(async move { + show_toast(format!("Importing {} files...", paths.len()), false); + match client.batch_import(&paths, &tag_ids, &new_tags, col_id.as_deref()).await { + Ok(resp) => { + show_toast( + format!("Done: {} imported, {} duplicates, {} errors", + resp.imported, resp.duplicates, resp.errors), + resp.errors > 0, + ); + refresh_media(); + if !new_tags.is_empty() { + refresh_tags(); + } + preview_files.set(Vec::new()); + preview_total_size.set(0); + } + Err(e) => show_toast(format!("Batch import failed: {e}"), true), + } + }); + } + }, + on_preview_directory: { + let client = client.read().clone(); + move |(path, recursive): (String, bool)| { + let client = client.clone(); + spawn(async move { + match client.preview_directory(&path, recursive).await { + Ok(resp) => { + preview_total_size.set(resp.total_size); + preview_files.set(resp.files); + } + Err(e) => { + show_toast(format!("Preview failed: {e}"), true); + preview_files.set(Vec::new()); + preview_total_size.set(0); + } + } + }); + } + }, + preview_files: preview_files.read().clone(), + preview_total_size: *preview_total_size.read(), + } + }, + View::Database => { + let refresh_db_stats = { + let client = client.read().clone(); + move || { + let client = client.clone(); + spawn(async move { + match client.database_stats().await { + Ok(stats) => db_stats.set(Some(stats)), + Err(e) => show_toast(format!("Failed to load stats: {e}"), true), + } + }); + } + }; + rsx! { + database::Database { + stats: db_stats.read().clone(), + on_refresh: { + let refresh_db_stats = refresh_db_stats.clone(); + move |_| refresh_db_stats() + }, + on_vacuum: { + let client = client.read().clone(); + let refresh_db_stats = refresh_db_stats.clone(); + move |_| { + let client = client.clone(); + let refresh_db_stats = refresh_db_stats.clone(); + spawn(async move { + show_toast("Vacuuming database...".into(), false); + match client.vacuum_database().await { + Ok(()) => { + show_toast("Vacuum complete".into(), false); + refresh_db_stats(); + } + Err(e) => show_toast(format!("Vacuum failed: {e}"), true), + } + }); + } + }, + on_clear: { + let client = client.read().clone(); + let refresh_media = refresh_media.clone(); + let refresh_tags = refresh_tags.clone(); + let refresh_collections = refresh_collections.clone(); + let refresh_db_stats = refresh_db_stats.clone(); + move |_| { + let client = client.clone(); + let refresh_media = refresh_media.clone(); + let refresh_tags = refresh_tags.clone(); + let refresh_collections = refresh_collections.clone(); + let refresh_db_stats = refresh_db_stats.clone(); + spawn(async move { + match client.clear_database().await { + Ok(()) => { + show_toast("All data cleared".into(), false); + refresh_media(); + refresh_tags(); + refresh_collections(); + refresh_db_stats(); + } + Err(e) => show_toast(format!("Clear failed: {e}"), true), + } + }); + } + }, + on_backup: { + move |_path: String| { + show_toast("Backup not yet implemented on server".into(), false); + } + }, + } + } + }, + View::Duplicates => { + rsx! { + duplicates::Duplicates { + groups: duplicate_groups.read().clone(), + server_url: server_url.read().clone(), + on_delete: { + let client = client.read().clone(); + move |media_id: String| { + let client = client.clone(); + spawn(async move { + match client.delete_media(&media_id).await { + Ok(_) => { + show_toast("Deleted duplicate".into(), false); + // Refresh duplicates list + if let Ok(groups) = client.list_duplicates().await { + duplicate_groups.set(groups); + } + } + Err(e) => show_toast(format!("Delete failed: {e}"), true), + } + }); + } + }, + on_refresh: { + let client = client.read().clone(); + move |_| { + let client = client.clone(); + spawn(async move { + match client.list_duplicates().await { + Ok(groups) => duplicate_groups.set(groups), + Err(e) => show_toast(format!("Failed to load duplicates: {e}"), true), + } + }); + } + }, + } + } + }, + View::Settings => { + let cfg_ref = config_data.read(); + match cfg_ref.as_ref() { + Some(cfg) => rsx! { + settings::Settings { + config: cfg.clone(), + on_add_root: { + let client = client.read().clone(); + move |path: String| { + let client = client.clone(); + spawn(async move { + match client.add_root(&path).await { + Ok(new_cfg) => { + config_data.set(Some(new_cfg)); + show_toast("Root added".into(), false); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_remove_root: { + let client = client.read().clone(); + move |path: String| { + let client = client.clone(); + spawn(async move { + match client.remove_root(&path).await { + Ok(new_cfg) => { + config_data.set(Some(new_cfg)); + show_toast("Root removed".into(), false); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_toggle_watch: { + let client = client.read().clone(); + move |enabled: bool| { + let client = client.clone(); + spawn(async move { + match client.update_scanning(Some(enabled), None, None).await { + Ok(new_cfg) => { + config_data.set(Some(new_cfg)); + let state = if enabled { "enabled" } else { "disabled" }; + show_toast(format!("Watching {state}"), false); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_update_poll_interval: { + let client = client.read().clone(); + move |secs: u64| { + let client = client.clone(); + spawn(async move { + match client.update_scanning(None, Some(secs), None).await { + Ok(new_cfg) => { + config_data.set(Some(new_cfg)); + show_toast(format!("Poll interval set to {secs}s"), false); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_update_ignore_patterns: { + let client = client.read().clone(); + move |patterns: Vec| { + let client = client.clone(); + spawn(async move { + match client.update_scanning(None, None, Some(patterns)).await { + Ok(new_cfg) => { + config_data.set(Some(new_cfg)); + show_toast("Ignore patterns updated".into(), false); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + on_update_ui_config: { + let client = client.read().clone(); + move |updates: serde_json::Value| { + let client = client.clone(); + spawn(async move { + match client.update_ui_config(updates).await { + Ok(ui_cfg) => { + auto_play_media.set(ui_cfg.auto_play_media); + sidebar_collapsed.set(ui_cfg.sidebar_collapsed); + // Reload full config + if let Ok(cfg) = client.get_config().await { + config_data.set(Some(cfg)); + } + show_toast("UI preferences updated".into(), false); + } + Err(e) => show_toast(format!("Failed: {e}"), true), + } + }); + } + }, + } + }, + None => rsx! { + div { class: "empty-state", + h3 { class: "empty-title", "Loading settings..." } + } + }, + } + }, + }} + } + } + + // Phase 7.1: Help overlay + if *show_help.read() { + div { class: "help-overlay", + onclick: move |_| show_help.set(false), + div { class: "help-dialog", + onclick: move |evt: MouseEvent| evt.stop_propagation(), + h3 { "Keyboard Shortcuts" } + div { class: "help-shortcuts", + div { class: "shortcut-row", + kbd { "Esc" } + span { "Go back / close overlay" } + } + div { class: "shortcut-row", + kbd { "/" } + span { "Focus search" } + } + div { class: "shortcut-row", + kbd { "Ctrl+K" } + span { "Focus search" } + } + div { class: "shortcut-row", + kbd { "?" } + span { "Toggle this help" } + } + } + button { + class: "help-close", + onclick: move |_| show_help.set(false), + "Close" + } + } + } + } + } + + // Phase 1.4: Toast queue - show up to 3 stacked from bottom + div { class: "toast-container", + { + let toasts = toast_queue.read().clone(); + let visible: Vec<_> = toasts.iter().rev().take(3).rev().cloned().collect(); + rsx! { + for (msg, is_error, id) in visible { + div { + key: "{id}", + class: if is_error { "toast error" } else { "toast success" }, + "{msg}" + } + } + } + } + } + } +} diff --git a/crates/pinakes-ui/src/client.rs b/crates/pinakes-ui/src/client.rs new file mode 100644 index 0000000..a659bf4 --- /dev/null +++ b/crates/pinakes-ui/src/client.rs @@ -0,0 +1,1066 @@ +use anyhow::Result; +use reqwest::Client; +use reqwest::header; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Payload for import events: (path, tag_ids, new_tags, collection_id) +pub type ImportEvent = (String, Vec, Vec, Option); + +/// Payload for media update events +#[derive(Debug, Clone, PartialEq)] +pub struct MediaUpdateEvent { + pub id: String, + pub title: Option, + pub artist: Option, + pub album: Option, + pub genre: Option, + pub year: Option, + pub description: Option, +} + +#[derive(Clone)] +pub struct ApiClient { + client: Client, + base_url: String, +} + +impl PartialEq for ApiClient { + fn eq(&self, other: &Self) -> bool { + self.base_url == other.base_url + } +} + +// ── Response types ── + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct MediaResponse { + pub id: String, + pub path: String, + pub file_name: String, + pub media_type: String, + pub content_hash: String, + pub file_size: u64, + pub title: Option, + pub artist: Option, + pub album: Option, + pub genre: Option, + pub year: Option, + pub duration_secs: Option, + pub description: Option, + #[serde(default)] + pub has_thumbnail: bool, + pub custom_fields: HashMap, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct CustomFieldResponse { + pub field_type: String, + pub value: String, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct ImportResponse { + pub media_id: String, + pub was_duplicate: bool, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct BatchImportResponse { + pub results: Vec, + pub total: usize, + pub imported: usize, + pub duplicates: usize, + pub errors: usize, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct BatchImportItemResult { + pub path: String, + pub media_id: Option, + pub was_duplicate: bool, + pub error: Option, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct DirectoryPreviewResponse { + pub files: Vec, + pub total_count: usize, + pub total_size: u64, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct DirectoryPreviewFile { + pub path: String, + pub file_name: String, + pub media_type: String, + pub file_size: u64, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct DuplicateGroupResponse { + pub content_hash: String, + pub items: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct TagResponse { + pub id: String, + pub name: String, + pub parent_id: Option, + pub created_at: String, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct CollectionResponse { + pub id: String, + pub name: String, + pub description: Option, + pub kind: String, + pub filter_query: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct SearchResponse { + pub items: Vec, + pub total_count: u64, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct AuditEntryResponse { + pub id: String, + pub media_id: Option, + pub action: String, + pub details: Option, + pub timestamp: String, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct ConfigResponse { + pub backend: String, + pub database_path: Option, + pub roots: Vec, + pub scanning: ScanningConfigResponse, + pub server: ServerConfigResponse, + #[serde(default)] + pub ui: UiConfigResponse, + pub config_path: Option, + pub config_writable: bool, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Default)] +pub struct UiConfigResponse { + #[serde(default = "default_theme")] + pub theme: String, + #[serde(default = "default_view")] + pub default_view: String, + #[serde(default = "default_page_size")] + pub default_page_size: usize, + #[serde(default = "default_view_mode")] + pub default_view_mode: String, + #[serde(default)] + pub auto_play_media: bool, + #[serde(default = "default_true")] + pub show_thumbnails: bool, + #[serde(default)] + pub sidebar_collapsed: bool, +} + +fn default_theme() -> String { + "dark".to_string() +} +fn default_view() -> String { + "library".to_string() +} +fn default_page_size() -> usize { + 48 +} +fn default_view_mode() -> String { + "grid".to_string() +} +fn default_true() -> bool { + true +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct LoginResponse { + pub token: String, + pub username: String, + pub role: String, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct UserInfoResponse { + pub username: String, + pub role: String, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct ScanningConfigResponse { + pub watch: bool, + pub poll_interval_secs: u64, + pub ignore_patterns: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct ServerConfigResponse { + pub host: String, + pub port: u16, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct ScanResponse { + pub files_found: usize, + pub files_processed: usize, + pub errors: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct ScanStatusResponse { + pub scanning: bool, + pub files_found: usize, + pub files_processed: usize, + pub error_count: usize, + pub errors: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct BatchOperationResponse { + pub processed: usize, + pub errors: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct LibraryStatisticsResponse { + pub total_media: u64, + pub total_size_bytes: u64, + pub avg_file_size_bytes: u64, + pub media_by_type: Vec, + pub storage_by_type: Vec, + pub newest_item: Option, + pub oldest_item: Option, + pub top_tags: Vec, + pub top_collections: Vec, + pub total_tags: u64, + pub total_collections: u64, + pub total_duplicates: u64, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct TypeCountResponse { + pub name: String, + pub count: u64, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct ScheduledTaskResponse { + pub id: String, + pub name: String, + pub schedule: String, + pub enabled: bool, + pub last_run: Option, + pub next_run: Option, + pub last_status: Option, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct DatabaseStatsResponse { + pub media_count: u64, + pub tag_count: u64, + pub collection_count: u64, + pub audit_count: u64, + pub database_size_bytes: u64, + pub backend_name: String, +} + +#[allow(dead_code)] +impl ApiClient { + pub fn new(base_url: &str, api_key: Option<&str>) -> Self { + let mut headers = header::HeaderMap::new(); + if let Some(key) = api_key + && let Ok(val) = header::HeaderValue::from_str(&format!("Bearer {key}")) + { + headers.insert(header::AUTHORIZATION, val); + } + let client = Client::builder() + .default_headers(headers) + .build() + .unwrap_or_else(|_| Client::new()); + Self { + client, + base_url: base_url.trim_end_matches('/').to_string(), + } + } + + pub fn base_url(&self) -> &str { + &self.base_url + } + + fn url(&self, path: &str) -> String { + format!("{}/api/v1{}", self.base_url, path) + } + + pub async fn health_check(&self) -> bool { + match self + .client + .get(self.url("/health")) + .timeout(std::time::Duration::from_secs(3)) + .send() + .await + { + Ok(resp) => resp.status().is_success(), + Err(_) => false, + } + } + + // ── Media ── + + pub async fn list_media( + &self, + offset: u64, + limit: u64, + sort: Option<&str>, + ) -> Result> { + let mut params = vec![("offset", offset.to_string()), ("limit", limit.to_string())]; + if let Some(s) = sort { + params.push(("sort", s.to_string())); + } + Ok(self + .client + .get(self.url("/media")) + .query(¶ms) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn get_media(&self, id: &str) -> Result { + Ok(self + .client + .get(self.url(&format!("/media/{id}"))) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn update_media(&self, event: &MediaUpdateEvent) -> Result { + let mut body = serde_json::Map::new(); + if let Some(v) = &event.title { + body.insert("title".into(), serde_json::json!(v)); + } + if let Some(v) = &event.artist { + body.insert("artist".into(), serde_json::json!(v)); + } + if let Some(v) = &event.album { + body.insert("album".into(), serde_json::json!(v)); + } + if let Some(v) = &event.genre { + body.insert("genre".into(), serde_json::json!(v)); + } + if let Some(v) = event.year { + body.insert("year".into(), serde_json::json!(v)); + } + if let Some(v) = &event.description { + body.insert("description".into(), serde_json::json!(v)); + } + let id = &event.id; + Ok(self + .client + .patch(self.url(&format!("/media/{id}"))) + .json(&serde_json::Value::Object(body)) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn delete_media(&self, id: &str) -> Result<()> { + self.client + .delete(self.url(&format!("/media/{id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn open_media(&self, id: &str) -> Result<()> { + self.client + .post(self.url(&format!("/media/{id}/open"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub fn stream_url(&self, id: &str) -> String { + self.url(&format!("/media/{id}/stream")) + } + + pub fn thumbnail_url(&self, id: &str) -> String { + self.url(&format!("/media/{id}/thumbnail")) + } + + pub async fn get_media_count(&self) -> Result { + #[derive(Deserialize)] + struct CountResp { + count: u64, + } + let resp: CountResp = self + .client + .get(self.url("/media/count")) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp.count) + } + + // ── Import ── + + pub async fn import_file(&self, path: &str) -> Result { + Ok(self + .client + .post(self.url("/media/import")) + .json(&serde_json::json!({"path": path})) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn import_with_options( + &self, + path: &str, + tag_ids: &[String], + new_tags: &[String], + collection_id: Option<&str>, + ) -> Result { + let mut body = serde_json::json!({"path": path}); + if !tag_ids.is_empty() { + body["tag_ids"] = serde_json::json!(tag_ids); + } + if !new_tags.is_empty() { + body["new_tags"] = serde_json::json!(new_tags); + } + if let Some(cid) = collection_id { + body["collection_id"] = serde_json::json!(cid); + } + Ok(self + .client + .post(self.url("/media/import/options")) + .json(&body) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn batch_import( + &self, + paths: &[String], + tag_ids: &[String], + new_tags: &[String], + collection_id: Option<&str>, + ) -> Result { + let mut body = serde_json::json!({"paths": paths}); + if !tag_ids.is_empty() { + body["tag_ids"] = serde_json::json!(tag_ids); + } + if !new_tags.is_empty() { + body["new_tags"] = serde_json::json!(new_tags); + } + if let Some(cid) = collection_id { + body["collection_id"] = serde_json::json!(cid); + } + Ok(self + .client + .post(self.url("/media/import/batch")) + .json(&body) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn import_directory( + &self, + path: &str, + tag_ids: &[String], + new_tags: &[String], + collection_id: Option<&str>, + ) -> Result { + let mut body = serde_json::json!({"path": path}); + if !tag_ids.is_empty() { + body["tag_ids"] = serde_json::json!(tag_ids); + } + if !new_tags.is_empty() { + body["new_tags"] = serde_json::json!(new_tags); + } + if let Some(cid) = collection_id { + body["collection_id"] = serde_json::json!(cid); + } + Ok(self + .client + .post(self.url("/media/import/directory")) + .json(&body) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn preview_directory( + &self, + path: &str, + recursive: bool, + ) -> Result { + Ok(self + .client + .post(self.url("/media/import/preview")) + .json(&serde_json::json!({"path": path, "recursive": recursive})) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + // ── Search ── + + pub async fn search( + &self, + query: &str, + sort: Option<&str>, + offset: u64, + limit: u64, + ) -> Result { + let mut params = vec![ + ("q", query.to_string()), + ("offset", offset.to_string()), + ("limit", limit.to_string()), + ]; + if let Some(s) = sort { + params.push(("sort", s.to_string())); + } + Ok(self + .client + .get(self.url("/search")) + .query(¶ms) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + // ── Tags ── + + pub async fn list_tags(&self) -> Result> { + Ok(self + .client + .get(self.url("/tags")) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn create_tag(&self, name: &str, parent_id: Option<&str>) -> Result { + let mut body = serde_json::json!({"name": name}); + if let Some(pid) = parent_id { + body["parent_id"] = serde_json::Value::String(pid.to_string()); + } + Ok(self + .client + .post(self.url("/tags")) + .json(&body) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn delete_tag(&self, id: &str) -> Result<()> { + self.client + .delete(self.url(&format!("/tags/{id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn tag_media(&self, media_id: &str, tag_id: &str) -> Result<()> { + self.client + .post(self.url(&format!("/media/{media_id}/tags"))) + .json(&serde_json::json!({"tag_id": tag_id})) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn untag_media(&self, media_id: &str, tag_id: &str) -> Result<()> { + self.client + .delete(self.url(&format!("/media/{media_id}/tags/{tag_id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn get_media_tags(&self, media_id: &str) -> Result> { + Ok(self + .client + .get(self.url(&format!("/media/{media_id}/tags"))) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + // ── Custom Fields ── + + pub async fn set_custom_field( + &self, + media_id: &str, + name: &str, + field_type: &str, + value: &str, + ) -> Result<()> { + self.client + .post(self.url(&format!("/media/{media_id}/custom-fields"))) + .json(&serde_json::json!({"name": name, "field_type": field_type, "value": value})) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn delete_custom_field(&self, media_id: &str, name: &str) -> Result<()> { + self.client + .delete(self.url(&format!("/media/{media_id}/custom-fields/{name}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + // ── Collections ── + + pub async fn list_collections(&self) -> Result> { + Ok(self + .client + .get(self.url("/collections")) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn create_collection( + &self, + name: &str, + kind: &str, + description: Option<&str>, + filter_query: Option<&str>, + ) -> Result { + let mut body = serde_json::json!({"name": name, "kind": kind}); + if let Some(desc) = description { + body["description"] = serde_json::Value::String(desc.to_string()); + } + if let Some(fq) = filter_query { + body["filter_query"] = serde_json::Value::String(fq.to_string()); + } + Ok(self + .client + .post(self.url("/collections")) + .json(&body) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn delete_collection(&self, id: &str) -> Result<()> { + self.client + .delete(self.url(&format!("/collections/{id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn get_collection_members(&self, id: &str) -> Result> { + Ok(self + .client + .get(self.url(&format!("/collections/{id}/members"))) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn add_to_collection( + &self, + collection_id: &str, + media_id: &str, + position: i32, + ) -> Result<()> { + self.client + .post(self.url(&format!("/collections/{collection_id}/members"))) + .json(&serde_json::json!({"media_id": media_id, "position": position})) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn remove_from_collection(&self, collection_id: &str, media_id: &str) -> Result<()> { + self.client + .delete(self.url(&format!("/collections/{collection_id}/members/{media_id}"))) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + // ── Batch Operations ── + + pub async fn batch_tag( + &self, + media_ids: &[String], + tag_ids: &[String], + ) -> Result { + Ok(self + .client + .post(self.url("/media/batch/tag")) + .json(&serde_json::json!({"media_ids": media_ids, "tag_ids": tag_ids})) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn batch_delete(&self, media_ids: &[String]) -> Result { + Ok(self + .client + .post(self.url("/media/batch/delete")) + .json(&serde_json::json!({"media_ids": media_ids})) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn delete_all_media(&self) -> Result { + Ok(self + .client + .delete(self.url("/media/all")) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn batch_add_to_collection( + &self, + media_ids: &[String], + collection_id: &str, + ) -> Result { + Ok(self + .client + .post(self.url("/media/batch/collection")) + .json(&serde_json::json!({"media_ids": media_ids, "collection_id": collection_id})) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + // ── Audit ── + + pub async fn list_audit(&self, offset: u64, limit: u64) -> Result> { + Ok(self + .client + .get(self.url("/audit")) + .query(&[("offset", offset.to_string()), ("limit", limit.to_string())]) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + // ── Scan ── + + pub async fn trigger_scan(&self) -> Result> { + Ok(self + .client + .post(self.url("/scan")) + .json(&serde_json::json!({"path": serde_json::Value::Null})) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn scan_status(&self) -> Result { + Ok(self + .client + .get(self.url("/scan/status")) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + // ── Config ── + + pub async fn get_config(&self) -> Result { + Ok(self + .client + .get(self.url("/config")) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn update_scanning( + &self, + watch: Option, + poll_interval: Option, + ignore_patterns: Option>, + ) -> Result { + let mut body = serde_json::Map::new(); + if let Some(w) = watch { + body.insert("watch".into(), serde_json::Value::Bool(w)); + } + if let Some(p) = poll_interval { + body.insert("poll_interval_secs".into(), serde_json::json!(p)); + } + if let Some(pat) = ignore_patterns { + body.insert("ignore_patterns".into(), serde_json::json!(pat)); + } + Ok(self + .client + .put(self.url("/config/scanning")) + .json(&body) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn add_root(&self, path: &str) -> Result { + Ok(self + .client + .post(self.url("/config/roots")) + .json(&serde_json::json!({"path": path})) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn remove_root(&self, path: &str) -> Result { + Ok(self + .client + .delete(self.url("/config/roots")) + .json(&serde_json::json!({"path": path})) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + // ── Database Management ── + + pub async fn database_stats(&self) -> Result { + Ok(self + .client + .get(self.url("/database/stats")) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn vacuum_database(&self) -> Result<()> { + self.client + .post(self.url("/database/vacuum")) + .json(&serde_json::json!({})) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn clear_database(&self) -> Result<()> { + self.client + .post(self.url("/database/clear")) + .json(&serde_json::json!({})) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + // ── Duplicates ── + + pub async fn list_duplicates(&self) -> Result> { + Ok(self + .client + .get(self.url("/duplicates")) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + // ── UI Config ── + + pub async fn get_ui_config(&self) -> Result { + Ok(self + .client + .get(self.url("/config/ui")) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn update_ui_config(&self, updates: serde_json::Value) -> Result { + Ok(self + .client + .put(self.url("/config/ui")) + .json(&updates) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + // ── Auth ── + + pub async fn login(&self, username: &str, password: &str) -> Result { + Ok(self + .client + .post(self.url("/auth/login")) + .json(&serde_json::json!({"username": username, "password": password})) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn logout(&self) -> Result<()> { + self.client + .post(self.url("/auth/logout")) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn get_current_user(&self) -> Result { + Ok(self + .client + .get(self.url("/auth/me")) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn library_statistics(&self) -> Result { + Ok(self + .client + .get(self.url("/statistics")) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn list_scheduled_tasks(&self) -> Result> { + Ok(self + .client + .get(self.url("/tasks/scheduled")) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn toggle_scheduled_task(&self, id: &str) -> Result { + Ok(self + .client + .post(self.url(&format!("/tasks/scheduled/{}/toggle", id))) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn run_scheduled_task_now(&self, id: &str) -> Result { + Ok(self + .client + .post(self.url(&format!("/tasks/scheduled/{}/run-now", id))) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub fn set_token(&mut self, token: &str) { + let mut headers = header::HeaderMap::new(); + if let Ok(val) = header::HeaderValue::from_str(&format!("Bearer {token}")) { + headers.insert(header::AUTHORIZATION, val); + } + self.client = Client::builder() + .default_headers(headers) + .build() + .unwrap_or_else(|_| Client::new()); + } +} diff --git a/crates/pinakes-ui/src/components/audit.rs b/crates/pinakes-ui/src/components/audit.rs new file mode 100644 index 0000000..df1fab4 --- /dev/null +++ b/crates/pinakes-ui/src/components/audit.rs @@ -0,0 +1,128 @@ +use dioxus::prelude::*; + +use super::pagination::Pagination as PaginationControls; +use super::utils::format_timestamp; +use crate::client::AuditEntryResponse; + +const ACTION_OPTIONS: &[&str] = &[ + "All", + "imported", + "deleted", + "tagged", + "untagged", + "updated", + "added_to_collection", + "removed_from_collection", + "opened", + "scanned", +]; + +#[component] +pub fn AuditLog( + entries: Vec, + on_select: EventHandler, + audit_page: u64, + total_pages: u64, + on_page_change: EventHandler, + audit_filter: String, + on_filter_change: EventHandler, +) -> Element { + if entries.is_empty() { + return rsx! { + div { class: "empty-state", + h3 { class: "empty-title", "No audit entries" } + p { class: "empty-subtitle", "Activity will appear here as you use the application." } + } + }; + } + + rsx! { + div { class: "audit-controls", + select { + class: "filter-select", + value: "{audit_filter}", + onchange: move |evt: Event| { + on_filter_change.call(evt.value().to_string()); + }, + for option in ACTION_OPTIONS.iter() { + option { + key: "{option}", + value: "{option}", + selected: audit_filter == *option, + "{option}" + } + } + } + } + + table { class: "data-table", + thead { + tr { + th { "Action" } + th { "Media ID" } + th { "Details" } + th { "Timestamp" } + } + } + tbody { + for entry in entries.iter() { + { + let media_id = entry.media_id.clone().unwrap_or_default(); + let truncated_id = if media_id.len() > 8 { + format!("{}...", &media_id[..8]) + } else { + media_id.clone() + }; + let details = entry.details.clone().unwrap_or_default(); + let action_class = action_badge_class(&entry.action); + let timestamp = format_timestamp(&entry.timestamp); + let click_id = media_id.clone(); + let has_media_id = !media_id.is_empty(); + rsx! { + tr { key: "{entry.id}", + td { + span { class: "type-badge {action_class}", "{entry.action}" } + } + td { + if has_media_id { + span { + class: "mono clickable", + onclick: move |_| { + on_select.call(click_id.clone()); + }, + "{truncated_id}" + } + } else { + span { class: "mono", "{truncated_id}" } + } + } + td { "{details}" } + td { "{timestamp}" } + } + } + } + } + } + } + + PaginationControls { + current_page: audit_page, + total_pages: total_pages, + on_page_change: on_page_change, + } + } +} + +fn action_badge_class(action: &str) -> &'static str { + match action { + "imported" => "type-image", + "deleted" => "action-danger", + "tagged" | "untagged" => "tag-badge", + "updated" => "action-updated", + "added_to_collection" => "action-collection", + "removed_from_collection" => "action-collection-remove", + "opened" => "action-opened", + "scanned" => "action-scanned", + _ => "type-other", + } +} diff --git a/crates/pinakes-ui/src/components/breadcrumb.rs b/crates/pinakes-ui/src/components/breadcrumb.rs new file mode 100644 index 0000000..5e60ef2 --- /dev/null +++ b/crates/pinakes-ui/src/components/breadcrumb.rs @@ -0,0 +1,42 @@ +use dioxus::prelude::*; + +#[derive(Debug, Clone, PartialEq)] +pub struct BreadcrumbItem { + pub label: String, + pub view: Option, +} + +#[component] +pub fn Breadcrumb( + items: Vec, + on_navigate: EventHandler>, +) -> Element { + rsx! { + nav { class: "breadcrumb", + for (i, item) in items.iter().enumerate() { + if i > 0 { + span { class: "breadcrumb-sep", " > " } + } + if i < items.len() - 1 { + { + let view = item.view.clone(); + let label = item.label.clone(); + rsx! { + a { + class: "breadcrumb-link", + href: "#", + onclick: move |e: Event| { + e.prevent_default(); + on_navigate.call(view.clone()); + }, + "{label}" + } + } + } + } else { + span { class: "breadcrumb-current", "{item.label}" } + } + } + } + } +} diff --git a/crates/pinakes-ui/src/components/collections.rs b/crates/pinakes-ui/src/components/collections.rs new file mode 100644 index 0000000..bbe5a68 --- /dev/null +++ b/crates/pinakes-ui/src/components/collections.rs @@ -0,0 +1,334 @@ +use dioxus::prelude::*; + +use super::utils::{format_size, type_badge_class}; +use crate::client::{CollectionResponse, MediaResponse}; + +#[component] +pub fn Collections( + collections: Vec, + collection_members: Vec, + viewing_collection: Option, + on_create: EventHandler<(String, String, Option, Option)>, + on_delete: EventHandler, + on_view_members: EventHandler, + on_back_to_list: EventHandler<()>, + on_remove_member: EventHandler<(String, String)>, + on_select: EventHandler, + on_add_member: EventHandler<(String, String)>, + all_media: Vec, +) -> Element { + let mut new_name = use_signal(String::new); + let mut new_kind = use_signal(|| String::from("manual")); + let mut new_description = use_signal(String::new); + let mut new_filter_query = use_signal(String::new); + let mut confirm_delete: Signal> = use_signal(|| None); + let mut show_add_modal = use_signal(|| false); + + // Detail view: viewing a specific collection's members + if let Some(ref col_id) = viewing_collection { + let col_name = collections + .iter() + .find(|c| &c.id == col_id) + .map(|c| c.name.clone()) + .unwrap_or_else(|| col_id.clone()); + + let back_click = move |_| on_back_to_list.call(()); + + // Collect IDs of current members to filter available media + let member_ids: Vec = collection_members.iter().map(|m| m.id.clone()).collect(); + let available_media: Vec<&MediaResponse> = all_media + .iter() + .filter(|m| !member_ids.contains(&m.id)) + .collect(); + + let modal_col_id = col_id.clone(); + + return rsx! { + button { + class: "btn btn-ghost mb-16", + onclick: back_click, + "\u{2190} Back to Collections" + } + + h3 { class: "mb-16", "{col_name}" } + + div { class: "form-row mb-16", + button { + class: "btn btn-primary", + onclick: move |_| show_add_modal.set(true), + "Add Media" + } + } + + if collection_members.is_empty() { + div { class: "empty-state", + p { class: "empty-subtitle", "This collection has no members." } + } + } else { + table { class: "data-table", + thead { + tr { + th { "Name" } + th { "Type" } + th { "Artist" } + th { "Size" } + th { "" } + } + } + tbody { + for item in collection_members.iter() { + { + let artist = item.artist.clone().unwrap_or_default(); + let size = format_size(item.file_size); + let badge_class = type_badge_class(&item.media_type); + let remove_cid = col_id.clone(); + let remove_mid = item.id.clone(); + let row_click = { + let mid = item.id.clone(); + move |_| on_select.call(mid.clone()) + }; + rsx! { + tr { + key: "{item.id}", + class: "clickable-row", + onclick: row_click, + td { "{item.file_name}" } + td { + span { class: "type-badge {badge_class}", "{item.media_type}" } + } + td { "{artist}" } + td { "{size}" } + td { + button { + class: "btn btn-danger btn-sm", + onclick: move |e: Event| { + e.stop_propagation(); + on_remove_member.call((remove_cid.clone(), remove_mid.clone())); + }, + "Remove" + } + } + } + } + } + } + } + } + } + + // Add Media modal + if *show_add_modal.read() { + div { class: "modal-overlay", + onclick: move |_| show_add_modal.set(false), + div { class: "modal", + onclick: move |e: Event| e.stop_propagation(), + div { class: "modal-header", + h3 { "Add Media to Collection" } + button { + class: "btn btn-ghost", + onclick: move |_| show_add_modal.set(false), + "\u{2715}" + } + } + div { class: "modal-body", + if available_media.is_empty() { + p { "No media available to add." } + } else { + table { class: "data-table", + thead { + tr { + th { "Name" } + th { "Type" } + th { "Artist" } + } + } + tbody { + for media in available_media.iter() { + { + let artist = media.artist.clone().unwrap_or_default(); + let badge_class = type_badge_class(&media.media_type); + let add_click = { + let cid = modal_col_id.clone(); + let mid = media.id.clone(); + move |_| { + on_add_member.call((cid.clone(), mid.clone())); + show_add_modal.set(false); + } + }; + rsx! { + tr { + key: "{media.id}", + class: "clickable-row", + onclick: add_click, + td { "{media.file_name}" } + td { + span { class: "type-badge {badge_class}", "{media.media_type}" } + } + td { "{artist}" } + } + } + } + } + } + } + } + } + } + } + } + }; + } + + // List view: show all collections with create form + let is_virtual = *new_kind.read() == "virtual"; + + let create_click = move |_| { + let name = new_name.read().clone(); + if name.is_empty() { + return; + } + let kind = new_kind.read().clone(); + let desc = { + let d = new_description.read().clone(); + if d.is_empty() { None } else { Some(d) } + }; + let filter = { + let f = new_filter_query.read().clone(); + if f.is_empty() { None } else { Some(f) } + }; + on_create.call((name, kind, desc, filter)); + new_name.set(String::new()); + new_kind.set(String::from("manual")); + new_description.set(String::new()); + new_filter_query.set(String::new()); + }; + + rsx! { + div { class: "card", + div { class: "card-header", + h3 { class: "card-title", "Collections" } + } + + div { class: "form-row mb-16", + input { + r#type: "text", + placeholder: "Collection name...", + value: "{new_name}", + oninput: move |e| new_name.set(e.value()), + } + select { + value: "{new_kind}", + onchange: move |e| new_kind.set(e.value()), + option { value: "manual", "Manual" } + option { value: "virtual", "Virtual" } + } + input { + r#type: "text", + placeholder: "Description (optional)...", + value: "{new_description}", + oninput: move |e| new_description.set(e.value()), + } + } + + if is_virtual { + div { class: "form-row mb-16", + input { + r#type: "text", + placeholder: "Filter query for virtual collection...", + value: "{new_filter_query}", + oninput: move |e| new_filter_query.set(e.value()), + } + } + } + + div { class: "form-row mb-16", + button { + class: "btn btn-primary", + onclick: create_click, + "Create" + } + } + + if collections.is_empty() { + div { class: "empty-state", + p { class: "empty-subtitle", "No collections yet. Create one above." } + } + } else { + table { class: "data-table", + thead { + tr { + th { "Name" } + th { "Kind" } + th { "Description" } + th { "" } + th { "" } + } + } + tbody { + for col in collections.iter() { + { + let desc = col.description.clone().unwrap_or_default(); + let kind_class = if col.kind == "virtual" { "type-document" } else { "type-other" }; + let view_click = { + let id = col.id.clone(); + move |_| on_view_members.call(id.clone()) + }; + let col_id_for_delete = col.id.clone(); + let is_confirming = confirm_delete + .read() + .as_ref() + .map(|id| id == &col.id) + .unwrap_or(false); + rsx! { + tr { key: "{col.id}", + td { "{col.name}" } + td { + span { class: "type-badge {kind_class}", "{col.kind}" } + } + td { "{desc}" } + td { + button { + class: "btn btn-sm btn-secondary", + onclick: view_click, + "View" + } + } + td { + if is_confirming { + button { + class: "btn btn-danger btn-sm", + onclick: { + let id = col_id_for_delete.clone(); + move |_| { + on_delete.call(id.clone()); + confirm_delete.set(None); + } + }, + "Confirm" + } + button { + class: "btn btn-ghost btn-sm", + onclick: move |_| confirm_delete.set(None), + "Cancel" + } + } else { + button { + class: "btn btn-danger btn-sm", + onclick: { + let id = col_id_for_delete.clone(); + move |_| confirm_delete.set(Some(id.clone())) + }, + "Delete" + } + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/crates/pinakes-ui/src/components/database.rs b/crates/pinakes-ui/src/components/database.rs new file mode 100644 index 0000000..32870a7 --- /dev/null +++ b/crates/pinakes-ui/src/components/database.rs @@ -0,0 +1,193 @@ +use dioxus::prelude::*; + +use super::utils::format_size; +use crate::client::DatabaseStatsResponse; + +#[component] +pub fn Database( + stats: Option, + on_refresh: EventHandler<()>, + on_vacuum: EventHandler<()>, + on_clear: EventHandler<()>, + on_backup: EventHandler, +) -> Element { + let mut confirm_clear = use_signal(|| false); + let mut confirm_vacuum = use_signal(|| false); + let mut backup_path = use_signal(String::new); + + rsx! { + div { class: "card mb-16", + div { class: "card-header", + h3 { class: "card-title", "Database Overview" } + button { + class: "btn btn-sm btn-secondary", + onclick: move |_| on_refresh.call(()), + "\u{21bb} Refresh" + } + } + + match stats.as_ref() { + Some(s) => { + let size_str = format_size(s.database_size_bytes); + rsx! { + div { class: "stats-grid", + div { class: "stat-card", + div { class: "stat-value", "{s.media_count}" } + div { class: "stat-label", "Media Items" } + } + div { class: "stat-card", + div { class: "stat-value", "{s.tag_count}" } + div { class: "stat-label", "Tags" } + } + div { class: "stat-card", + div { class: "stat-value", "{s.collection_count}" } + div { class: "stat-label", "Collections" } + } + div { class: "stat-card", + div { class: "stat-value", "{s.audit_count}" } + div { class: "stat-label", "Audit Entries" } + } + div { class: "stat-card", + div { class: "stat-value", "{size_str}" } + div { class: "stat-label", "Database Size" } + } + div { class: "stat-card", + div { class: "stat-value", "{s.backend_name}" } + div { class: "stat-label", "Backend" } + } + } + } + }, + None => rsx! { + div { class: "empty-state", + p { class: "text-muted", "Loading database stats..." } + } + }, + } + } + + // Maintenance actions + div { class: "card mb-16", + div { class: "card-header", + h3 { class: "card-title", "Maintenance" } + } + + div { class: "db-actions", + // Vacuum + div { class: "db-action-row", + div { class: "db-action-info", + h4 { "Vacuum Database" } + p { class: "text-muted text-sm", + "Reclaim unused disk space and optimize the database. " + "This is safe to run at any time but may briefly lock the database." + } + } + if *confirm_vacuum.read() { + div { class: "db-action-confirm", + span { class: "text-sm", "Run vacuum?" } + button { + class: "btn btn-sm btn-primary", + onclick: move |_| { + confirm_vacuum.set(false); + on_vacuum.call(()); + }, + "Confirm" + } + button { + class: "btn btn-sm btn-ghost", + onclick: move |_| confirm_vacuum.set(false), + "Cancel" + } + } + } else { + button { + class: "btn btn-secondary", + onclick: move |_| confirm_vacuum.set(true), + "Vacuum" + } + } + } + + // Backup + div { class: "db-action-row", + div { class: "db-action-info", + h4 { "Backup Database" } + p { class: "text-muted text-sm", + "Create a copy of the database at the specified path. " + "The backup is a full snapshot of the current state." + } + } + div { class: "form-row", + input { + r#type: "text", + placeholder: "/path/to/backup.db", + value: "{backup_path}", + oninput: move |e| backup_path.set(e.value()), + style: "max-width: 300px;", + } + button { + class: "btn btn-secondary", + disabled: backup_path.read().is_empty(), + onclick: { + let mut backup_path = backup_path; + move |_| { + let path = backup_path.read().clone(); + if !path.is_empty() { + on_backup.call(path); + backup_path.set(String::new()); + } + } + }, + "Backup" + } + } + } + } + } + + // Danger zone + div { class: "card mb-16 danger-card", + div { class: "card-header", + h3 { class: "card-title", style: "color: var(--danger);", "Danger Zone" } + } + + div { class: "db-actions", + div { class: "db-action-row", + div { class: "db-action-info", + h4 { "Clear All Data" } + p { class: "text-muted text-sm", + "Permanently delete all media records, tags, collections, and audit entries. " + "This cannot be undone. Files on disk are not affected." + } + } + if *confirm_clear.read() { + div { class: "db-action-confirm", + span { class: "text-sm", style: "color: var(--danger);", + "This will delete everything. Are you sure?" + } + button { + class: "btn btn-sm btn-danger", + onclick: move |_| { + confirm_clear.set(false); + on_clear.call(()); + }, + "Yes, Delete Everything" + } + button { + class: "btn btn-sm btn-ghost", + onclick: move |_| confirm_clear.set(false), + "Cancel" + } + } + } else { + button { + class: "btn btn-danger", + onclick: move |_| confirm_clear.set(true), + "Clear All Data" + } + } + } + } + } + } +} diff --git a/crates/pinakes-ui/src/components/detail.rs b/crates/pinakes-ui/src/components/detail.rs new file mode 100644 index 0000000..35e1d56 --- /dev/null +++ b/crates/pinakes-ui/src/components/detail.rs @@ -0,0 +1,663 @@ +use dioxus::prelude::*; + +use super::image_viewer::ImageViewer; +use super::markdown_viewer::MarkdownViewer; +use super::media_player::MediaPlayer; +use super::utils::{format_duration, format_size, media_category, type_badge_class}; +use crate::client::{MediaResponse, MediaUpdateEvent, TagResponse}; + +#[component] +pub fn Detail( + media: MediaResponse, + media_tags: Vec, + all_tags: Vec, + server_url: String, + #[props(default = false)] autoplay: bool, + on_back: EventHandler<()>, + on_open: EventHandler, + on_update: EventHandler, + on_tag: EventHandler<(String, String)>, + on_untag: EventHandler<(String, String)>, + on_set_custom_field: EventHandler<(String, String, String, String)>, + on_delete_custom_field: EventHandler<(String, String)>, + on_delete: EventHandler, +) -> Element { + let mut editing = use_signal(|| false); + let mut show_image_viewer = use_signal(|| false); + let mut edit_title = use_signal(String::new); + let mut edit_artist = use_signal(String::new); + let mut edit_album = use_signal(String::new); + let mut edit_genre = use_signal(String::new); + let mut edit_year = use_signal(String::new); + let mut edit_description = use_signal(String::new); + + let mut add_tag_id = use_signal(String::new); + + let mut new_field_name = use_signal(String::new); + let mut new_field_type = use_signal(|| "text".to_string()); + let mut new_field_value = use_signal(String::new); + + let mut confirm_delete = use_signal(|| false); + + let id = media.id.clone(); + let title = media.title.clone().unwrap_or_default(); + let artist = media.artist.clone().unwrap_or_default(); + let album = media.album.clone().unwrap_or_default(); + let genre = media.genre.clone().unwrap_or_default(); + let year_str = media.year.map(|y| y.to_string()).unwrap_or_default(); + let duration_str = media.duration_secs.map(format_duration).unwrap_or_default(); + let description = media.description.clone().unwrap_or_default(); + let size = format_size(media.file_size); + let badge_class = type_badge_class(&media.media_type); + let custom_fields: Vec<(String, String, String)> = media + .custom_fields + .iter() + .map(|(k, v)| (k.clone(), v.field_type.clone(), v.value.clone())) + .collect(); + + let is_editing = editing(); + + // Separate system-extracted metadata from user-defined custom fields. + // System fields are those set by extractors (camera info, dimensions, etc.) + let system_field_names: &[&str] = &[ + "width", + "height", + "camera_make", + "camera_model", + "date_taken", + "gps_latitude", + "gps_longitude", + "iso", + "exposure_time", + "f_number", + "focal_length", + "software", + "lens_model", + "flash", + "orientation", + "track_number", + "disc_number", + "comment", + "bitrate", + "sample_rate", + "channels", + "resolution", + "video_codec", + "audio_codec", + "audio_bitrate", + ]; + let system_fields: Vec<(String, String, String)> = custom_fields + .iter() + .filter(|(k, _, _)| system_field_names.contains(&k.as_str())) + .cloned() + .collect(); + let user_fields: Vec<(String, String, String)> = custom_fields + .iter() + .filter(|(k, _, _)| !system_field_names.contains(&k.as_str())) + .cloned() + .collect(); + let has_system_fields = !system_fields.is_empty(); + let has_user_fields = !user_fields.is_empty(); + + // Media preview URLs + let stream_url = format!("{}/api/v1/media/{}/stream", server_url, media.id); + let thumbnail_url = format!("{}/api/v1/media/{}/thumbnail", server_url, media.id); + let category = media_category(&media.media_type); + let has_thumbnail = media.has_thumbnail; + + // Compute available tags (all_tags minus media_tags) + let media_tag_ids: Vec = media_tags.iter().map(|t| t.id.clone()).collect(); + let available_tags: Vec = all_tags + .iter() + .filter(|t| !media_tag_ids.contains(&t.id)) + .cloned() + .collect(); + + // Clone values needed for closures + let id_for_open = id.clone(); + let id_for_save = id.clone(); + let id_for_tag = id.clone(); + let id_for_field = id.clone(); + let id_for_delete = id.clone(); + + // Clone media field values for the edit button + let title_for_edit = media.title.clone().unwrap_or_default(); + let artist_for_edit = media.artist.clone().unwrap_or_default(); + let album_for_edit = media.album.clone().unwrap_or_default(); + let genre_for_edit = media.genre.clone().unwrap_or_default(); + let year_for_edit = media.year.map(|y| y.to_string()).unwrap_or_default(); + let description_for_edit = media.description.clone().unwrap_or_default(); + + let on_edit_click = move |_| { + edit_title.set(title_for_edit.clone()); + edit_artist.set(artist_for_edit.clone()); + edit_album.set(album_for_edit.clone()); + edit_genre.set(genre_for_edit.clone()); + edit_year.set(year_for_edit.clone()); + edit_description.set(description_for_edit.clone()); + editing.set(true); + }; + + let on_save_click = { + let id_save = id_for_save.clone(); + move |_| { + let t = edit_title(); + let ar = edit_artist(); + let al = edit_album(); + let g = edit_genre(); + let y_str = edit_year(); + let d = edit_description(); + + let title_opt = if t.is_empty() { None } else { Some(t) }; + let artist_opt = if ar.is_empty() { None } else { Some(ar) }; + let album_opt = if al.is_empty() { None } else { Some(al) }; + let genre_opt = if g.is_empty() { None } else { Some(g) }; + let year_opt = if y_str.is_empty() { + None + } else { + y_str.parse::().ok() + }; + let desc_opt = if d.is_empty() { None } else { Some(d) }; + + on_update.call(MediaUpdateEvent { + id: id_save.clone(), + title: title_opt, + artist: artist_opt, + album: album_opt, + genre: genre_opt, + year: year_opt, + description: desc_opt, + }); + editing.set(false); + } + }; + + let on_cancel_click = move |_| { + editing.set(false); + }; + + let on_tag_add_click = { + let id_tag = id_for_tag.clone(); + move |_| { + let tag_id = add_tag_id(); + if !tag_id.is_empty() { + on_tag.call((id_tag.clone(), tag_id)); + add_tag_id.set(String::new()); + } + } + }; + + let on_add_field_click = { + let id_field = id_for_field.clone(); + move |_| { + let name = new_field_name(); + let ft = new_field_type(); + let val = new_field_value(); + if !name.is_empty() && !val.is_empty() { + on_set_custom_field.call((id_field.clone(), name, ft, val)); + new_field_name.set(String::new()); + new_field_type.set("text".to_string()); + new_field_value.set(String::new()); + } + } + }; + + let on_delete_click = move |_| { + confirm_delete.set(true); + }; + + let on_confirm_delete = { + let id_del = id_for_delete.clone(); + move |_| { + on_delete.call(id_del.clone()); + confirm_delete.set(false); + } + }; + + let on_cancel_delete = move |_| { + confirm_delete.set(false); + }; + + let stream_url_for_viewer = stream_url.clone(); + let thumb_for_player = thumbnail_url.clone(); + let file_name_for_viewer = media.file_name.clone(); + + rsx! { + // Media preview + div { class: "detail-preview", + if category == "audio" { + MediaPlayer { + src: stream_url.clone(), + media_type: "audio".to_string(), + title: media.title.clone(), + thumbnail_url: if has_thumbnail { Some(thumb_for_player.clone()) } else { None }, + autoplay: autoplay, + } + } else if category == "video" { + MediaPlayer { + src: stream_url.clone(), + media_type: "video".to_string(), + title: media.title.clone(), + autoplay: autoplay, + } + } else if category == "image" { + if has_thumbnail { + img { + src: "{thumbnail_url}", + alt: "{media.file_name}", + class: "detail-preview-image clickable", + onclick: move |_| show_image_viewer.set(true), + } + } else { + img { + src: "{stream_url}", + alt: "{media.file_name}", + class: "detail-preview-image clickable", + onclick: move |_| show_image_viewer.set(true), + } + } + } else if category == "text" { + MarkdownViewer { + content_url: stream_url.clone(), + media_type: media.media_type.clone(), + } + } else if category == "document" { + div { class: "detail-no-preview", + p { class: "text-muted", "Preview not available for this document type." } + button { + class: "btn btn-primary", + onclick: { + let id_open = id.clone(); + move |_| on_open.call(id_open.clone()) + }, + "Open Externally" + } + } + } else if has_thumbnail { + img { + src: "{thumbnail_url}", + alt: "Thumbnail", + class: "detail-thumbnail", + } + } + } + + // Action bar + div { class: "detail-actions", + button { + class: "btn btn-secondary", + onclick: move |_| on_back.call(()), + "Back" + } + button { + class: "btn btn-primary", + onclick: { + let id_open = id_for_open.clone(); + move |_| on_open.call(id_open.clone()) + }, + "Open" + } + if is_editing { + button { + class: "btn btn-primary", + onclick: on_save_click, + "Save" + } + button { + class: "btn btn-ghost", + onclick: on_cancel_click, + "Cancel" + } + } else { + button { + class: "btn btn-secondary", + onclick: on_edit_click, + "Edit" + } + } + if confirm_delete() { + button { + class: "btn btn-danger", + onclick: on_confirm_delete, + "Confirm Delete" + } + button { + class: "btn btn-ghost", + onclick: on_cancel_delete, + "Cancel" + } + } else { + button { + class: "btn btn-danger", + onclick: on_delete_click, + "Delete" + } + } + } + + // Info / Edit section + if is_editing { + div { class: "detail-grid", + // Read-only file info + div { class: "detail-field", + span { class: "detail-label", "File Name" } + span { class: "detail-value", "{media.file_name}" } + } + div { class: "detail-field", + span { class: "detail-label", "Path" } + span { class: "detail-value mono", "{media.path}" } + } + div { class: "detail-field", + span { class: "detail-label", "Type" } + span { class: "detail-value", + span { class: "type-badge {badge_class}", "{media.media_type}" } + } + } + div { class: "detail-field", + span { class: "detail-label", "Size" } + span { class: "detail-value", "{size}" } + } + div { class: "detail-field", + span { class: "detail-label", "Hash" } + span { class: "detail-value mono", "{media.content_hash}" } + } + + // Editable fields — conditional by media category + div { class: "detail-field", + label { class: "detail-label", "Title" } + input { + r#type: "text", + value: "{edit_title}", + oninput: move |e: Event| edit_title.set(e.value()), + } + } + div { class: "detail-field", + label { class: "detail-label", + {match category { + "image" => "Photographer", + "document" | "text" => "Author", + _ => "Artist", + }} + } + input { + r#type: "text", + value: "{edit_artist}", + oninput: move |e: Event| edit_artist.set(e.value()), + } + } + if category == "audio" { + div { class: "detail-field", + label { class: "detail-label", "Album" } + input { + r#type: "text", + value: "{edit_album}", + oninput: move |e: Event| edit_album.set(e.value()), + } + } + } + if category == "audio" || category == "video" { + div { class: "detail-field", + label { class: "detail-label", "Genre" } + input { + r#type: "text", + value: "{edit_genre}", + oninput: move |e: Event| edit_genre.set(e.value()), + } + } + } + if category == "audio" || category == "video" || category == "document" { + div { class: "detail-field", + label { class: "detail-label", "Year" } + input { + r#type: "text", + value: "{edit_year}", + oninput: move |e: Event| edit_year.set(e.value()), + } + } + } + div { class: "detail-field full-width", + label { class: "detail-label", "Description" } + textarea { + value: "{edit_description}", + oninput: move |e: Event| edit_description.set(e.value()), + } + } + } + } else { + div { class: "detail-grid", + div { class: "detail-field", + span { class: "detail-label", "File Name" } + span { class: "detail-value", "{media.file_name}" } + } + div { class: "detail-field", + span { class: "detail-label", "Path" } + span { class: "detail-value mono", "{media.path}" } + } + div { class: "detail-field", + span { class: "detail-label", "Type" } + span { class: "detail-value", + span { class: "type-badge {badge_class}", "{media.media_type}" } + } + } + div { class: "detail-field", + span { class: "detail-label", "Size" } + span { class: "detail-value", "{size}" } + } + div { class: "detail-field", + span { class: "detail-label", "Hash" } + span { class: "detail-value mono", "{media.content_hash}" } + } + // Title: only shown when non-empty + if !title.is_empty() { + div { class: "detail-field", + span { class: "detail-label", "Title" } + span { class: "detail-value", "{title}" } + } + } + // Artist/Author/Photographer: only shown when non-empty + if !artist.is_empty() { + div { class: "detail-field", + span { class: "detail-label", + {match category { + "image" => "Photographer", + "document" | "text" => "Author", + _ => "Artist", + }} + } + span { class: "detail-value", "{artist}" } + } + } + // Album: audio only, when non-empty + if category == "audio" && !album.is_empty() { + div { class: "detail-field", + span { class: "detail-label", "Album" } + span { class: "detail-value", "{album}" } + } + } + // Genre: audio and video, when non-empty + if (category == "audio" || category == "video") && !genre.is_empty() { + div { class: "detail-field", + span { class: "detail-label", "Genre" } + span { class: "detail-value", "{genre}" } + } + } + // Year: audio, video, document, when non-empty + if (category == "audio" || category == "video" || category == "document") && !year_str.is_empty() { + div { class: "detail-field", + span { class: "detail-label", "Year" } + span { class: "detail-value", "{year_str}" } + } + } + // Duration: audio and video + if (category == "audio" || category == "video") && media.duration_secs.is_some() { + div { class: "detail-field", + span { class: "detail-label", "Duration" } + span { class: "detail-value", "{duration_str}" } + } + } + // Description: only shown when non-empty + if !description.is_empty() { + div { class: "detail-field full-width", + span { class: "detail-label", "Description" } + span { class: "detail-value", "{description}" } + } + } + div { class: "detail-field", + span { class: "detail-label", "Created" } + span { class: "detail-value", "{media.created_at}" } + } + div { class: "detail-field", + span { class: "detail-label", "Updated" } + span { class: "detail-value", "{media.updated_at}" } + } + } + } + + // Tags section + div { class: "card mb-16", + div { class: "card-header", + h4 { class: "card-title", "Tags" } + } + div { class: "tag-list mb-8", + for tag in media_tags.iter() { + { + let tag_id = tag.id.clone(); + let media_id_untag = id.clone(); + rsx! { + span { + class: "tag-badge", + key: "{tag_id}", + "{tag.name}" + span { + class: "tag-remove", + onclick: { + let tid = tag_id.clone(); + let mid = media_id_untag.clone(); + move |_| on_untag.call((mid.clone(), tid.clone())) + }, + "x" + } + } + } + } + } + } + div { class: "form-row", + select { + value: "{add_tag_id}", + onchange: move |e: Event| add_tag_id.set(e.value()), + option { value: "", "Add tag..." } + for tag in available_tags.iter() { + { + let tid = tag.id.clone(); + let tname = tag.name.clone(); + rsx! { + option { + key: "{tid}", + value: "{tid}", + "{tname}" + } + } + } + } + } + button { + class: "btn btn-sm btn-primary", + onclick: on_tag_add_click, + "Add" + } + } + } + + // Technical Metadata section (system-extracted fields) + if has_system_fields { + div { class: "card mb-16", + div { class: "card-header", + h4 { class: "card-title", "Technical Metadata" } + } + div { class: "detail-grid", + for (key, _field_type, value) in system_fields.iter() { + div { + class: "detail-field", + key: "{key}", + span { class: "detail-label", "{key}" } + span { class: "detail-value", "{value}" } + } + } + } + } + } + + // Custom Fields section (user-defined) + div { class: "card", + div { class: "card-header", + h4 { class: "card-title", "Custom Fields" } + } + if has_user_fields { + div { class: "detail-grid", + for (key, field_type, value) in user_fields.iter() { + { + let field_name = key.clone(); + let media_id_del = id.clone(); + rsx! { + div { + class: "detail-field", + key: "{field_name}", + span { class: "detail-label", "{key} ({field_type})" } + div { class: "flex-row", + span { class: "detail-value", "{value}" } + button { + class: "btn-icon", + onclick: { + let fname = field_name.clone(); + let mid = media_id_del.clone(); + move |_| on_delete_custom_field.call((mid.clone(), fname.clone())) + }, + "x" + } + } + } + } + } + } + } + } + div { class: "form-row", + input { + r#type: "text", + placeholder: "Field name", + value: "{new_field_name}", + oninput: move |e: Event| new_field_name.set(e.value()), + } + select { + value: "{new_field_type}", + onchange: move |e: Event| new_field_type.set(e.value()), + option { value: "text", "text" } + option { value: "number", "number" } + option { value: "date", "date" } + option { value: "boolean", "boolean" } + } + input { + r#type: "text", + placeholder: "Value", + value: "{new_field_value}", + oninput: move |e: Event| new_field_value.set(e.value()), + } + button { + class: "btn btn-sm btn-primary", + onclick: on_add_field_click, + "Add" + } + } + } + + // Image viewer overlay + if *show_image_viewer.read() { + ImageViewer { + src: stream_url_for_viewer.clone(), + alt: file_name_for_viewer.clone(), + on_close: move |_| show_image_viewer.set(false), + } + } + } +} diff --git a/crates/pinakes-ui/src/components/duplicates.rs b/crates/pinakes-ui/src/components/duplicates.rs new file mode 100644 index 0000000..4b97562 --- /dev/null +++ b/crates/pinakes-ui/src/components/duplicates.rs @@ -0,0 +1,170 @@ +use dioxus::prelude::*; + +use super::utils::{format_size, format_timestamp}; +use crate::client::DuplicateGroupResponse; + +#[component] +pub fn Duplicates( + groups: Vec, + server_url: String, + on_delete: EventHandler, + on_refresh: EventHandler<()>, +) -> Element { + let mut expanded_group = use_signal(|| Option::::None); + let mut confirm_delete = use_signal(|| Option::::None); + + let total_groups = groups.len(); + let total_duplicates: usize = groups.iter().map(|g| g.items.len().saturating_sub(1)).sum(); + + rsx! { + div { class: "duplicates-view", + div { class: "duplicates-header", + h3 { "Duplicates" } + div { class: "duplicates-summary", + span { class: "text-muted", + "{total_groups} group(s), {total_duplicates} duplicate(s)" + } + button { + class: "btn btn-sm btn-secondary", + onclick: move |_| on_refresh.call(()), + "Refresh" + } + } + } + + if groups.is_empty() { + div { class: "empty-state", + p { class: "text-muted", "No duplicate files found." } + } + } + + for group in groups.iter() { + { + let hash = group.content_hash.clone(); + let is_expanded = expanded_group.read().as_ref() == Some(&hash); + let hash_for_toggle = hash.clone(); + let item_count = group.items.len(); + let first_name = group.items.first() + .map(|i| i.file_name.clone()) + .unwrap_or_default(); + let total_size: u64 = group.items.iter().map(|i| i.file_size).sum(); + let short_hash = if hash.len() > 12 { + format!("{}...", &hash[..12]) + } else { + hash.clone() + }; + + rsx! { + div { + class: "duplicate-group", + key: "{hash}", + + // Group header + button { + class: "duplicate-group-header", + onclick: move |_| { + let current = expanded_group.read().clone(); + if current.as_ref() == Some(&hash_for_toggle) { + expanded_group.set(None); + } else { + expanded_group.set(Some(hash_for_toggle.clone())); + } + }, + span { class: "expand-icon", + if is_expanded { "\u{25bc}" } else { "\u{25b6}" } + } + span { class: "group-name", "{first_name}" } + span { class: "group-badge", "{item_count} files" } + span { class: "group-size text-muted", "{format_size(total_size)}" } + span { class: "group-hash mono text-muted", + "{short_hash}" + } + } + + // Expanded: show items + if is_expanded { + div { class: "duplicate-items", + for (idx, item) in group.items.iter().enumerate() { + { + let item_id = item.id.clone(); + let is_first = idx == 0; + let is_confirming = confirm_delete.read().as_ref() == Some(&item_id); + let thumb_url = format!("{}/api/v1/media/{}/thumbnail", server_url, item.id); + let has_thumb = item.has_thumbnail; + + rsx! { + div { + class: if is_first { "duplicate-item duplicate-item-keep" } else { "duplicate-item" }, + key: "{item_id}", + + // Thumbnail + div { class: "dup-thumb", + if has_thumb { + img { + src: "{thumb_url}", + alt: "{item.file_name}", + class: "dup-thumb-img", + } + } else { + div { class: "dup-thumb-placeholder", "\u{1f5bc}" } + } + } + + // Info + div { class: "dup-info", + div { class: "dup-filename", "{item.file_name}" } + div { class: "dup-path mono text-muted", "{item.path}" } + div { class: "dup-meta", + span { "{format_size(item.file_size)}" } + span { class: "text-muted", " | " } + span { "{format_timestamp(&item.created_at)}" } + } + } + + // Actions + div { class: "dup-actions", + if is_first { + span { class: "keep-badge", "Keep" } + } + + if is_confirming { + button { + class: "btn btn-sm btn-danger", + onclick: { + let id = item_id.clone(); + move |_| { + confirm_delete.set(None); + on_delete.call(id.clone()); + } + }, + "Confirm" + } + button { + class: "btn btn-sm btn-ghost", + onclick: move |_| confirm_delete.set(None), + "Cancel" + } + } else if !is_first { + button { + class: "btn btn-sm btn-danger", + onclick: { + let id = item_id.clone(); + move |_| confirm_delete.set(Some(id.clone())) + }, + "Delete" + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/crates/pinakes-ui/src/components/image_viewer.rs b/crates/pinakes-ui/src/components/image_viewer.rs new file mode 100644 index 0000000..db3fc1c --- /dev/null +++ b/crates/pinakes-ui/src/components/image_viewer.rs @@ -0,0 +1,236 @@ +use dioxus::prelude::*; + +#[derive(Debug, Clone, Copy, PartialEq)] +enum FitMode { + FitScreen, + FitWidth, + Actual, +} + +impl FitMode { + fn next(self) -> Self { + match self { + Self::FitScreen => Self::FitWidth, + Self::FitWidth => Self::Actual, + Self::Actual => Self::FitScreen, + } + } + + fn label(self) -> &'static str { + match self { + Self::FitScreen => "Fit", + Self::FitWidth => "Width", + Self::Actual => "100%", + } + } +} + +#[component] +pub fn ImageViewer( + src: String, + alt: String, + on_close: EventHandler<()>, + #[props(default)] on_prev: Option>, + #[props(default)] on_next: Option>, +) -> Element { + let mut zoom = use_signal(|| 1.0f64); + let mut offset_x = use_signal(|| 0.0f64); + let mut offset_y = use_signal(|| 0.0f64); + let mut dragging = use_signal(|| false); + let mut drag_start_x = use_signal(|| 0.0f64); + let mut drag_start_y = use_signal(|| 0.0f64); + let mut fit_mode = use_signal(|| FitMode::FitScreen); + + let z = *zoom.read(); + let ox = *offset_x.read(); + let oy = *offset_y.read(); + let is_dragging = *dragging.read(); + let zoom_pct = (z * 100.0) as u32; + let current_fit = *fit_mode.read(); + + let transform = format!("translate({ox}px, {oy}px) scale({z})"); + let cursor = if z > 1.0 { + if is_dragging { "grabbing" } else { "grab" } + } else { + "default" + }; + + // Compute image style based on fit mode + let img_style = match current_fit { + FitMode::FitScreen => format!( + "transform: {transform}; cursor: {cursor}; max-width: 100%; max-height: 100%; object-fit: contain;" + ), + FitMode::FitWidth => { + format!("transform: {transform}; cursor: {cursor}; width: 100%; object-fit: contain;") + } + FitMode::Actual => format!("transform: {transform}; cursor: {cursor};"), + }; + + let on_wheel = move |e: WheelEvent| { + e.prevent_default(); + let delta = e.delta().strip_units(); + let factor = if delta.y < 0.0 { 1.1 } else { 1.0 / 1.1 }; + let new_zoom = (*zoom.read() * factor).clamp(0.1, 20.0); + zoom.set(new_zoom); + }; + + let on_mouse_down = move |e: MouseEvent| { + if *zoom.read() > 1.0 { + dragging.set(true); + let coords = e.client_coordinates(); + drag_start_x.set(coords.x - *offset_x.read()); + drag_start_y.set(coords.y - *offset_y.read()); + } + }; + + let on_mouse_move = move |e: MouseEvent| { + if *dragging.read() { + let coords = e.client_coordinates(); + offset_x.set(coords.x - *drag_start_x.read()); + offset_y.set(coords.y - *drag_start_y.read()); + } + }; + + let on_mouse_up = move |_: MouseEvent| { + dragging.set(false); + }; + + let on_keydown = { + move |evt: KeyboardEvent| match evt.key() { + Key::Escape => on_close.call(()), + Key::Character(ref c) if c == "+" || c == "=" => { + let new_zoom = (*zoom.read() * 1.2).min(20.0); + zoom.set(new_zoom); + } + Key::Character(ref c) if c == "-" => { + let new_zoom = (*zoom.read() / 1.2).max(0.1); + zoom.set(new_zoom); + } + Key::Character(ref c) if c == "0" => { + zoom.set(1.0); + offset_x.set(0.0); + offset_y.set(0.0); + fit_mode.set(FitMode::FitScreen); + } + Key::ArrowLeft => { + if let Some(ref prev) = on_prev { + prev.call(()); + zoom.set(1.0); + offset_x.set(0.0); + offset_y.set(0.0); + } + } + Key::ArrowRight => { + if let Some(ref next) = on_next { + next.call(()); + zoom.set(1.0); + offset_x.set(0.0); + offset_y.set(0.0); + } + } + _ => {} + } + }; + + let zoom_in = move |_| { + let new_zoom = (*zoom.read() * 1.2).min(20.0); + zoom.set(new_zoom); + }; + + let zoom_out = move |_| { + let new_zoom = (*zoom.read() / 1.2).max(0.1); + zoom.set(new_zoom); + }; + + let cycle_fit = move |_| { + let next = fit_mode.read().next(); + fit_mode.set(next); + zoom.set(1.0); + offset_x.set(0.0); + offset_y.set(0.0); + }; + + let has_prev = on_prev.is_some(); + let has_next = on_next.is_some(); + + rsx! { + div { + class: "image-viewer-overlay", + tabindex: "0", + onkeydown: on_keydown, + + // Toolbar + div { class: "image-viewer-toolbar", + div { class: "image-viewer-toolbar-left", + if has_prev { + button { + class: "iv-btn", + onclick: move |_| { + if let Some(ref prev) = on_prev { + prev.call(()); + zoom.set(1.0); + offset_x.set(0.0); + offset_y.set(0.0); + } + }, + title: "Previous", + "\u{25c0}" + } + } + if has_next { + button { + class: "iv-btn", + onclick: move |_| { + if let Some(ref next) = on_next { + next.call(()); + zoom.set(1.0); + offset_x.set(0.0); + offset_y.set(0.0); + } + }, + title: "Next", + "\u{25b6}" + } + } + } + div { class: "image-viewer-toolbar-center", + button { class: "iv-btn", onclick: cycle_fit, title: "Cycle fit mode", + "{current_fit.label()}" + } + button { class: "iv-btn", onclick: zoom_out, title: "Zoom out", "\u{2212}" } + span { class: "iv-zoom-label", "{zoom_pct}%" } + button { class: "iv-btn", onclick: zoom_in, title: "Zoom in", "+" } + } + div { class: "image-viewer-toolbar-right", + button { + class: "iv-btn iv-close", + onclick: move |_| on_close.call(()), + title: "Close", + "\u{2715}" + } + } + } + + // Image canvas + div { + class: "image-viewer-canvas", + onwheel: on_wheel, + onmousedown: on_mouse_down, + onmousemove: on_mouse_move, + onmouseup: on_mouse_up, + onclick: move |e: MouseEvent| { + // Close on background click (not on image) + e.stop_propagation(); + }, + + img { + src: "{src}", + alt: "{alt}", + style: "{img_style}", + draggable: "false", + onclick: move |e: MouseEvent| e.stop_propagation(), + } + } + } + } +} diff --git a/crates/pinakes-ui/src/components/import.rs b/crates/pinakes-ui/src/components/import.rs new file mode 100644 index 0000000..0b97d74 --- /dev/null +++ b/crates/pinakes-ui/src/components/import.rs @@ -0,0 +1,717 @@ +use std::collections::HashSet; + +use dioxus::prelude::*; + +use super::utils::{format_size, type_badge_class}; +use crate::client::{ + CollectionResponse, DirectoryPreviewFile, ImportEvent, ScanStatusResponse, TagResponse, +}; + +/// Import event for batch: (paths, tag_ids, new_tags, collection_id) +pub type BatchImportEvent = (Vec, Vec, Vec, Option); + +#[component] +pub fn Import( + tags: Vec, + collections: Vec, + on_import_file: EventHandler, + on_import_directory: EventHandler, + on_import_batch: EventHandler, + on_scan: EventHandler<()>, + on_preview_directory: EventHandler<(String, bool)>, + preview_files: Vec, + preview_total_size: u64, + scan_progress: Option, +) -> Element { + let mut import_mode = use_signal(|| 0usize); + let mut file_path = use_signal(String::new); + let mut dir_path = use_signal(String::new); + let selected_tags = use_signal(Vec::::new); + let new_tags_input = use_signal(String::new); + let selected_collection = use_signal(|| Option::::None); + + // Recursive toggle for directory preview + let mut recursive = use_signal(|| true); + + // Filter state for directory preview + let mut filter_types = use_signal(|| vec![true, true, true, true, true, true]); // audio, video, image, document, text, other + let mut filter_min_size = use_signal(|| 0u64); + let mut filter_max_size = use_signal(|| 0u64); // 0 means no limit + + // File selection state + let mut selected_file_paths = use_signal(HashSet::::new); + + let current_mode = *import_mode.read(); + + rsx! { + // Tab bar + div { class: "import-tabs", + button { + class: if current_mode == 0 { "import-tab active" } else { "import-tab" }, + onclick: move |_| import_mode.set(0), + "Single File" + } + button { + class: if current_mode == 1 { "import-tab active" } else { "import-tab" }, + onclick: move |_| import_mode.set(1), + "Directory" + } + button { + class: if current_mode == 2 { "import-tab active" } else { "import-tab" }, + onclick: move |_| import_mode.set(2), + "Scan Roots" + } + } + + // Mode 0: Single File + if current_mode == 0 { + div { class: "card mb-16", + div { class: "card-header", + h3 { class: "card-title", "Import Single File" } + } + + div { class: "form-group", + label { class: "form-label", "File Path" } + div { class: "form-row", + input { + r#type: "text", + placeholder: "/path/to/file...", + value: "{file_path}", + oninput: move |e| file_path.set(e.value()), + onkeypress: { + let mut file_path = file_path; + let mut selected_tags = selected_tags; + let mut new_tags_input = new_tags_input; + let mut selected_collection = selected_collection; + move |e: KeyboardEvent| { + if e.key() == Key::Enter { + let path = file_path.read().clone(); + if !path.is_empty() { + let tag_ids = selected_tags.read().clone(); + let new_tags = parse_new_tags(&new_tags_input.read()); + let col_id = selected_collection.read().clone(); + on_import_file.call((path, tag_ids, new_tags, col_id)); + file_path.set(String::new()); + selected_tags.set(Vec::new()); + new_tags_input.set(String::new()); + selected_collection.set(None); + } + } + } + }, + } + button { + class: "btn btn-secondary", + onclick: move |_| { + let mut file_path = file_path; + spawn(async move { + if let Some(handle) = rfd::AsyncFileDialog::new().pick_file().await { + file_path.set(handle.path().to_string_lossy().to_string()); + } + }); + }, + "Browse..." + } + button { + class: "btn btn-primary", + onclick: { + let mut file_path = file_path; + let mut selected_tags = selected_tags; + let mut new_tags_input = new_tags_input; + let mut selected_collection = selected_collection; + move |_| { + let path = file_path.read().clone(); + if !path.is_empty() { + let tag_ids = selected_tags.read().clone(); + let new_tags = parse_new_tags(&new_tags_input.read()); + let col_id = selected_collection.read().clone(); + on_import_file.call((path, tag_ids, new_tags, col_id)); + file_path.set(String::new()); + selected_tags.set(Vec::new()); + new_tags_input.set(String::new()); + selected_collection.set(None); + } + } + }, + "Import" + } + } + } + } + + ImportOptions { + tags: tags.clone(), + collections: collections.clone(), + selected_tags: selected_tags, + new_tags_input: new_tags_input, + selected_collection: selected_collection, + } + } + + // Mode 1: Directory + if current_mode == 1 { + div { class: "card mb-16", + div { class: "card-header", + h3 { class: "card-title", "Import Directory" } + } + + div { class: "form-group", + label { class: "form-label", "Directory Path" } + div { class: "form-row", + input { + r#type: "text", + placeholder: "/path/to/directory...", + value: "{dir_path}", + oninput: move |e| dir_path.set(e.value()), + onkeypress: { + let dir_path = dir_path; + let recursive = recursive; + move |e: KeyboardEvent| { + if e.key() == Key::Enter { + let path = dir_path.read().clone(); + if !path.is_empty() { + on_preview_directory.call((path, *recursive.read())); + } + } + } + }, + } + button { + class: "btn btn-secondary", + onclick: move |_| { + let mut dir_path = dir_path; + let recursive = recursive; + spawn(async move { + if let Some(handle) = rfd::AsyncFileDialog::new().pick_folder().await { + let path = handle.path().to_string_lossy().to_string(); + dir_path.set(path.clone()); + on_preview_directory.call((path, *recursive.read())); + } + }); + }, + "Browse..." + } + button { + class: "btn btn-secondary", + onclick: { + let dir_path = dir_path; + let recursive = recursive; + move |_| { + let path = dir_path.read().clone(); + if !path.is_empty() { + on_preview_directory.call((path, *recursive.read())); + } + } + }, + "Preview" + } + } + } + + // Recursive toggle + div { class: "form-group", + label { class: "form-row", + input { + r#type: "checkbox", + checked: *recursive.read(), + onchange: move |_| recursive.toggle(), + } + span { style: "margin-left: 6px;", "Recursive (include subdirectories)" } + } + } + } + + // Preview results + if !preview_files.is_empty() { + { + // Read filter signals once before the loop to avoid per-item reads + let types_snapshot = filter_types.read().clone(); + let min = *filter_min_size.read(); + let max = *filter_max_size.read(); + + let filtered: Vec<&DirectoryPreviewFile> = preview_files.iter().filter(|f| { + let type_idx = match type_badge_class(&f.media_type) { + "type-audio" => 0, + "type-video" => 1, + "type-image" => 2, + "type-document" => 3, + "type-text" => 4, + _ => 5, + }; + if !types_snapshot[type_idx] { return false; } + if min > 0 && f.file_size < min { return false; } + if max > 0 && f.file_size > max { return false; } + true + }).collect(); + + let filtered_count = filtered.len(); + let total_count = preview_files.len(); + + // Read selection once for display + let selection = selected_file_paths.read().clone(); + let selected_count = selection.len(); + let all_filtered_selected = !filtered.is_empty() + && filtered.iter().all(|f| selection.contains(&f.path)); + + let filtered_paths: Vec = filtered.iter().map(|f| f.path.clone()).collect(); + + rsx! { + div { class: "card mb-16", + div { class: "card-header", + h3 { class: "card-title", "Preview" } + p { class: "text-muted text-sm", + "{filtered_count} of {total_count} files shown, {format_size(preview_total_size)} total" + } + } + + // Filter bar + div { class: "filter-bar", + div { class: "flex-row mb-8", + label { + input { + r#type: "checkbox", + checked: types_snapshot[0], + onchange: move |_| { + let mut types = filter_types.read().clone(); + types[0] = !types[0]; + filter_types.set(types); + }, + } + " Audio" + } + label { + input { + r#type: "checkbox", + checked: types_snapshot[1], + onchange: move |_| { + let mut types = filter_types.read().clone(); + types[1] = !types[1]; + filter_types.set(types); + }, + } + " Video" + } + label { + input { + r#type: "checkbox", + checked: types_snapshot[2], + onchange: move |_| { + let mut types = filter_types.read().clone(); + types[2] = !types[2]; + filter_types.set(types); + }, + } + " Image" + } + label { + input { + r#type: "checkbox", + checked: types_snapshot[3], + onchange: move |_| { + let mut types = filter_types.read().clone(); + types[3] = !types[3]; + filter_types.set(types); + }, + } + " Document" + } + label { + input { + r#type: "checkbox", + checked: types_snapshot[4], + onchange: move |_| { + let mut types = filter_types.read().clone(); + types[4] = !types[4]; + filter_types.set(types); + }, + } + " Text" + } + label { + input { + r#type: "checkbox", + checked: types_snapshot[5], + onchange: move |_| { + let mut types = filter_types.read().clone(); + types[5] = !types[5]; + filter_types.set(types); + }, + } + " Other" + } + } + div { class: "flex-row", + label { class: "form-label", "Min size (MB): " } + input { + r#type: "number", + value: "{min / (1024 * 1024)}", + oninput: move |e| { + if let Ok(mb) = e.value().parse::() { + filter_min_size.set(mb * 1024 * 1024); + } else { + filter_min_size.set(0); + } + }, + } + label { class: "form-label", "Max size (MB): " } + input { + r#type: "number", + value: "{max / (1024 * 1024)}", + oninput: move |e| { + if let Ok(mb) = e.value().parse::() { + filter_max_size.set(mb * 1024 * 1024); + } else { + filter_max_size.set(0); + } + }, + } + } + } + + // Selection toolbar + div { class: "flex-row mb-8", style: "gap: 8px; align-items: center; padding: 0 8px;", + button { + class: "btn btn-sm btn-secondary", + onclick: { + let filtered_paths = filtered_paths.clone(); + move |_| { + let mut sel = selected_file_paths.read().clone(); + for p in &filtered_paths { + sel.insert(p.clone()); + } + selected_file_paths.set(sel); + } + }, + "Select All ({filtered_count})" + } + button { + class: "btn btn-sm btn-ghost", + onclick: move |_| { + selected_file_paths.set(HashSet::new()); + }, + "Deselect All" + } + if selected_count > 0 { + span { class: "text-muted text-sm", + "{selected_count} files selected" + } + } + } + + div { style: "max-height: 400px; overflow-y: auto;", + table { class: "data-table", + thead { + tr { + th { style: "width: 32px;", + input { + r#type: "checkbox", + checked: all_filtered_selected, + onclick: { + let filtered_paths = filtered_paths.clone(); + move |_| { + if all_filtered_selected { + // Deselect all filtered + let filtered_set: HashSet = filtered_paths.iter().cloned().collect(); + let sel = selected_file_paths.read().clone(); + let new_sel: HashSet = sel.difference(&filtered_set).cloned().collect(); + selected_file_paths.set(new_sel); + } else { + // Select all filtered + let mut sel = selected_file_paths.read().clone(); + for p in &filtered_paths { + sel.insert(p.clone()); + } + selected_file_paths.set(sel); + } + } + }, + } + } + th { "File Name" } + th { "Type" } + th { "Size" } + } + } + tbody { + for file in filtered.iter() { + { + let size = format_size(file.file_size); + let badge_class = type_badge_class(&file.media_type); + let is_selected = selection.contains(&file.path); + let file_path_clone = file.path.clone(); + rsx! { + tr { + key: "{file.path}", + class: if is_selected { "row-selected" } else { "" }, + td { + input { + r#type: "checkbox", + checked: is_selected, + onclick: { + let path = file_path_clone.clone(); + move |_| { + let mut sel = selected_file_paths.read().clone(); + if sel.contains(&path) { + sel.remove(&path); + } else { + sel.insert(path.clone()); + } + selected_file_paths.set(sel); + } + }, + } + } + td { "{file.file_name}" } + td { + span { class: "type-badge {badge_class}", "{file.media_type}" } + } + td { "{size}" } + } + } + } + } + } + } + } + } + } + } + } + + ImportOptions { + tags: tags.clone(), + collections: collections.clone(), + selected_tags: selected_tags, + new_tags_input: new_tags_input, + selected_collection: selected_collection, + } + + div { class: "flex-row mb-16", style: "gap: 8px;", + // Import selected files only (batch import) + { + let sel_count = selected_file_paths.read().len(); + let has_selected = sel_count > 0; + rsx! { + button { + class: "btn btn-primary", + disabled: !has_selected, + onclick: { + let mut selected_file_paths = selected_file_paths; + let mut selected_tags = selected_tags; + let mut new_tags_input = new_tags_input; + let mut selected_collection = selected_collection; + move |_| { + let paths: Vec = selected_file_paths.read().iter().cloned().collect(); + if !paths.is_empty() { + let tag_ids = selected_tags.read().clone(); + let new_tags = parse_new_tags(&new_tags_input.read()); + let col_id = selected_collection.read().clone(); + on_import_batch.call((paths, tag_ids, new_tags, col_id)); + selected_file_paths.set(HashSet::new()); + selected_tags.set(Vec::new()); + new_tags_input.set(String::new()); + selected_collection.set(None); + } + } + }, + if has_selected { + "Import Selected ({sel_count})" + } else { + "Import Selected" + } + } + } + } + + // Import entire directory + button { + class: "btn btn-secondary", + onclick: { + let mut dir_path = dir_path; + let mut selected_tags = selected_tags; + let mut new_tags_input = new_tags_input; + let mut selected_collection = selected_collection; + let mut selected_file_paths = selected_file_paths; + move |_| { + let path = dir_path.read().clone(); + if !path.is_empty() { + let tag_ids = selected_tags.read().clone(); + let new_tags = parse_new_tags(&new_tags_input.read()); + let col_id = selected_collection.read().clone(); + on_import_directory.call((path, tag_ids, new_tags, col_id)); + dir_path.set(String::new()); + selected_tags.set(Vec::new()); + new_tags_input.set(String::new()); + selected_collection.set(None); + selected_file_paths.set(HashSet::new()); + } + } + }, + "Import Entire Directory" + } + } + } + + // Mode 2: Scan Roots + if current_mode == 2 { + div { class: "card mb-16", + div { class: "card-header", + h3 { class: "card-title", "Scan Root Directories" } + } + + div { class: "empty-state", + p { class: "empty-subtitle", + "Scan all configured root directories for media files. " + "This will discover and import any new files found in your root paths." + } + } + + div { class: "mb-16", style: "text-align: center;", + button { + class: "btn btn-primary", + onclick: move |_| on_scan.call(()), + "Scan All Roots" + } + } + + if let Some(ref progress) = scan_progress { + { + let pct = (progress.files_processed * 100).checked_div(progress.files_found).unwrap_or(0); + rsx! { + div { class: "mb-16", + div { class: "progress-bar", + div { + class: "progress-fill", + style: "width: {pct}%;", + } + } + p { class: "text-muted text-sm", + "{progress.files_processed} / {progress.files_found} files processed" + } + if progress.error_count > 0 { + p { class: "text-muted text-sm", + "{progress.error_count} errors" + } + } + if progress.scanning { + p { class: "text-muted text-sm", "Scanning..." } + } else { + p { class: "text-muted text-sm", "Scan complete" } + } + } + } + } + } + } + } + } +} + +#[component] +fn ImportOptions( + tags: Vec, + collections: Vec, + selected_tags: Signal>, + new_tags_input: Signal, + selected_collection: Signal>, +) -> Element { + let selected_tags = selected_tags; + let mut new_tags_input = new_tags_input; + let selected_collection = selected_collection; + + rsx! { + div { class: "card mb-16", + div { class: "card-header", + h4 { class: "card-title", "Import Options" } + } + + div { class: "form-group", + label { class: "form-label", "Tags" } + if tags.is_empty() { + p { class: "text-muted text-sm", "No tags available. Create tags from the Tags page." } + } else { + div { class: "tag-list", + for tag in tags.iter() { + { + let tag_id = tag.id.clone(); + let tag_name = tag.name.clone(); + let is_selected = selected_tags.read().contains(&tag_id); + let badge_class = if is_selected { + "tag-badge selected" + } else { + "tag-badge" + }; + rsx! { + span { + class: "{badge_class}", + onclick: { + let tag_id = tag_id.clone(); + let mut selected_tags = selected_tags; + move |_| { + let mut current = selected_tags.read().clone(); + if let Some(pos) = current.iter().position(|t| t == &tag_id) { + current.remove(pos); + } else { + current.push(tag_id.clone()); + } + selected_tags.set(current); + } + }, + "{tag_name}" + } + } + } + } + } + } + } + + div { class: "form-group", + label { class: "form-label", "Create New Tags" } + input { + r#type: "text", + placeholder: "tag1, tag2, tag3...", + value: "{new_tags_input}", + oninput: move |e| new_tags_input.set(e.value()), + } + p { class: "text-muted text-sm", "Comma-separated. Will be created if they don't exist." } + } + + div { class: "form-group", + label { class: "form-label", "Add to Collection" } + select { + value: "{selected_collection.read().clone().unwrap_or_default()}", + onchange: { + let mut selected_collection = selected_collection; + move |e: Event| { + let val = e.value(); + if val.is_empty() { + selected_collection.set(None); + } else { + selected_collection.set(Some(val)); + } + } + }, + option { value: "", "None" } + for col in collections.iter() { + { + let col_id = col.id.clone(); + let col_name = col.name.clone(); + rsx! { + option { value: "{col_id}", "{col_name}" } + } + } + } + } + } + } + } +} + +fn parse_new_tags(input: &str) -> Vec { + input + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() +} diff --git a/crates/pinakes-ui/src/components/library.rs b/crates/pinakes-ui/src/components/library.rs new file mode 100644 index 0000000..fa93623 --- /dev/null +++ b/crates/pinakes-ui/src/components/library.rs @@ -0,0 +1,874 @@ +use dioxus::prelude::*; + +use super::pagination::Pagination as PaginationControls; +use super::utils::{format_size, media_category, type_badge_class, type_icon}; +use crate::client::{CollectionResponse, MediaResponse, TagResponse}; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ViewMode { + Grid, + Table, +} + +/// The set of type filter categories available to the user. +const TYPE_FILTERS: &[&str] = &["all", "audio", "video", "image", "document", "text"]; + +/// Human-readable label for a type filter value. +fn filter_label(f: &str) -> &str { + match f { + "all" => "All", + "audio" => "Audio", + "video" => "Video", + "image" => "Image", + "document" => "Document", + "text" => "Text", + _ => f, + } +} + +/// Parse the current sort field string into (column, direction) so table +/// headers can show the correct arrow indicator. +fn parse_sort(sort: &str) -> (&str, &str) { + if let Some(col) = sort.strip_suffix("_asc") { + (col, "asc") + } else if let Some(col) = sort.strip_suffix("_desc") { + (col, "desc") + } else { + (sort, "asc") + } +} + +/// Return the sort arrow indicator for a table column header. Returns an empty +/// string when the column is not the active sort column. +fn sort_arrow(current_sort: &str, column: &str) -> &'static str { + let (col, dir) = parse_sort(current_sort); + if col == column { + if dir == "asc" { + " \u{25b2}" + } else { + " \u{25bc}" + } + } else { + "" + } +} + +/// Compute the next sort value when a table column header is clicked. If the +/// column is already sorted ascending, flip to descending and vice-versa. +/// Otherwise default to ascending. +fn next_sort(current_sort: &str, column: &str) -> String { + let (col, dir) = parse_sort(current_sort); + if col == column { + let new_dir = if dir == "asc" { "desc" } else { "asc" }; + format!("{column}_{new_dir}") + } else { + format!("{column}_asc") + } +} + +#[component] +pub fn Library( + media: Vec, + tags: Vec, + collections: Vec, + total_count: u64, + current_page: u64, + page_size: u64, + server_url: String, + on_select: EventHandler, + on_delete: EventHandler, + on_batch_delete: EventHandler>, + on_batch_tag: EventHandler<(Vec, Vec)>, + on_batch_collection: EventHandler<(Vec, String)>, + on_page_change: EventHandler, + on_page_size_change: EventHandler, + on_sort_change: EventHandler, + #[props(default)] on_select_all_global: Option>>>, + #[props(default)] on_delete_all: Option>, +) -> Element { + let mut selected_ids = use_signal(Vec::::new); + let mut select_all = use_signal(|| false); + let mut confirm_delete = use_signal(|| Option::::None); + let mut confirm_batch_delete = use_signal(|| false); + let mut confirm_delete_all = use_signal(|| false); + let mut show_batch_tag = use_signal(|| false); + let mut batch_tag_selection = use_signal(Vec::::new); + let mut show_batch_collection = use_signal(|| false); + let mut batch_collection_id = use_signal(String::new); + let mut view_mode = use_signal(|| ViewMode::Grid); + let mut sort_field = use_signal(|| "created_at_desc".to_string()); + let mut type_filter = use_signal(|| "all".to_string()); + // Track the last-clicked index for shift+click range selection. + let mut last_click_index = use_signal(|| Option::::None); + // True when all items across all pages have been selected. + let mut global_all_selected = use_signal(|| false); + + if media.is_empty() && total_count == 0 { + return rsx! { + div { class: "empty-state", + h3 { class: "empty-title", "No media found" } + p { class: "empty-subtitle", "Import files or scan your root directories to get started." } + } + }; + } + + // Apply client-side type filter. + let active_filter = type_filter.read().clone(); + let filtered_media: Vec = if active_filter == "all" { + media.clone() + } else { + media + .iter() + .filter(|m| media_category(&m.media_type) == active_filter.as_str()) + .cloned() + .collect() + }; + let filtered_count = filtered_media.len(); + + let all_ids: Vec = filtered_media.iter().map(|m| m.id.clone()).collect(); + // Read selection once to avoid repeated signal reads in loops + let current_selection: Vec = selected_ids.read().clone(); + let selection_count = current_selection.len(); + let has_selection = selection_count > 0; + let total_pages = total_count.div_ceil(page_size); + + let toggle_select_all = { + let all_ids = all_ids.clone(); + move |_| { + let new_val = !*select_all.read(); + select_all.set(new_val); + global_all_selected.set(false); + if new_val { + selected_ids.set(all_ids.clone()); + } else { + selected_ids.set(Vec::new()); + } + } + }; + + let is_all_selected = *select_all.read(); + let current_mode = *view_mode.read(); + let current_sort = sort_field.read().clone(); + + rsx! { + // Confirmation dialog for single delete + if confirm_delete.read().is_some() { + div { class: "modal-overlay", + onclick: move |_| confirm_delete.set(None), + div { class: "modal", + onclick: move |e: Event| e.stop_propagation(), + h3 { class: "modal-title", "Confirm Delete" } + p { class: "modal-body", "Are you sure you want to delete this media item? This cannot be undone." } + div { class: "modal-actions", + button { + class: "btn btn-ghost", + onclick: move |_| confirm_delete.set(None), + "Cancel" + } + button { + class: "btn btn-danger", + onclick: move |_| { + if let Some(id) = confirm_delete.read().clone() { + on_delete.call(id); + } + confirm_delete.set(None); + }, + "Delete" + } + } + } + } + } + + // Confirmation dialog for batch delete + if *confirm_batch_delete.read() { + div { class: "modal-overlay", + onclick: move |_| confirm_batch_delete.set(false), + div { class: "modal", + onclick: move |e: Event| e.stop_propagation(), + h3 { class: "modal-title", "Confirm Batch Delete" } + p { class: "modal-body", + "Are you sure you want to delete {selection_count} selected items? This cannot be undone." + } + div { class: "modal-actions", + button { + class: "btn btn-ghost", + onclick: move |_| confirm_batch_delete.set(false), + "Cancel" + } + button { + class: "btn btn-danger", + onclick: move |_| { + let ids = selected_ids.read().clone(); + on_batch_delete.call(ids); + selected_ids.set(Vec::new()); + select_all.set(false); + confirm_batch_delete.set(false); + }, + "Delete All" + } + } + } + } + } + + // Confirmation dialog for delete all + if *confirm_delete_all.read() { + div { class: "modal-overlay", + onclick: move |_| confirm_delete_all.set(false), + div { class: "modal", + onclick: move |e: Event| e.stop_propagation(), + h3 { class: "modal-title", "Delete All Media" } + p { class: "modal-body", + "Are you sure you want to delete ALL {total_count} items? This cannot be undone." + } + div { class: "modal-actions", + button { + class: "btn btn-ghost", + onclick: move |_| confirm_delete_all.set(false), + "Cancel" + } + button { + class: "btn btn-danger", + onclick: move |_| { + if let Some(handler) = on_delete_all { + handler.call(()); + } + selected_ids.set(Vec::new()); + select_all.set(false); + global_all_selected.set(false); + confirm_delete_all.set(false); + }, + "Delete Everything" + } + } + } + } + } + + // Batch tag dialog + if *show_batch_tag.read() { + div { class: "modal-overlay", + onclick: move |_| { + show_batch_tag.set(false); + batch_tag_selection.set(Vec::new()); + }, + div { class: "modal", + onclick: move |e: Event| e.stop_propagation(), + h3 { class: "modal-title", "Tag Selected Items" } + p { class: "modal-body text-muted text-sm", + "Select tags to apply to {selection_count} items:" + } + if tags.is_empty() { + p { class: "text-muted", "No tags available. Create tags first." } + } else { + div { class: "tag-list", style: "margin: 12px 0;", + for tag in tags.iter() { + { + let tag_id = tag.id.clone(); + let tag_name = tag.name.clone(); + let is_selected = batch_tag_selection.read().contains(&tag_id); + let badge_class = if is_selected { "tag-badge selected" } else { "tag-badge" }; + rsx! { + span { + class: "{badge_class}", + onclick: { + let tag_id = tag_id.clone(); + move |_| { + let mut current = batch_tag_selection.read().clone(); + if let Some(pos) = current.iter().position(|t| t == &tag_id) { + current.remove(pos); + } else { + current.push(tag_id.clone()); + } + batch_tag_selection.set(current); + } + }, + "{tag_name}" + } + } + } + } + } + } + div { class: "modal-actions", + button { + class: "btn btn-ghost", + onclick: move |_| { + show_batch_tag.set(false); + batch_tag_selection.set(Vec::new()); + }, + "Cancel" + } + button { + class: "btn btn-primary", + onclick: move |_| { + let ids = selected_ids.read().clone(); + let tag_ids = batch_tag_selection.read().clone(); + if !tag_ids.is_empty() { + on_batch_tag.call((ids, tag_ids)); + selected_ids.set(Vec::new()); + select_all.set(false); + } + show_batch_tag.set(false); + batch_tag_selection.set(Vec::new()); + }, + "Apply Tags" + } + } + } + } + } + + // Batch collection dialog + if *show_batch_collection.read() { + div { class: "modal-overlay", + onclick: move |_| { + show_batch_collection.set(false); + batch_collection_id.set(String::new()); + }, + div { class: "modal", + onclick: move |e: Event| e.stop_propagation(), + h3 { class: "modal-title", "Add to Collection" } + p { class: "modal-body text-muted text-sm", + "Choose a collection for {selection_count} items:" + } + if collections.is_empty() { + p { class: "text-muted", "No collections available. Create one first." } + } else { + select { + style: "width: 100%; margin: 12px 0;", + value: "{batch_collection_id}", + onchange: move |e: Event| batch_collection_id.set(e.value()), + option { value: "", "Select a collection..." } + for col in collections.iter() { + option { + key: "{col.id}", + value: "{col.id}", + "{col.name}" + } + } + } + } + div { class: "modal-actions", + button { + class: "btn btn-ghost", + onclick: move |_| { + show_batch_collection.set(false); + batch_collection_id.set(String::new()); + }, + "Cancel" + } + button { + class: "btn btn-primary", + onclick: move |_| { + let ids = selected_ids.read().clone(); + let col_id = batch_collection_id.read().clone(); + if !col_id.is_empty() { + on_batch_collection.call((ids, col_id)); + selected_ids.set(Vec::new()); + select_all.set(false); + } + show_batch_collection.set(false); + batch_collection_id.set(String::new()); + }, + "Add to Collection" + } + } + } + } + } + + // Toolbar: view toggle, sort, batch actions + div { class: "library-toolbar", + div { class: "toolbar-left", + // View mode toggle + div { class: "view-toggle", + button { + class: if current_mode == ViewMode::Grid { "view-btn active" } else { "view-btn" }, + onclick: move |_| view_mode.set(ViewMode::Grid), + title: "Grid view", + "\u{25a6}" + } + button { + class: if current_mode == ViewMode::Table { "view-btn active" } else { "view-btn" }, + onclick: move |_| view_mode.set(ViewMode::Table), + title: "Table view", + "\u{2630}" + } + } + + // Sort selector + div { class: "sort-control", + select { + value: "{sort_field}", + onchange: move |e: Event| { + let val = e.value(); + sort_field.set(val.clone()); + on_sort_change.call(val); + }, + option { value: "created_at_desc", "Newest first" } + option { value: "created_at_asc", "Oldest first" } + option { value: "file_name_asc", "Name A-Z" } + option { value: "file_name_desc", "Name Z-A" } + option { value: "file_size_desc", "Largest first" } + option { value: "file_size_asc", "Smallest first" } + option { value: "media_type_asc", "Type" } + } + } + + // Page size + div { class: "page-size-control", + span { class: "text-muted text-sm", "Show:" } + select { + value: "{page_size}", + onchange: move |e: Event| { + if let Ok(size) = e.value().parse::() { + on_page_size_change.call(size); + } + }, + option { value: "24", "24" } + option { value: "48", "48" } + option { value: "96", "96" } + option { value: "200", "200" } + } + } + } + + div { class: "toolbar-right", + // Select All / Deselect All toggle (works in both grid and table) + { + let all_ids2 = all_ids.clone(); + rsx! { + button { + class: "btn btn-sm btn-ghost", + onclick: move |_| { + if is_all_selected { + selected_ids.set(Vec::new()); + select_all.set(false); + global_all_selected.set(false); + } else { + selected_ids.set(all_ids2.clone()); + select_all.set(true); + } + }, + if is_all_selected { + "Deselect All" + } else { + "Select All" + } + } + } + } + + if has_selection { + div { class: "batch-actions", + span { "{selection_count} selected" } + button { + class: "btn btn-sm btn-secondary", + onclick: move |_| show_batch_tag.set(true), + "Tag" + } + button { + class: "btn btn-sm btn-secondary", + onclick: move |_| show_batch_collection.set(true), + "Collection" + } + button { + class: "btn btn-sm btn-danger", + onclick: move |_| confirm_batch_delete.set(true), + "Delete" + } + button { + class: "btn btn-sm btn-ghost", + onclick: move |_| { + selected_ids.set(Vec::new()); + select_all.set(false); + global_all_selected.set(false); + }, + "Clear" + } + } + } + if on_delete_all.is_some() && total_count > 0 { + button { + class: "btn btn-sm btn-danger", + onclick: move |_| confirm_delete_all.set(true), + "Delete All" + } + } + span { class: "text-muted text-sm", + "{total_count} items" + } + } + } + + // Type filter chips + div { class: "type-filter-row", + for filter in TYPE_FILTERS.iter() { + { + let f = (*filter).to_string(); + let is_active = active_filter == f; + let chip_class = if is_active { "filter-chip active" } else { "filter-chip" }; + let label = filter_label(filter); + rsx! { + button { + key: "{f}", + class: "{chip_class}", + onclick: { + let f = f.clone(); + move |_| { + type_filter.set(f.clone()); + } + }, + "{label}" + } + } + } + } + } + + // Stats summary row + div { class: "library-stats", + span { class: "text-muted text-sm", + if active_filter != "all" { + "Showing {filtered_count} of {total_count} items (filtered: {active_filter})" + } else { + "Showing {filtered_count} items" + } + } + span { class: "text-muted text-sm", + "Page {current_page + 1} of {total_pages}" + } + } + + // Select-all banner: when all items on this page are selected and there + // are more pages, offer to select everything across all pages. + if is_all_selected && total_count > page_size && !*global_all_selected.read() { + div { class: "select-all-banner", + "All {filtered_count} items on this page are selected." + if on_select_all_global.is_some() { + button { + onclick: move |_| { + if let Some(handler) = on_select_all_global { + handler.call(EventHandler::new(move |all_ids: Vec| { + selected_ids.set(all_ids); + global_all_selected.set(true); + })); + } + }, + "Select all {total_count} items" + } + } + } + } + if *global_all_selected.read() { + div { class: "select-all-banner", + "All {selection_count} items across all pages are selected." + button { + onclick: move |_| { + selected_ids.set(Vec::new()); + select_all.set(false); + global_all_selected.set(false); + }, + "Clear selection" + } + } + } + + // Content: grid or table + match current_mode { + ViewMode::Grid => rsx! { + div { class: "media-grid", + for (idx, item) in filtered_media.iter().enumerate() { + { + let id = item.id.clone(); + let badge_class = type_badge_class(&item.media_type); + let is_checked = current_selection.contains(&id); + + let card_click = { + let id = item.id.clone(); + move |_| on_select.call(id.clone()) + }; + + // Build a list of all visible IDs for shift+click range selection. + let visible_ids: Vec = filtered_media.iter().map(|m| m.id.clone()).collect(); + + let toggle_id = { + let id = id.clone(); + move |e: Event| { + e.stop_propagation(); + let shift = e.modifiers().shift(); + let mut ids = selected_ids.read().clone(); + + if shift { + // Shift+click: select range from last_click_index to current idx. + if let Some(last) = *last_click_index.read() { + let start = last.min(idx); + let end = last.max(idx); + for i in start..=end { + if let Some(range_id) = visible_ids.get(i) + && !ids.contains(range_id) + { + ids.push(range_id.clone()); + } + } + } else { + // No previous click, just toggle this one. + if !ids.contains(&id) { + ids.push(id.clone()); + } + } + } else if ids.contains(&id) { + ids.retain(|x| x != &id); + } else { + ids.push(id.clone()); + } + + last_click_index.set(Some(idx)); + selected_ids.set(ids); + } + }; + + let thumb_url = if item.has_thumbnail { + format!("{}/api/v1/media/{}/thumbnail", server_url, item.id) + } else { + String::new() + }; + let has_thumb = item.has_thumbnail; + let media_type = item.media_type.clone(); + let card_class = if is_checked { "media-card selected" } else { "media-card" }; + let title_text = item.title.clone().unwrap_or_default(); + let artist_text = item.artist.clone().unwrap_or_default(); + + rsx! { + div { + key: "{item.id}", + class: "{card_class}", + onclick: card_click, + + div { class: "card-checkbox", + input { + r#type: "checkbox", + checked: is_checked, + onclick: toggle_id, + } + } + + // Thumbnail with CSS fallback: both the icon and img + // are rendered. The img is absolutely positioned on + // top. If the image fails to load, the icon beneath + // shows through. + div { class: "card-thumbnail", + div { class: "card-type-icon {badge_class}", + "{type_icon(&media_type)}" + } + if has_thumb { + img { + class: "card-thumb-img", + src: "{thumb_url}", + alt: "{item.file_name}", + loading: "lazy", + } + } + } + + div { class: "card-info", + div { class: "card-name", title: "{item.file_name}", + "{item.file_name}" + } + if !title_text.is_empty() { + div { class: "card-title text-muted text-xs", + "{title_text}" + } + } + if !artist_text.is_empty() { + div { class: "card-artist text-muted text-xs", + "{artist_text}" + } + } + div { class: "card-meta", + span { class: "type-badge {badge_class}", "{item.media_type}" } + span { class: "card-size", "{format_size(item.file_size)}" } + } + } + } + } + } + } + } + }, + ViewMode::Table => rsx! { + table { class: "data-table", + thead { + tr { + th { + input { + r#type: "checkbox", + checked: is_all_selected, + onclick: toggle_select_all, + } + } + th { "" } + th { + class: "sortable-header", + onclick: { + let cs = current_sort.clone(); + move |_| { + let val = next_sort(&cs, "file_name"); + sort_field.set(val.clone()); + on_sort_change.call(val); + } + }, + "Name{sort_arrow(¤t_sort, \"file_name\")}" + } + th { + class: "sortable-header", + onclick: { + let cs = current_sort.clone(); + move |_| { + let val = next_sort(&cs, "media_type"); + sort_field.set(val.clone()); + on_sort_change.call(val); + } + }, + "Type{sort_arrow(¤t_sort, \"media_type\")}" + } + th { "Artist" } + th { + class: "sortable-header", + onclick: { + let cs = current_sort.clone(); + move |_| { + let val = next_sort(&cs, "file_size"); + sort_field.set(val.clone()); + on_sort_change.call(val); + } + }, + "Size{sort_arrow(¤t_sort, \"file_size\")}" + } + th { "" } + } + } + tbody { + for (idx, item) in filtered_media.iter().enumerate() { + { + let id = item.id.clone(); + let artist = item.artist.clone().unwrap_or_default(); + let size = format_size(item.file_size); + let badge_class = type_badge_class(&item.media_type); + let is_checked = current_selection.contains(&id); + + let visible_ids: Vec = filtered_media.iter().map(|m| m.id.clone()).collect(); + + let toggle_id = { + let id = id.clone(); + move |e: Event| { + e.stop_propagation(); + let shift = e.modifiers().shift(); + let mut ids = selected_ids.read().clone(); + + if shift { + if let Some(last) = *last_click_index.read() { + let start = last.min(idx); + let end = last.max(idx); + for i in start..=end { + if let Some(range_id) = visible_ids.get(i) + && !ids.contains(range_id) + { + ids.push(range_id.clone()); + } + } + } else { + if !ids.contains(&id) { + ids.push(id.clone()); + } + } + } else if ids.contains(&id) { + ids.retain(|x| x != &id); + } else { + ids.push(id.clone()); + } + + last_click_index.set(Some(idx)); + selected_ids.set(ids); + } + }; + + let row_click = { + let id = item.id.clone(); + move |_| on_select.call(id.clone()) + }; + + let delete_click = { + let id = item.id.clone(); + move |e: Event| { + e.stop_propagation(); + confirm_delete.set(Some(id.clone())); + } + }; + + let thumb_url = if item.has_thumbnail { + format!("{}/api/v1/media/{}/thumbnail", server_url, item.id) + } else { + String::new() + }; + let has_thumb = item.has_thumbnail; + let media_type_str = item.media_type.clone(); + + rsx! { + tr { + key: "{item.id}", + onclick: row_click, + td { + input { + r#type: "checkbox", + checked: is_checked, + onclick: toggle_id, + } + } + td { class: "table-thumb-cell", + // Thumbnail with CSS fallback: icon always + // rendered, img overlays when available. + span { class: "table-type-icon {badge_class}", + "{type_icon(&media_type_str)}" + } + if has_thumb { + img { + class: "table-thumb table-thumb-overlay", + src: "{thumb_url}", + alt: "", + loading: "lazy", + } + } + } + td { "{item.file_name}" } + td { + span { class: "type-badge {badge_class}", "{item.media_type}" } + } + td { "{artist}" } + td { "{size}" } + td { + button { + class: "btn btn-danger btn-sm", + onclick: delete_click, + "Delete" + } + } + } + } + } + } + } + } + }, + } + + // Pagination controls + PaginationControls { + current_page, + total_pages, + on_page_change: move |page: u64| on_page_change.call(page), + } + } +} diff --git a/crates/pinakes-ui/src/components/loading.rs b/crates/pinakes-ui/src/components/loading.rs new file mode 100644 index 0000000..b92c723 --- /dev/null +++ b/crates/pinakes-ui/src/components/loading.rs @@ -0,0 +1,59 @@ +use dioxus::prelude::*; + +#[component] +pub fn SkeletonCard() -> Element { + rsx! { + div { class: "skeleton-card", + div { class: "skeleton-thumb skeleton-pulse" } + div { class: "skeleton-text skeleton-pulse" } + div { class: "skeleton-text skeleton-text-short skeleton-pulse" } + } + } +} + +#[component] +pub fn SkeletonRow() -> Element { + rsx! { + div { class: "skeleton-row", + div { class: "skeleton-cell skeleton-cell-icon skeleton-pulse" } + div { class: "skeleton-cell skeleton-cell-wide skeleton-pulse" } + div { class: "skeleton-cell skeleton-pulse" } + div { class: "skeleton-cell skeleton-pulse" } + } + } +} + +#[component] +pub fn LoadingOverlay(message: Option) -> Element { + let msg = message.unwrap_or_else(|| "Loading...".to_string()); + rsx! { + div { class: "loading-overlay", + div { class: "loading-spinner" } + span { class: "loading-message", "{msg}" } + } + } +} + +#[component] +pub fn SkeletonGrid(count: Option) -> Element { + let n = count.unwrap_or(12); + rsx! { + div { class: "media-grid", + for i in 0..n { + SkeletonCard { key: "skel-{i}" } + } + } + } +} + +#[component] +pub fn SkeletonList(count: Option) -> Element { + let n = count.unwrap_or(10); + rsx! { + div { class: "media-list", + for i in 0..n { + SkeletonRow { key: "skel-row-{i}" } + } + } + } +} diff --git a/crates/pinakes-ui/src/components/login.rs b/crates/pinakes-ui/src/components/login.rs new file mode 100644 index 0000000..e9f028d --- /dev/null +++ b/crates/pinakes-ui/src/components/login.rs @@ -0,0 +1,75 @@ +use dioxus::prelude::*; + +#[component] +pub fn Login( + on_login: EventHandler<(String, String)>, + #[props(default)] error: Option, + #[props(default = false)] loading: bool, +) -> Element { + let mut username = use_signal(String::new); + let mut password = use_signal(String::new); + + let on_submit = { + move |_| { + let u = username.read().clone(); + let p = password.read().clone(); + if !u.is_empty() && !p.is_empty() { + on_login.call((u, p)); + } + } + }; + + let on_key = move |e: KeyboardEvent| { + if e.key() == Key::Enter { + let u = username.read().clone(); + let p = password.read().clone(); + if !u.is_empty() && !p.is_empty() { + on_login.call((u, p)); + } + } + }; + + rsx! { + div { class: "login-container", + div { class: "login-card", + h2 { class: "login-title", "Pinakes" } + p { class: "login-subtitle", "Sign in to continue" } + + if let Some(ref err) = error { + div { class: "login-error", "{err}" } + } + + div { class: "login-form", + div { class: "form-group", + label { class: "form-label", "Username" } + input { + r#type: "text", + placeholder: "Enter username", + value: "{username}", + disabled: loading, + oninput: move |e: Event| username.set(e.value()), + onkeypress: on_key, + } + } + div { class: "form-group", + label { class: "form-label", "Password" } + input { + r#type: "password", + placeholder: "Enter password", + value: "{password}", + disabled: loading, + oninput: move |e: Event| password.set(e.value()), + onkeypress: on_key, + } + } + button { + class: "btn btn-primary login-btn", + disabled: loading, + onclick: on_submit, + if loading { "Signing in..." } else { "Sign In" } + } + } + } + } + } +} diff --git a/crates/pinakes-ui/src/components/markdown_viewer.rs b/crates/pinakes-ui/src/components/markdown_viewer.rs new file mode 100644 index 0000000..3cda3e8 --- /dev/null +++ b/crates/pinakes-ui/src/components/markdown_viewer.rs @@ -0,0 +1,180 @@ +use dioxus::prelude::*; + +#[component] +pub fn MarkdownViewer(content_url: String, media_type: String) -> Element { + let mut rendered_html = use_signal(String::new); + let mut frontmatter_html = use_signal(|| Option::::None); + let mut loading = use_signal(|| true); + let mut error = use_signal(|| Option::::None); + + // Fetch content on mount + let url = content_url.clone(); + let mtype = media_type.clone(); + use_effect(move || { + let url = url.clone(); + let mtype = mtype.clone(); + spawn(async move { + loading.set(true); + error.set(None); + match reqwest::get(&url).await { + Ok(resp) => match resp.text().await { + Ok(text) => { + if mtype == "md" || mtype == "markdown" { + let (fm_html, body_html) = render_markdown_with_frontmatter(&text); + frontmatter_html.set(fm_html); + rendered_html.set(body_html); + } else { + frontmatter_html.set(None); + rendered_html.set(render_plaintext(&text)); + }; + } + Err(e) => error.set(Some(format!("Failed to read content: {e}"))), + }, + Err(e) => error.set(Some(format!("Failed to fetch: {e}"))), + } + loading.set(false); + }); + }); + + let is_loading = *loading.read(); + + rsx! { + div { class: "markdown-viewer", + if is_loading { + div { class: "loading-overlay", + div { class: "spinner" } + "Loading content..." + } + } + + if let Some(ref err) = *error.read() { + div { class: "error-banner", + span { class: "error-icon", "\u{26a0}" } + "{err}" + } + } + + if !is_loading && error.read().is_none() { + if let Some(ref fm) = *frontmatter_html.read() { + div { + class: "frontmatter-card", + dangerous_inner_html: "{fm}", + } + } + div { + class: "markdown-content", + dangerous_inner_html: "{rendered_html}", + } + } + } + } +} + +/// Parse frontmatter and render markdown body. Returns (frontmatter_html, body_html). +fn render_markdown_with_frontmatter(text: &str) -> (Option, String) { + use gray_matter::Matter; + use gray_matter::engine::YAML; + + let matter = Matter::::new(); + let Ok(result) = matter.parse(text) else { + // If frontmatter parsing fails, just render the whole text as markdown + return (None, render_markdown(text)); + }; + + let fm_html = result.data.and_then(|data| render_frontmatter_card(&data)); + + let body_html = render_markdown(&result.content); + (fm_html, body_html) +} + +/// Render frontmatter fields as an HTML card. +fn render_frontmatter_card(data: &gray_matter::Pod) -> Option { + let gray_matter::Pod::Hash(map) = data else { + return None; + }; + + if map.is_empty() { + return None; + } + + let mut html = String::from("
"); + + for (key, value) in map { + let display_value = pod_to_display(value); + let escaped_key = escape_html(key); + html.push_str(&format!("
{escaped_key}
{display_value}
")); + } + + html.push_str("
"); + Some(html) +} + +fn pod_to_display(pod: &gray_matter::Pod) -> String { + match pod { + gray_matter::Pod::String(s) => escape_html(s), + gray_matter::Pod::Integer(n) => n.to_string(), + gray_matter::Pod::Float(f) => f.to_string(), + gray_matter::Pod::Boolean(b) => b.to_string(), + gray_matter::Pod::Array(arr) => { + let items: Vec = arr.iter().map(pod_to_display).collect(); + items.join(", ") + } + gray_matter::Pod::Hash(map) => { + let items: Vec = map + .iter() + .map(|(k, v)| format!("{}: {}", escape_html(k), pod_to_display(v))) + .collect(); + items.join("; ") + } + gray_matter::Pod::Null => String::new(), + } +} + +fn render_markdown(text: &str) -> String { + use pulldown_cmark::{Options, Parser, html}; + + let mut options = Options::empty(); + options.insert(Options::ENABLE_TABLES); + options.insert(Options::ENABLE_STRIKETHROUGH); + options.insert(Options::ENABLE_TASKLISTS); + options.insert(Options::ENABLE_FOOTNOTES); + options.insert(Options::ENABLE_HEADING_ATTRIBUTES); + + let parser = Parser::new_ext(text, options); + let mut html_output = String::new(); + html::push_html(&mut html_output, parser); + + // Strip script tags for safety + strip_script_tags(&html_output) +} + +fn render_plaintext(text: &str) -> String { + let escaped = escape_html(text); + format!("
{escaped}
") +} + +fn escape_html(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +fn strip_script_tags(html: &str) -> String { + // Simple removal of ") { + result = format!( + "{}{}", + &result[..start], + &result[start + end + "".len()..] + ); + } else { + // Malformed script tag - remove to end + result = result[..start].to_string(); + break; + } + } + result +} diff --git a/crates/pinakes-ui/src/components/media_player.rs b/crates/pinakes-ui/src/components/media_player.rs new file mode 100644 index 0000000..f780580 --- /dev/null +++ b/crates/pinakes-ui/src/components/media_player.rs @@ -0,0 +1,516 @@ +use dioxus::document::eval; +use dioxus::prelude::*; + +use super::utils::format_duration; + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct QueueItem { + pub media_id: String, + pub title: String, + pub artist: Option, + pub duration_secs: Option, + pub media_type: String, + pub stream_url: String, + pub thumbnail_url: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum RepeatMode { + Off, + One, + All, +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct PlayQueue { + pub items: Vec, + pub current_index: usize, + pub repeat: RepeatMode, + pub shuffle: bool, +} + +impl Default for PlayQueue { + fn default() -> Self { + Self { + items: Vec::new(), + current_index: 0, + repeat: RepeatMode::Off, + shuffle: false, + } + } +} + +impl PlayQueue { + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + pub fn current(&self) -> Option<&QueueItem> { + self.items.get(self.current_index) + } + + pub fn next(&mut self) -> Option<&QueueItem> { + if self.items.is_empty() { + return None; + } + match self.repeat { + RepeatMode::One => self.items.get(self.current_index), + RepeatMode::All => { + self.current_index = (self.current_index + 1) % self.items.len(); + self.items.get(self.current_index) + } + RepeatMode::Off => { + if self.current_index + 1 < self.items.len() { + self.current_index += 1; + self.items.get(self.current_index) + } else { + None + } + } + } + } + + pub fn previous(&mut self) -> Option<&QueueItem> { + if self.items.is_empty() { + return None; + } + if self.current_index > 0 { + self.current_index -= 1; + } else if self.repeat == RepeatMode::All { + self.current_index = self.items.len() - 1; + } + self.items.get(self.current_index) + } + + pub fn add(&mut self, item: QueueItem) { + self.items.push(item); + } + + pub fn remove(&mut self, index: usize) { + if index < self.items.len() { + self.items.remove(index); + if self.current_index >= self.items.len() && !self.items.is_empty() { + self.current_index = self.items.len() - 1; + } + } + } + + pub fn clear(&mut self) { + self.items.clear(); + self.current_index = 0; + } + + pub fn toggle_repeat(&mut self) { + self.repeat = match self.repeat { + RepeatMode::Off => RepeatMode::All, + RepeatMode::All => RepeatMode::One, + RepeatMode::One => RepeatMode::Off, + }; + } + + pub fn toggle_shuffle(&mut self) { + self.shuffle = !self.shuffle; + } +} + +#[component] +pub fn MediaPlayer( + src: String, + media_type: String, + #[props(default)] title: Option, + #[props(default)] thumbnail_url: Option, + #[props(default = false)] autoplay: bool, + #[props(default)] on_track_ended: Option>, +) -> Element { + let mut playing = use_signal(|| false); + let mut current_time = use_signal(|| 0.0f64); + let mut duration = use_signal(|| 0.0f64); + let mut volume = use_signal(|| 1.0f64); + let mut muted = use_signal(|| false); + + let is_video = media_type == "video"; + let is_playing = *playing.read(); + let cur_time = *current_time.read(); + let dur = *duration.read(); + let vol = *volume.read(); + let is_muted = *muted.read(); + let time_str = format_duration(cur_time); + let dur_str = format_duration(dur); + let vol_pct = (vol * 100.0) as u32; + + // Poll playback state every 250ms + let src_clone = src.clone(); + let on_ended = on_track_ended; + use_effect(move || { + let _ = &src_clone; + let on_ended = on_ended; + spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + let result = eval( + r#" + let el = document.getElementById('pinakes-player'); + if (el) { + return JSON.stringify({ + currentTime: el.currentTime, + duration: el.duration || 0, + paused: el.paused, + volume: el.volume, + muted: el.muted, + ended: el.ended + }); + } + return "null"; + "#, + ) + .await; + if let Ok(val) = result + && let Some(s) = val.as_str() + && s != "null" + && let Ok(state) = serde_json::from_str::(s) + { + if let Some(ct) = state["currentTime"].as_f64() { + current_time.set(ct); + } + if let Some(d) = state["duration"].as_f64() + && d.is_finite() + { + duration.set(d); + } + if let Some(p) = state["paused"].as_bool() { + playing.set(!p); + } + if let Some(true) = state["ended"].as_bool() + && let Some(ref handler) = on_ended + { + handler.call(()); + } + } + } + }); + }); + + // Autoplay on mount + if autoplay { + let src_auto = src.clone(); + use_effect(move || { + let _ = &src_auto; + spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + let _ = eval("document.getElementById('pinakes-player')?.play()").await; + }); + }); + } + + let toggle_play = move |_| { + spawn(async move { + if *playing.read() { + let _ = eval("document.getElementById('pinakes-player')?.pause()").await; + } else { + let _ = eval("document.getElementById('pinakes-player')?.play()").await; + } + }); + }; + + let toggle_mute = move |_| { + let new_muted = !*muted.read(); + muted.set(new_muted); + let js = format!( + "let e = document.getElementById('pinakes-player'); if(e) e.muted = {};", + new_muted + ); + spawn(async move { + let _ = eval(&js).await; + }); + }; + + let on_seek = move |e: Event| { + if let Ok(t) = e.value().parse::() { + current_time.set(t); + let js = format!( + "let e = document.getElementById('pinakes-player'); if(e) e.currentTime = {};", + t + ); + spawn(async move { + let _ = eval(&js).await; + }); + } + }; + + let on_volume = move |e: Event| { + if let Ok(v) = e.value().parse::() { + let vol_val = v / 100.0; + volume.set(vol_val); + let js = format!( + "let e = document.getElementById('pinakes-player'); if(e) e.volume = {};", + vol_val + ); + spawn(async move { + let _ = eval(&js).await; + }); + } + }; + + let on_fullscreen = move |_| { + spawn(async move { + let _ = eval( + "let e = document.getElementById('pinakes-player'); if(e) { if(document.fullscreenElement) document.exitFullscreen(); else e.requestFullscreen(); }", + ).await; + }); + }; + + // Keyboard controls + let on_keydown = move |evt: KeyboardEvent| { + let key = evt.key(); + match key { + Key::Character(ref c) if c == " " => { + evt.prevent_default(); + spawn(async move { + if *playing.read() { + let _ = eval("document.getElementById('pinakes-player')?.pause()").await; + } else { + let _ = eval("document.getElementById('pinakes-player')?.play()").await; + } + }); + } + Key::ArrowLeft => { + evt.prevent_default(); + spawn(async move { + let _ = eval("let e = document.getElementById('pinakes-player'); if(e) e.currentTime = Math.max(0, e.currentTime - 5);").await; + }); + } + Key::ArrowRight => { + evt.prevent_default(); + spawn(async move { + let _ = eval("let e = document.getElementById('pinakes-player'); if(e) e.currentTime = Math.min(e.duration || 0, e.currentTime + 5);").await; + }); + } + Key::ArrowUp => { + evt.prevent_default(); + let new_vol = (vol + 0.1).min(1.0); + volume.set(new_vol); + let js = format!( + "let e = document.getElementById('pinakes-player'); if(e) e.volume = {};", + new_vol + ); + spawn(async move { + let _ = eval(&js).await; + }); + } + Key::ArrowDown => { + evt.prevent_default(); + let new_vol = (vol - 0.1).max(0.0); + volume.set(new_vol); + let js = format!( + "let e = document.getElementById('pinakes-player'); if(e) e.volume = {};", + new_vol + ); + spawn(async move { + let _ = eval(&js).await; + }); + } + Key::Character(ref c) if c == "m" || c == "M" => { + let new_muted = !*muted.read(); + muted.set(new_muted); + let js = format!( + "let e = document.getElementById('pinakes-player'); if(e) e.muted = {};", + new_muted + ); + spawn(async move { + let _ = eval(&js).await; + }); + } + Key::Character(ref c) if c == "f" || c == "F" => { + spawn(async move { + let _ = eval("let e = document.getElementById('pinakes-player'); if(e) { if(document.fullscreenElement) document.exitFullscreen(); else e.requestFullscreen(); }").await; + }); + } + _ => {} + } + }; + + let play_icon = if is_playing { "\u{23f8}" } else { "\u{25b6}" }; + let mute_icon = if is_muted { "\u{1f507}" } else { "\u{1f50a}" }; + + rsx! { + div { + class: if is_video { "media-player media-player-video" } else { "media-player media-player-audio" }, + tabindex: "0", + onkeydown: on_keydown, + + // Hidden native element + if is_video { + video { + id: "pinakes-player", + src: "{src}", + style: if is_video { "width: 100%; display: block;" } else { "display: none;" }, + preload: "metadata", + } + } else { + audio { + id: "pinakes-player", + src: "{src}", + style: "display: none;", + preload: "metadata", + } + } + + // Album art for audio + if !is_video { + div { class: "player-artwork", + if let Some(ref thumb) = thumbnail_url { + img { src: "{thumb}", alt: "Cover art" } + } else { + div { class: "player-artwork-placeholder", "\u{266b}" } + } + if let Some(ref t) = title { + div { class: "player-title", "{t}" } + } + } + } + + // Custom controls + div { class: "player-controls", + button { + class: "play-btn", + onclick: toggle_play, + title: if is_playing { "Pause" } else { "Play" }, + "{play_icon}" + } + span { class: "player-time", "{time_str}" } + input { + r#type: "range", + class: "seek-bar", + min: "0", + max: "{dur}", + step: "0.1", + value: "{cur_time}", + oninput: on_seek, + } + span { class: "player-time", "{dur_str}" } + button { + class: "mute-btn", + onclick: toggle_mute, + title: if is_muted { "Unmute" } else { "Mute" }, + "{mute_icon}" + } + input { + r#type: "range", + class: "volume-slider", + min: "0", + max: "100", + value: "{vol_pct}", + oninput: on_volume, + } + if is_video { + button { + class: "fullscreen-btn", + onclick: on_fullscreen, + title: "Fullscreen", + "\u{26f6}" + } + } + } + } + } +} + +#[component] +pub fn QueuePanel( + queue: PlayQueue, + on_select: EventHandler, + on_remove: EventHandler, + on_clear: EventHandler<()>, + on_toggle_repeat: EventHandler<()>, + on_toggle_shuffle: EventHandler<()>, + on_next: EventHandler<()>, + on_previous: EventHandler<()>, +) -> Element { + let repeat_label = match queue.repeat { + RepeatMode::Off => "Repeat: Off", + RepeatMode::One => "Repeat: One", + RepeatMode::All => "Repeat: All", + }; + let shuffle_label = if queue.shuffle { + "Shuffle: On" + } else { + "Shuffle: Off" + }; + let current_idx = queue.current_index; + + rsx! { + div { class: "queue-panel", + div { class: "queue-header", + h3 { "Play Queue ({queue.items.len()})" } + div { class: "queue-controls", + button { + class: "btn btn-sm btn-ghost", + onclick: move |_| on_previous.call(()), + title: "Previous (P)", + "\u{23ee}" + } + button { + class: "btn btn-sm btn-ghost", + onclick: move |_| on_next.call(()), + title: "Next (N)", + "\u{23ed}" + } + button { + class: "btn btn-sm btn-ghost", + onclick: move |_| on_toggle_repeat.call(()), + title: "{repeat_label}", + "\u{1f501}" + } + button { + class: "btn btn-sm btn-ghost", + onclick: move |_| on_toggle_shuffle.call(()), + title: "{shuffle_label}", + "\u{1f500}" + } + button { + class: "btn btn-sm btn-ghost", + onclick: move |_| on_clear.call(()), + title: "Clear Queue", + "\u{1f5d1}" + } + } + } + + if queue.items.is_empty() { + div { class: "queue-empty", "Queue is empty. Add items from the library." } + } else { + div { class: "queue-list", + for (i, item) in queue.items.iter().enumerate() { + { + let is_current = i == current_idx; + let item_class = if is_current { "queue-item queue-item-active" } else { "queue-item" }; + let title = item.title.clone(); + let artist = item.artist.clone().unwrap_or_default(); + rsx! { + div { + key: "q-{i}", + class: "{item_class}", + onclick: move |_| on_select.call(i), + div { class: "queue-item-info", + span { class: "queue-item-title", "{title}" } + if !artist.is_empty() { + span { class: "queue-item-artist", "{artist}" } + } + } + button { + class: "btn btn-sm btn-ghost queue-item-remove", + onclick: move |e: Event| { + e.stop_propagation(); + on_remove.call(i); + }, + "\u{2715}" + } + } + } + } + } + } + } + } + } +} diff --git a/crates/pinakes-ui/src/components/mod.rs b/crates/pinakes-ui/src/components/mod.rs new file mode 100644 index 0000000..dfdc5bd --- /dev/null +++ b/crates/pinakes-ui/src/components/mod.rs @@ -0,0 +1,20 @@ +pub mod audit; +pub mod breadcrumb; +pub mod collections; +pub mod database; +pub mod detail; +pub mod duplicates; +pub mod image_viewer; +pub mod import; +pub mod library; +pub mod loading; +pub mod login; +pub mod markdown_viewer; +pub mod media_player; +pub mod pagination; +pub mod search; +pub mod settings; +pub mod statistics; +pub mod tags; +pub mod tasks; +pub mod utils; diff --git a/crates/pinakes-ui/src/components/pagination.rs b/crates/pinakes-ui/src/components/pagination.rs new file mode 100644 index 0000000..4db096c --- /dev/null +++ b/crates/pinakes-ui/src/components/pagination.rs @@ -0,0 +1,102 @@ +use dioxus::prelude::*; + +#[component] +pub fn Pagination( + current_page: u64, + total_pages: u64, + on_page_change: EventHandler, +) -> Element { + if total_pages <= 1 { + return rsx! {}; + } + + let pages = pagination_range(current_page, total_pages); + + rsx! { + div { class: "pagination", + button { + class: "btn btn-sm btn-secondary", + disabled: current_page == 0, + onclick: move |_| { + if current_page > 0 { + on_page_change.call(current_page - 1); + } + }, + "Prev" + } + + for page in pages { + if page == u64::MAX { + span { class: "page-ellipsis", "..." } + } else { + { + let btn_class = if page == current_page { + "btn btn-sm btn-primary page-btn" + } else { + "btn btn-sm btn-ghost page-btn" + }; + rsx! { + button { + key: "page-{page}", + class: "{btn_class}", + onclick: move |_| on_page_change.call(page), + "{page + 1}" + } + } + } + } + } + + button { + class: "btn btn-sm btn-secondary", + disabled: current_page >= total_pages - 1, + onclick: move |_| { + if current_page < total_pages - 1 { + on_page_change.call(current_page + 1); + } + }, + "Next" + } + } + } +} + +/// Compute a range of page numbers to display (with ellipsis as u64::MAX). +pub fn pagination_range(current: u64, total: u64) -> Vec { + let mut pages = Vec::new(); + if total <= 7 { + for i in 0..total { + pages.push(i); + } + return pages; + } + + pages.push(0); + + if current > 2 { + pages.push(u64::MAX); + } + + let start = if current <= 2 { 1 } else { current - 1 }; + let end = if current >= total - 3 { + total - 1 + } else { + current + 2 + }; + + for i in start..end { + if !pages.contains(&i) { + pages.push(i); + } + } + + if current < total - 3 { + pages.push(u64::MAX); + } + + if !pages.contains(&(total - 1)) { + pages.push(total - 1); + } + + pages +} diff --git a/crates/pinakes-ui/src/components/search.rs b/crates/pinakes-ui/src/components/search.rs new file mode 100644 index 0000000..4fcaa4c --- /dev/null +++ b/crates/pinakes-ui/src/components/search.rs @@ -0,0 +1,251 @@ +use dioxus::prelude::*; + +use super::pagination::Pagination as PaginationControls; +use super::utils::{format_size, type_badge_class, type_icon}; +use crate::client::MediaResponse; + +#[component] +pub fn Search( + results: Vec, + total_count: u64, + search_page: u64, + page_size: u64, + on_search: EventHandler<(String, Option)>, + on_select: EventHandler, + on_page_change: EventHandler, + server_url: String, +) -> Element { + let mut query = use_signal(String::new); + let mut sort_by = use_signal(|| String::from("relevance")); + let mut show_help = use_signal(|| false); + // 0 = table, 1 = grid + let mut view_mode = use_signal(|| 0u8); + + let do_search = { + let query = query; + let sort_by = sort_by; + move |_| { + let q = query.read().clone(); + let s = sort_by.read().clone(); + let sort = if s == "relevance" || s.is_empty() { + None + } else { + Some(s) + }; + on_search.call((q, sort)); + } + }; + + let on_key = { + let query = query; + let sort_by = sort_by; + move |e: KeyboardEvent| { + if e.key() == Key::Enter { + let q = query.read().clone(); + let s = sort_by.read().clone(); + let sort = if s == "relevance" || s.is_empty() { + None + } else { + Some(s) + }; + on_search.call((q, sort)); + } + } + }; + + let toggle_help = move |_| { + let current = *show_help.read(); + show_help.set(!current); + }; + + let help_visible = *show_help.read(); + let current_mode = *view_mode.read(); + let total_pages = if page_size > 0 { + total_count.div_ceil(page_size) + } else { + 1 + }; + + rsx! { + div { class: "form-row mb-16", + input { + r#type: "text", + placeholder: "Search media...", + value: "{query}", + oninput: move |e| query.set(e.value()), + onkeypress: on_key, + } + select { + value: "{sort_by}", + onchange: move |e| sort_by.set(e.value()), + option { value: "relevance", "Relevance" } + option { value: "date_desc", "Newest" } + option { value: "date_asc", "Oldest" } + option { value: "name_asc", "Name A-Z" } + option { value: "name_desc", "Name Z-A" } + option { value: "size_desc", "Size (largest)" } + option { value: "size_asc", "Size (smallest)" } + } + button { + class: "btn btn-primary", + onclick: do_search, + "Search" + } + button { + class: "btn btn-ghost", + onclick: toggle_help, + "Syntax Help" + } + + // View mode toggle + div { class: "view-toggle", + button { + class: if current_mode == 1 { "view-btn active" } else { "view-btn" }, + onclick: move |_| view_mode.set(1), + title: "Grid view", + "\u{25a6}" + } + button { + class: if current_mode == 0 { "view-btn active" } else { "view-btn" }, + onclick: move |_| view_mode.set(0), + title: "Table view", + "\u{2630}" + } + } + } + + if help_visible { + div { class: "card mb-16", + h4 { "Search Syntax" } + ul { + li { code { "hello world" } " -- full text search (implicit AND)" } + li { code { "artist:Beatles" } " -- field match" } + li { code { "type:pdf" } " -- filter by media type" } + li { code { "tag:music" } " -- filter by tag" } + li { code { "hello OR world" } " -- OR operator" } + li { code { "-excluded" } " -- NOT (exclude term)" } + li { code { "hel*" } " -- prefix search" } + li { code { "hello~" } " -- fuzzy search" } + li { code { "\"exact phrase\"" } " -- quoted exact match" } + } + } + } + + p { class: "text-muted text-sm mb-8", "Results: {total_count}" } + + if results.is_empty() && query.read().is_empty() { + div { class: "empty-state", + h3 { class: "empty-title", "Search your media" } + p { class: "empty-subtitle", "Enter a query above to find files by name, metadata, tags, or type." } + } + } + + if results.is_empty() && !query.read().is_empty() { + div { class: "empty-state", + h3 { class: "empty-title", "No results found" } + p { class: "empty-subtitle", "Try a different query or check the syntax help." } + } + } + + // Content: grid or table + match current_mode { + 1 => rsx! { + div { class: "media-grid", + for item in results.iter() { + { + let badge_class = type_badge_class(&item.media_type); + let card_click = { + let id = item.id.clone(); + move |_| on_select.call(id.clone()) + }; + + let thumb_url = if item.has_thumbnail { + format!("{}/api/v1/media/{}/thumbnail", server_url, item.id) + } else { + String::new() + }; + let has_thumb = item.has_thumbnail; + let media_type = item.media_type.clone(); + + rsx! { + div { + key: "{item.id}", + class: "media-card", + onclick: card_click, + + div { class: "card-thumbnail", + if has_thumb { + img { + src: "{thumb_url}", + alt: "{item.file_name}", + loading: "lazy", + } + } else { + div { class: "card-type-icon {badge_class}", + "{type_icon(&media_type)}" + } + } + } + + div { class: "card-info", + div { class: "card-name", title: "{item.file_name}", + "{item.file_name}" + } + div { class: "card-meta", + span { class: "type-badge {badge_class}", "{item.media_type}" } + span { class: "card-size", "{format_size(item.file_size)}" } + } + } + } + } + } + } + } + }, + _ => rsx! { + table { class: "data-table", + thead { + tr { + th { "Name" } + th { "Type" } + th { "Artist" } + th { "Size" } + } + } + tbody { + for item in results.iter() { + { + let artist = item.artist.clone().unwrap_or_default(); + let size = format_size(item.file_size); + let badge_class = type_badge_class(&item.media_type); + let row_click = { + let id = item.id.clone(); + move |_| on_select.call(id.clone()) + }; + rsx! { + tr { + key: "{item.id}", + onclick: row_click, + td { "{item.file_name}" } + td { + span { class: "type-badge {badge_class}", "{item.media_type}" } + } + td { "{artist}" } + td { "{size}" } + } + } + } + } + } + } + }, + } + + // Pagination controls + PaginationControls { + current_page: search_page, + total_pages: total_pages, + on_page_change: on_page_change, + } + } +} diff --git a/crates/pinakes-ui/src/components/settings.rs b/crates/pinakes-ui/src/components/settings.rs new file mode 100644 index 0000000..edde519 --- /dev/null +++ b/crates/pinakes-ui/src/components/settings.rs @@ -0,0 +1,545 @@ +use dioxus::prelude::*; + +use crate::client::ConfigResponse; + +#[component] +pub fn Settings( + config: ConfigResponse, + on_add_root: EventHandler, + on_remove_root: EventHandler, + on_toggle_watch: EventHandler, + on_update_poll_interval: EventHandler, + on_update_ignore_patterns: EventHandler>, + #[props(default)] on_update_ui_config: Option>, +) -> Element { + let mut new_root = use_signal(String::new); + let mut editing_poll = use_signal(|| false); + let mut poll_input = use_signal(String::new); + let mut poll_error = use_signal(|| Option::::None); + let mut editing_patterns = use_signal(|| false); + let mut patterns_input = use_signal(String::new); + + let writable = config.config_writable; + let watch_enabled = config.scanning.watch; + let host_port = format!("{}:{}", config.server.host, config.server.port); + let db_path = config.database_path.clone().unwrap_or_default(); + let root_count = config.roots.len(); + + rsx! { + div { class: "settings-layout", + + // ── Configuration Source ── + div { class: "settings-card", + div { class: "settings-card-header", + h3 { class: "settings-card-title", "Configuration Source" } + if writable { + span { class: "badge badge-success", "Writable" } + } else { + span { class: "badge badge-warning", "Read-only" } + } + } + div { class: "settings-card-body", + if let Some(ref path) = config.config_path { + div { class: "info-row", + label { class: "form-label", "Config Path" } + span { class: "info-value mono", "{path}" } + } + } + if !writable { + div { class: "settings-notice settings-notice-warning", + "Configuration is read-only. Changes cannot be persisted to disk. " + "To enable editing, ensure the config file exists and is writable by the server process." + } + } + } + } + + // ── Server Health ── + div { class: "settings-card", + div { class: "settings-card-header", + h3 { class: "settings-card-title", "Server Info" } + } + div { class: "settings-card-body", + div { class: "info-row", + div { class: "form-label-row", + label { class: "form-label", "Backend" } + span { class: "tooltip-trigger", + "?" + span { class: "tooltip-text", "The storage backend used by the server (SQLite or PostgreSQL)." } + } + } + span { class: "info-value badge badge-neutral", "{config.backend}" } + } + div { class: "info-row", + div { class: "form-label-row", + label { class: "form-label", "Server Address" } + span { class: "tooltip-trigger", + "?" + span { class: "tooltip-text", "The address and port the server is listening on." } + } + } + span { class: "info-value mono", "{host_port}" } + } + div { class: "info-row", + div { class: "form-label-row", + label { class: "form-label", "Database Path" } + span { class: "tooltip-trigger", + "?" + span { class: "tooltip-text", "File path to the SQLite database, or connection info for PostgreSQL." } + } + } + span { class: "info-value mono", "{db_path}" } + } + } + } + + // ── Root Directories ── + div { class: "settings-card", + div { class: "settings-card-header", + div { class: "form-label-row", + h3 { class: "settings-card-title", "Root Directories" } + span { class: "badge badge-neutral", "{root_count}" } + } + span { class: "tooltip-trigger", + "?" + span { class: "tooltip-text", "Directories that Pinakes scans for media files. Only existing directories can be added." } + } + } + div { class: "settings-card-body", + if config.roots.is_empty() { + p { class: "text-muted", "No root directories configured." } + } else { + div { class: "root-list", + for root in config.roots.iter() { + div { class: "root-item", key: "{root}", + span { class: "mono root-path", "{root}" } + button { + class: "btn btn-danger btn-sm", + disabled: !writable, + onclick: { + let root = root.clone(); + move |_| { + if writable { + on_remove_root.call(root.clone()); + } + } + }, + "Remove" + } + } + } + } + } + + div { class: "form-row", + input { + r#type: "text", + placeholder: "/path/to/root...", + value: "{new_root}", + disabled: !writable, + oninput: move |e| new_root.set(e.value()), + onkeypress: move |e: KeyboardEvent| { + if writable && e.key() == Key::Enter { + let path = new_root.read().clone(); + if !path.is_empty() { + on_add_root.call(path); + new_root.set(String::new()); + } + } + }, + } + button { + class: "btn btn-secondary", + disabled: !writable, + onclick: move |_| { + if writable { + let mut new_root = new_root; + spawn(async move { + if let Some(handle) = rfd::AsyncFileDialog::new().pick_folder().await { + new_root.set(handle.path().to_string_lossy().to_string()); + } + }); + } + }, + "Browse..." + } + button { + class: "btn btn-primary", + disabled: !writable, + onclick: move |_| { + if writable { + let path = new_root.read().clone(); + if !path.is_empty() { + on_add_root.call(path); + new_root.set(String::new()); + } + } + }, + "Add Root" + } + } + } + } + + // ── Scanning ── + div { class: "settings-card", + div { class: "settings-card-header", + h3 { class: "settings-card-title", "Scanning" } + } + div { class: "settings-card-body", + + // File watching toggle + div { class: "settings-field", + div { class: "form-label-row", + label { class: "form-label", "File Watching" } + span { class: "tooltip-trigger", + "?" + span { class: "tooltip-text", "When enabled, Pinakes monitors root directories for new, modified, or deleted files in real time using filesystem events." } + } + } + div { + class: if writable { "toggle" } else { "toggle toggle-disabled" }, + onclick: move |_| { + if writable { + on_toggle_watch.call(!watch_enabled); + } + }, + div { + class: if watch_enabled { "toggle-track active" } else { "toggle-track" }, + div { class: "toggle-thumb" } + } + } + } + + // Poll interval + div { class: "settings-field", + div { class: "form-label-row", + label { class: "form-label", "Poll Interval" } + span { class: "tooltip-trigger", + "?" + span { class: "tooltip-text", "How often (in seconds) Pinakes polls for file changes when watch mode is not available or as a fallback." } + } + } + if *editing_poll.read() { + div { class: "settings-inline-edit", + input { + r#type: "number", + min: "1", + value: "{poll_input}", + class: "input-sm", + oninput: move |e| { + poll_input.set(e.value()); + // Clear error on new input + poll_error.set(None); + }, + onkeypress: move |e: KeyboardEvent| { + if e.key() == Key::Enter { + let raw = poll_input.read().clone(); + match raw.parse::() { + Ok(secs) if secs > 0 => { + on_update_poll_interval.call(secs); + editing_poll.set(false); + poll_error.set(None); + } + _ => { + poll_error.set(Some("Enter a positive integer (seconds).".to_string())); + } + } + } + }, + } + span { class: "input-suffix", "seconds" } + button { + class: "btn btn-primary btn-sm", + onclick: move |_| { + let raw = poll_input.read().clone(); + match raw.parse::() { + Ok(secs) if secs > 0 => { + on_update_poll_interval.call(secs); + editing_poll.set(false); + poll_error.set(None); + } + _ => { + poll_error.set(Some("Enter a positive integer (seconds).".to_string())); + } + } + }, + "Save" + } + button { + class: "btn btn-ghost btn-sm", + onclick: move |_| { + editing_poll.set(false); + poll_error.set(None); + }, + "Cancel" + } + } + if let Some(ref err) = *poll_error.read() { + p { class: "field-error", "{err}" } + } + } else { + div { class: "flex-row", + span { class: "info-value", "{config.scanning.poll_interval_secs}s" } + button { + class: "btn btn-ghost btn-sm", + disabled: !writable, + onclick: { + let current = config.scanning.poll_interval_secs; + move |_| { + if writable { + poll_input.set(current.to_string()); + poll_error.set(None); + editing_poll.set(true); + } + } + }, + "Edit" + } + } + } + } + + // Ignore patterns + div { class: "settings-field", + div { class: "settings-field-header", + div { class: "form-label-row", + label { class: "form-label", "Ignore Patterns" } + span { class: "tooltip-trigger", + "?" + span { class: "tooltip-text", "Glob patterns for files and directories to skip during scanning. One pattern per line." } + } + } + if *editing_patterns.read() { + div { class: "flex-row", + button { + class: "btn btn-primary btn-sm", + onclick: move |_| { + let input = patterns_input.read().clone(); + let patterns: Vec = input + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect(); + on_update_ignore_patterns.call(patterns); + editing_patterns.set(false); + }, + "Save" + } + button { + class: "btn btn-ghost btn-sm", + onclick: move |_| editing_patterns.set(false), + "Cancel" + } + } + } else { + button { + class: "btn btn-ghost btn-sm", + disabled: !writable, + onclick: { + let patterns = config.scanning.ignore_patterns.clone(); + move |_| { + if writable { + patterns_input.set(patterns.join("\n")); + editing_patterns.set(true); + } + } + }, + "Edit" + } + } + } + + if *editing_patterns.read() { + div { class: "settings-patterns-edit", + textarea { + value: "{patterns_input}", + oninput: move |e| patterns_input.set(e.value()), + rows: "8", + class: "patterns-textarea", + placeholder: "One pattern per line, e.g.:\n*.tmp\n.git/**\nnode_modules/**", + } + p { class: "text-muted text-sm", "Enter one glob pattern per line. Empty lines are ignored." } + } + } else { + if config.scanning.ignore_patterns.is_empty() { + p { class: "text-muted text-sm", "No ignore patterns configured." } + } else { + div { class: "patterns-list", + for pattern in config.scanning.ignore_patterns.iter() { + span { class: "pattern-chip mono", "{pattern}" } + } + } + } + } + } + } + } + + // ── UI Preferences ── + div { class: "settings-card", + div { class: "settings-card-header", + h3 { class: "settings-card-title", "UI Preferences" } + } + div { class: "settings-card-body", + // Theme + div { class: "settings-field", + div { class: "form-label-row", + label { class: "form-label", "Theme" } + span { class: "tooltip-trigger", + "?" + span { class: "tooltip-text", "Choose between dark and light themes." } + } + } + select { + value: "{config.ui.theme}", + onchange: { + let handler = on_update_ui_config; + move |e: Event| { + if let Some(ref h) = handler { + h.call(serde_json::json!({"theme": e.value()})); + } + } + }, + option { value: "dark", "Dark" } + option { value: "light", "Light" } + } + } + + // Default view + div { class: "settings-field", + div { class: "form-label-row", + label { class: "form-label", "Default View" } + span { class: "tooltip-trigger", + "?" + span { class: "tooltip-text", "The view shown when the application starts." } + } + } + select { + value: "{config.ui.default_view}", + onchange: { + let handler = on_update_ui_config; + move |e: Event| { + if let Some(ref h) = handler { + h.call(serde_json::json!({"default_view": e.value()})); + } + } + }, + option { value: "library", "Library" } + option { value: "search", "Search" } + } + } + + // Default page size + div { class: "settings-field", + div { class: "form-label-row", + label { class: "form-label", "Default Page Size" } + span { class: "tooltip-trigger", + "?" + span { class: "tooltip-text", "Number of items shown per page by default." } + } + } + select { + value: "{config.ui.default_page_size}", + onchange: { + let handler = on_update_ui_config; + move |e: Event| { + if let Some(ref h) = handler + && let Ok(size) = e.value().parse::() { + h.call(serde_json::json!({"default_page_size": size})); + } + } + }, + option { value: "24", "24" } + option { value: "48", "48" } + option { value: "96", "96" } + option { value: "200", "200" } + } + } + + // Default view mode + div { class: "settings-field", + div { class: "form-label-row", + label { class: "form-label", "Default View Mode" } + span { class: "tooltip-trigger", + "?" + span { class: "tooltip-text", "Whether to show items in a grid or table layout." } + } + } + select { + value: "{config.ui.default_view_mode}", + onchange: { + let handler = on_update_ui_config; + move |e: Event| { + if let Some(ref h) = handler { + h.call(serde_json::json!({"default_view_mode": e.value()})); + } + } + }, + option { value: "grid", "Grid" } + option { value: "table", "Table" } + } + } + + // Auto-play media + div { class: "settings-field", + div { class: "form-label-row", + label { class: "form-label", "Auto-play Media" } + span { class: "tooltip-trigger", + "?" + span { class: "tooltip-text", "Automatically start playback when opening audio or video." } + } + } + { + let autoplay = config.ui.auto_play_media; + let handler = on_update_ui_config; + rsx! { + div { + class: "toggle", + onclick: move |_| { + if let Some(ref h) = handler { + h.call(serde_json::json!({"auto_play_media": !autoplay})); + } + }, + div { + class: if autoplay { "toggle-track active" } else { "toggle-track" }, + div { class: "toggle-thumb" } + } + } + } + } + } + + // Show thumbnails + div { class: "settings-field", + div { class: "form-label-row", + label { class: "form-label", "Show Thumbnails" } + span { class: "tooltip-trigger", + "?" + span { class: "tooltip-text", "Display thumbnail previews in library and search views." } + } + } + { + let show_thumbs = config.ui.show_thumbnails; + let handler = on_update_ui_config; + rsx! { + div { + class: "toggle", + onclick: move |_| { + if let Some(ref h) = handler { + h.call(serde_json::json!({"show_thumbnails": !show_thumbs})); + } + }, + div { + class: if show_thumbs { "toggle-track active" } else { "toggle-track" }, + div { class: "toggle-thumb" } + } + } + } + } + } + } + } + } + } +} diff --git a/crates/pinakes-ui/src/components/statistics.rs b/crates/pinakes-ui/src/components/statistics.rs new file mode 100644 index 0000000..9f2130e --- /dev/null +++ b/crates/pinakes-ui/src/components/statistics.rs @@ -0,0 +1,183 @@ +use dioxus::prelude::*; + +use super::utils::format_size; +use crate::client::LibraryStatisticsResponse; + +#[component] +pub fn Statistics( + stats: Option, + #[props(default)] error: Option, + on_refresh: EventHandler<()>, +) -> Element { + rsx! { + div { class: "card mb-16", + div { class: "card-header", + h3 { class: "card-title", "Library Statistics" } + button { + class: "btn btn-sm btn-secondary", + onclick: move |_| on_refresh.call(()), + "\u{21bb} Refresh" + } + } + + if let Some(ref err) = error { + div { class: "alert alert-error mb-8", + span { "{err}" } + button { + class: "btn btn-sm btn-secondary ml-8", + onclick: move |_| on_refresh.call(()), + "Retry" + } + } + } + + match stats.as_ref() { + Some(s) => { + let total_size = format_size(s.total_size_bytes); + let avg_size = format_size(s.avg_file_size_bytes); + rsx! { + // Overview + div { class: "stats-grid", + div { class: "stat-card", + div { class: "stat-value", "{s.total_media}" } + div { class: "stat-label", "Total Media" } + } + div { class: "stat-card", + div { class: "stat-value", "{total_size}" } + div { class: "stat-label", "Total Size" } + } + div { class: "stat-card", + div { class: "stat-value", "{avg_size}" } + div { class: "stat-label", "Avg File Size" } + } + div { class: "stat-card", + div { class: "stat-value", "{s.total_tags}" } + div { class: "stat-label", "Tags" } + } + div { class: "stat-card", + div { class: "stat-value", "{s.total_collections}" } + div { class: "stat-label", "Collections" } + } + div { class: "stat-card", + div { class: "stat-value", "{s.total_duplicates}" } + div { class: "stat-label", "Duplicate Hashes" } + } + } + + // Media by Type + if !s.media_by_type.is_empty() { + div { class: "card mt-16", + h4 { class: "card-title", "Media by Type" } + table { class: "table", + thead { + tr { + th { "Type" } + th { "Count" } + } + } + tbody { + for item in s.media_by_type.iter() { + tr { + td { "{item.name}" } + td { "{item.count}" } + } + } + } + } + } + } + + // Storage by Type + if !s.storage_by_type.is_empty() { + div { class: "card mt-16", + h4 { class: "card-title", "Storage by Type" } + table { class: "table", + thead { + tr { + th { "Type" } + th { "Size" } + } + } + tbody { + for item in s.storage_by_type.iter() { + tr { + td { "{item.name}" } + td { "{format_size(item.count)}" } + } + } + } + } + } + } + + // Top Tags + if !s.top_tags.is_empty() { + div { class: "card mt-16", + h4 { class: "card-title", "Top Tags" } + table { class: "table", + thead { + tr { + th { "Tag" } + th { "Count" } + } + } + tbody { + for item in s.top_tags.iter() { + tr { + td { "{item.name}" } + td { "{item.count}" } + } + } + } + } + } + } + + // Top Collections + if !s.top_collections.is_empty() { + div { class: "card mt-16", + h4 { class: "card-title", "Top Collections" } + table { class: "table", + thead { + tr { + th { "Collection" } + th { "Members" } + } + } + tbody { + for item in s.top_collections.iter() { + tr { + td { "{item.name}" } + td { "{item.count}" } + } + } + } + } + } + } + + // Date Range + div { class: "card mt-16", + h4 { class: "card-title", "Date Range" } + div { class: "stats-grid", + div { class: "stat-card", + div { class: "stat-value", "{s.oldest_item.as_deref().unwrap_or(\"N/A\")}" } + div { class: "stat-label", "Oldest Item" } + } + div { class: "stat-card", + div { class: "stat-value", "{s.newest_item.as_deref().unwrap_or(\"N/A\")}" } + div { class: "stat-label", "Newest Item" } + } + } + } + } + }, + None => rsx! { + div { class: "empty-state", + p { "Loading statistics..." } + } + }, + } + } + } +} diff --git a/crates/pinakes-ui/src/components/tags.rs b/crates/pinakes-ui/src/components/tags.rs new file mode 100644 index 0000000..cecbb6e --- /dev/null +++ b/crates/pinakes-ui/src/components/tags.rs @@ -0,0 +1,273 @@ +use dioxus::prelude::*; + +use crate::client::TagResponse; + +#[component] +pub fn Tags( + tags: Vec, + on_create: EventHandler<(String, Option)>, + on_delete: EventHandler, +) -> Element { + let mut new_tag_name = use_signal(String::new); + let mut parent_tag = use_signal(String::new); + let mut confirm_delete: Signal> = use_signal(|| None); + + let create_click = move |_| { + let name = new_tag_name.read().clone(); + if name.is_empty() { + return; + } + let parent = { + let p = parent_tag.read().clone(); + if p.is_empty() { None } else { Some(p) } + }; + on_create.call((name, parent)); + new_tag_name.set(String::new()); + parent_tag.set(String::new()); + }; + + let create_key = move |e: KeyboardEvent| { + if e.key() == Key::Enter { + let name = new_tag_name.read().clone(); + if name.is_empty() { + return; + } + let parent = { + let p = parent_tag.read().clone(); + if p.is_empty() { None } else { Some(p) } + }; + on_create.call((name, parent)); + new_tag_name.set(String::new()); + parent_tag.set(String::new()); + } + }; + + // Separate root tags and child tags + let root_tags: Vec<&TagResponse> = tags.iter().filter(|t| t.parent_id.is_none()).collect(); + let child_tags: Vec<&TagResponse> = tags.iter().filter(|t| t.parent_id.is_some()).collect(); + + rsx! { + div { class: "card", + div { class: "card-header", + h3 { class: "card-title", "Tags" } + } + + div { class: "form-row mb-16", + input { + r#type: "text", + placeholder: "New tag name...", + value: "{new_tag_name}", + oninput: move |e| new_tag_name.set(e.value()), + onkeypress: create_key, + } + select { + value: "{parent_tag}", + onchange: move |e| parent_tag.set(e.value()), + option { value: "", "No Parent" } + for tag in tags.iter() { + option { + key: "{tag.id}", + value: "{tag.id}", + "{tag.name}" + } + } + } + button { + class: "btn btn-primary", + onclick: create_click, + "Create" + } + } + + if tags.is_empty() { + div { class: "empty-state", + p { class: "empty-subtitle", "No tags yet. Create one above." } + } + } else { + div { class: "tag-list", + // Root tags + for tag in root_tags.iter() { + { + let tag_id = tag.id.clone(); + let tag_name = tag.name.clone(); + let children: Vec<&TagResponse> = child_tags + .iter() + .filter(|c| c.parent_id.as_deref() == Some(tag_id.as_str())) + .copied() + .collect(); + + let is_confirming = confirm_delete.read().as_deref() == Some(tag_id.as_str()); + + rsx! { + div { key: "{tag_id}", class: "tag-group", + span { class: "tag-badge", + "{tag_name}" + if is_confirming { + { + let confirm_id = tag_id.clone(); + rsx! { + span { class: "tag-confirm-delete", + " Are you sure? " + span { + class: "tag-confirm-yes", + onclick: move |_| { + on_delete.call(confirm_id.clone()); + confirm_delete.set(None); + }, + "Confirm" + } + " " + span { + class: "tag-confirm-no", + onclick: move |_| { + confirm_delete.set(None); + }, + "Cancel" + } + } + } + } + } else { + { + let remove_id = tag_id.clone(); + rsx! { + span { + class: "tag-remove", + onclick: move |_| { + confirm_delete.set(Some(remove_id.clone())); + }, + "\u{00d7}" + } + } + } + } + } + if !children.is_empty() { + div { class: "tag-children", style: "margin-left: 16px; margin-top: 4px;", + for child in children.iter() { + { + let child_id = child.id.clone(); + let child_name = child.name.clone(); + let child_is_confirming = confirm_delete.read().as_deref() == Some(child_id.as_str()); + + rsx! { + span { + key: "{child_id}", + class: "tag-badge", + "{child_name}" + if child_is_confirming { + { + let confirm_id = child_id.clone(); + rsx! { + span { class: "tag-confirm-delete", + " Are you sure? " + span { + class: "tag-confirm-yes", + onclick: move |_| { + on_delete.call(confirm_id.clone()); + confirm_delete.set(None); + }, + "Confirm" + } + " " + span { + class: "tag-confirm-no", + onclick: move |_| { + confirm_delete.set(None); + }, + "Cancel" + } + } + } + } + } else { + { + let remove_id = child_id.clone(); + rsx! { + span { + class: "tag-remove", + onclick: move |_| { + confirm_delete.set(Some(remove_id.clone())); + }, + "\u{00d7}" + } + } + } + } + } + } + } + } + } + } + } + } + } + } + + // Orphan child tags (parent not found in current list) + for tag in child_tags.iter() { + { + let parent_exists = root_tags.iter().any(|r| Some(r.id.as_str()) == tag.parent_id.as_deref()) + || child_tags.iter().any(|c| c.id != tag.id && Some(c.id.as_str()) == tag.parent_id.as_deref()); + if !parent_exists { + let orphan_id = tag.id.clone(); + let orphan_name = tag.name.clone(); + let parent_label = tag.parent_id.clone().unwrap_or_default(); + let is_confirming = confirm_delete.read().as_deref() == Some(orphan_id.as_str()); + + rsx! { + span { key: "{orphan_id}", class: "tag-badge", + "{orphan_name}" + span { class: "text-muted text-sm", " (parent: {parent_label})" } + if is_confirming { + { + let confirm_id = orphan_id.clone(); + rsx! { + span { class: "tag-confirm-delete", + " Are you sure? " + span { + class: "tag-confirm-yes", + onclick: move |_| { + on_delete.call(confirm_id.clone()); + confirm_delete.set(None); + }, + "Confirm" + } + " " + span { + class: "tag-confirm-no", + onclick: move |_| { + confirm_delete.set(None); + }, + "Cancel" + } + } + } + } + } else { + { + let remove_id = orphan_id.clone(); + rsx! { + span { + class: "tag-remove", + onclick: move |_| { + confirm_delete.set(Some(remove_id.clone())); + }, + "\u{00d7}" + } + } + } + } + } + } + } else { + rsx! {} + } + } + } + } + } + } + } +} diff --git a/crates/pinakes-ui/src/components/tasks.rs b/crates/pinakes-ui/src/components/tasks.rs new file mode 100644 index 0000000..e4eb51c --- /dev/null +++ b/crates/pinakes-ui/src/components/tasks.rs @@ -0,0 +1,95 @@ +use dioxus::prelude::*; + +use crate::client::ScheduledTaskResponse; + +#[component] +pub fn Tasks( + tasks: Vec, + #[props(default)] error: Option, + on_refresh: EventHandler<()>, + on_toggle: EventHandler, + on_run_now: EventHandler, +) -> Element { + rsx! { + div { class: "card mb-16", + div { class: "card-header", + h3 { class: "card-title", "Scheduled Tasks" } + button { + class: "btn btn-sm btn-secondary", + onclick: move |_| on_refresh.call(()), + "\u{21bb} Refresh" + } + } + + if let Some(ref err) = error { + div { class: "alert alert-error mb-8", + span { "{err}" } + button { + class: "btn btn-sm btn-secondary ml-8", + onclick: move |_| on_refresh.call(()), + "Retry" + } + } + } + + if tasks.is_empty() { + div { class: "empty-state", + p { "No scheduled tasks configured." } + } + } else { + table { class: "table", + thead { + tr { + th { "Enabled" } + th { "Name" } + th { "Schedule" } + th { "Last Run" } + th { "Next Run" } + th { "Status" } + th { "Actions" } + } + } + tbody { + for task in tasks.iter() { + { + let task_id_toggle = task.id.clone(); + let task_id_run = task.id.clone(); + let last_run = task.last_run.clone().unwrap_or_else(|| "-".to_string()); + let next_run = task.next_run.clone().unwrap_or_else(|| "-".to_string()); + let last_status = task.last_status.clone().unwrap_or_else(|| "-".to_string()); + rsx! { + tr { + td { + if task.enabled { + span { class: "badge badge-success", "\u{2713}" } + } else { + span { class: "badge badge-muted", "\u{2715}" } + } + } + td { "{task.name}" } + td { "{task.schedule}" } + td { "{last_run}" } + td { "{next_run}" } + td { "{last_status}" } + td { + button { + class: "btn btn-sm btn-secondary mr-8", + onclick: move |_| on_toggle.call(task_id_toggle.clone()), + if task.enabled { "Disable" } else { "Enable" } + } + button { + class: "btn btn-sm btn-primary", + onclick: move |_| on_run_now.call(task_id_run.clone()), + "Run Now" + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/crates/pinakes-ui/src/components/utils.rs b/crates/pinakes-ui/src/components/utils.rs new file mode 100644 index 0000000..a08e1fd --- /dev/null +++ b/crates/pinakes-ui/src/components/utils.rs @@ -0,0 +1,69 @@ +pub fn format_size(bytes: u64) -> String { + if bytes < 1024 { + format!("{bytes} B") + } else if bytes < 1024 * 1024 { + format!("{:.1} KB", bytes as f64 / 1024.0) + } else if bytes < 1024 * 1024 * 1024 { + format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) + } else { + format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) + } +} + +pub fn type_badge_class(media_type: &str) -> &'static str { + match media_type { + "mp3" | "flac" | "ogg" | "wav" => "type-audio", + "mp4" | "mkv" | "avi" | "webm" => "type-video", + "jpeg" | "jpg" | "png" | "gif" | "webp" | "avif" => "type-image", + "pdf" | "epub" | "djvu" => "type-document", + "md" | "markdown" => "type-text", + _ => "type-other", + } +} + +pub fn type_icon(media_type: &str) -> &'static str { + match media_type { + "mp3" | "flac" | "ogg" | "wav" => "\u{266b}", + "mp4" | "mkv" | "avi" | "webm" => "\u{25b6}", + "jpeg" | "jpg" | "png" | "gif" | "webp" | "avif" => "\u{1f5bc}", + "pdf" | "epub" | "djvu" => "\u{1f4c4}", + "md" | "markdown" => "\u{270e}", + _ => "\u{1f4c1}", + } +} + +pub fn format_timestamp(ts: &str) -> String { + let trimmed = ts.replace('T', " "); + if let Some(dot_pos) = trimmed.find('.') { + trimmed[..dot_pos].to_string() + } else if let Some(z_pos) = trimmed.find('Z') { + trimmed[..z_pos].to_string() + } else if trimmed.len() > 19 { + trimmed[..19].to_string() + } else { + trimmed + } +} + +pub fn media_category(media_type: &str) -> &'static str { + match media_type { + "mp3" | "flac" | "ogg" | "wav" => "audio", + "mp4" | "mkv" | "avi" | "webm" => "video", + "jpeg" | "jpg" | "png" | "gif" | "webp" | "avif" => "image", + "pdf" | "epub" | "djvu" => "document", + "md" | "markdown" => "text", + _ => "other", + } +} + +pub fn format_duration(secs: f64) -> String { + let total = secs as u64; + let hours = total / 3600; + let mins = (total % 3600) / 60; + let s = total % 60; + if hours > 0 { + format!("{hours}:{mins:02}:{s:02}") + } else { + format!("{mins:02}:{s:02}") + } +} diff --git a/crates/pinakes-ui/src/main.rs b/crates/pinakes-ui/src/main.rs new file mode 100644 index 0000000..1556a0d --- /dev/null +++ b/crates/pinakes-ui/src/main.rs @@ -0,0 +1,46 @@ +use clap::Parser; +use tracing_subscriber::EnvFilter; + +mod app; +mod client; +mod components; +mod state; +mod styles; + +use dioxus::prelude::*; + +/// Pinakes desktop UI client +#[derive(Parser)] +#[command(name = "pinakes-ui", version, about)] +struct Cli { + /// Server URL to connect to + #[arg( + short, + long, + env = "PINAKES_SERVER_URL", + default_value = "http://localhost:3000" + )] + server: String, + + /// Set log level (trace, debug, info, warn, error) + #[arg(long, default_value = "warn")] + log_level: String, +} + +fn main() { + let cli = Cli::parse(); + + let env_filter = EnvFilter::try_new(&cli.log_level).unwrap_or_else(|_| EnvFilter::new("warn")); + + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .compact() + .init(); + + // SAFETY: Called before any threads are spawned (single-threaded at this point). + unsafe { std::env::set_var("PINAKES_SERVER_URL", &cli.server) }; + + tracing::info!(server = %cli.server, "starting pinakes desktop UI"); + + launch(app::App); +} diff --git a/crates/pinakes-ui/src/state.rs b/crates/pinakes-ui/src/state.rs new file mode 100644 index 0000000..c5e6953 --- /dev/null +++ b/crates/pinakes-ui/src/state.rs @@ -0,0 +1 @@ +// Reserved for future shared state utilities. diff --git a/crates/pinakes-ui/src/styles.rs b/crates/pinakes-ui/src/styles.rs new file mode 100644 index 0000000..642814d --- /dev/null +++ b/crates/pinakes-ui/src/styles.rs @@ -0,0 +1,2628 @@ +pub const CSS: &str = r#" +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --bg-0: #111118; + --bg-1: #18181f; + --bg-2: #1f1f28; + --bg-3: #26263a; + --border-subtle: rgba(255, 255, 255, 0.06); + --border: rgba(255, 255, 255, 0.09); + --border-strong: rgba(255, 255, 255, 0.14); + --text-0: #dcdce4; + --text-1: #a0a0b8; + --text-2: #6c6c84; + --accent: #7c7ef5; + --accent-dim: rgba(124, 126, 245, 0.15); + --accent-text: #9698f7; + --success: #3ec97a; + --error: #e45858; + --warning: #d4a037; + --radius-sm: 3px; + --radius: 5px; + --radius-md: 7px; + --shadow-sm: 0 1px 3px rgba(0,0,0,0.3); + --shadow: 0 2px 8px rgba(0,0,0,0.35); + --shadow-lg: 0 4px 20px rgba(0,0,0,0.45); +} + +body { + font-family: 'Inter', -apple-system, 'Segoe UI', system-ui, sans-serif; + background: var(--bg-0); + color: var(--text-0); + font-size: 13px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + overflow: hidden; +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* ── Layout ── */ +.app { + display: flex; + height: 100vh; + overflow: hidden; +} + +.sidebar { + width: 220px; + min-width: 220px; + background: var(--bg-1); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + user-select: none; + overflow-y: auto; + z-index: 10; + transition: width 0.15s, min-width 0.15s; +} + +.sidebar.collapsed { width: 48px; min-width: 48px; } +.sidebar.collapsed .nav-label, +.sidebar.collapsed .sidebar-header .logo, +.sidebar.collapsed .sidebar-header .version, +.sidebar.collapsed .nav-badge { display: none; } +.sidebar.collapsed .nav-item { justify-content: center; padding: 8px; border-left: none; } +.sidebar.collapsed .nav-icon { width: auto; margin: 0; } + +.sidebar-toggle { + background: none; + border: none; + color: var(--text-2); + cursor: pointer; + padding: 8px; + font-size: 16px; + width: 100%; + text-align: center; +} +.sidebar-toggle:hover { color: var(--text-0); } + +.sidebar-header { + padding: 16px 16px 20px; + display: flex; + align-items: baseline; + gap: 8px; +} + +.sidebar-header .logo { + font-size: 15px; + font-weight: 700; + letter-spacing: -0.4px; + color: var(--text-0); +} + +.sidebar-header .version { + font-size: 10px; + color: var(--text-2); +} + +.nav-section { + padding: 0 8px; + margin-bottom: 2px; +} + +.nav-label { + padding: 8px 8px 4px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-2); +} + +.nav-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: var(--radius-sm); + cursor: pointer; + color: var(--text-1); + font-size: 13px; + font-weight: 450; + transition: color 0.1s, background 0.1s; + border: none; + background: none; + width: 100%; + text-align: left; + border-left: 2px solid transparent; + margin-left: 0; +} + +.nav-item:hover { + color: var(--text-0); + background: rgba(255,255,255,0.03); +} + +.nav-item.active { + color: var(--accent-text); + border-left-color: var(--accent); + background: var(--accent-dim); +} + +.nav-icon { + width: 18px; + text-align: center; + font-size: 14px; + opacity: 0.7; +} + +.sidebar-spacer { flex: 1; } + +.sidebar-footer { + padding: 12px; + border-top: 1px solid var(--border-subtle); +} + +/* ── Main ── */ +.main { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-width: 0; +} + +.header { + height: 48px; + min-height: 48px; + border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; + padding: 0 20px; + gap: 12px; + background: var(--bg-1); +} + +.page-title { + font-size: 14px; + font-weight: 600; + color: var(--text-0); +} + +.header-spacer { flex: 1; } + +.content { + flex: 1; + overflow-y: auto; + padding: 20px; +} + +/* ── Table ── */ +.data-table { + width: 100%; + border-collapse: collapse; + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.data-table thead th { + padding: 8px 14px; + text-align: left; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-2); + border-bottom: 1px solid var(--border); + background: var(--bg-3); +} + +.data-table tbody td { + padding: 8px 14px; + font-size: 13px; + border-bottom: 1px solid var(--border-subtle); + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.data-table tbody tr { + cursor: pointer; + transition: background 0.08s; +} + +.data-table tbody tr:hover { + background: rgba(255,255,255,0.02); +} + +.data-table tbody tr.row-selected { + background: rgba(99, 102, 241, 0.12); +} + +.data-table tbody tr:last-child td { + border-bottom: none; +} + +/* ── Buttons ── */ +.btn { + padding: 5px 12px; + border-radius: var(--radius-sm); + border: none; + cursor: pointer; + font-size: 12px; + font-weight: 500; + transition: all 0.1s; + display: inline-flex; + align-items: center; + gap: 5px; + white-space: nowrap; + line-height: 1.5; +} + +.btn-primary { + background: var(--accent); + color: #fff; +} +.btn-primary:hover { background: #8b8df7; } + +.btn-secondary { + background: var(--bg-3); + color: var(--text-0); + border: 1px solid var(--border); +} +.btn-secondary:hover { + border-color: var(--border-strong); + background: rgba(255,255,255,0.06); +} + +.btn-danger { + background: transparent; + color: var(--error); + border: 1px solid rgba(228, 88, 88, 0.25); +} +.btn-danger:hover { background: rgba(228, 88, 88, 0.08); } + +.btn-ghost { + background: transparent; + color: var(--text-1); + border: none; + padding: 5px 8px; +} +.btn-ghost:hover { + color: var(--text-0); + background: rgba(255,255,255,0.04); +} + +.btn-sm { + padding: 3px 8px; + font-size: 11px; +} + +.btn-icon { + padding: 4px; + border-radius: var(--radius-sm); + background: transparent; + border: none; + color: var(--text-2); + cursor: pointer; + transition: color 0.1s; + font-size: 13px; +} +.btn-icon:hover { color: var(--text-0); } + +/* ── Cards ── */ +.card { + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.card-title { + font-size: 14px; + font-weight: 600; +} + +/* ── Forms ── */ +input[type="text"], textarea, select { + padding: 6px 10px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-0); + color: var(--text-0); + font-size: 13px; + outline: none; + transition: border-color 0.12s; + font-family: inherit; +} + +input[type="text"]::placeholder, textarea::placeholder { + color: var(--text-2); +} + +input[type="text"]:focus, textarea:focus, select:focus { + border-color: var(--accent); +} + +.form-group { margin-bottom: 12px; } + +.form-label { + display: block; + font-size: 11px; + font-weight: 600; + color: var(--text-1); + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.form-row { + display: flex; + gap: 8px; + align-items: flex-end; +} + +.form-row input[type="text"] { + flex: 1; +} + +/* ── Toast ── */ +.toast { + position: fixed; + bottom: 16px; + right: 16px; + padding: 8px 16px; + border-radius: var(--radius); + background: var(--bg-3); + border: 1px solid var(--border); + color: var(--text-0); + font-size: 12px; + box-shadow: var(--shadow); + z-index: 300; + animation: toast-in 0.15s ease-out; + max-width: 420px; +} + +.toast.success { border-left: 3px solid var(--success); } +.toast.error { border-left: 3px solid var(--error); } + +@keyframes toast-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ── Detail ── */ +.detail-actions { + display: flex; + gap: 6px; + margin-bottom: 16px; +} + +.detail-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.detail-field { + padding: 10px 12px; + background: var(--bg-0); + border-radius: var(--radius-sm); + border: 1px solid var(--border-subtle); +} + +.detail-field.full-width { + grid-column: 1 / -1; +} + +.detail-label { + font-size: 10px; + font-weight: 600; + color: var(--text-2); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 2px; +} + +.detail-value { + font-size: 13px; + color: var(--text-0); + word-break: break-all; +} + +.detail-value.mono { + font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; + font-size: 11px; + color: var(--text-1); +} + +/* ── Stats ── */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; + margin-bottom: 20px; +} + +.stat-card { + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px 16px; +} + +.stat-value { + font-size: 22px; + font-weight: 700; + color: var(--text-0); + line-height: 1.2; + font-variant-numeric: tabular-nums; +} + +.stat-label { + font-size: 11px; + color: var(--text-2); + margin-top: 2px; + font-weight: 500; +} + +/* ── Type badges ── */ +.type-badge { + display: inline-block; + padding: 1px 6px; + border-radius: var(--radius-sm); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.type-audio { background: rgba(139, 92, 246, 0.1); color: #9d8be0; } +.type-video { background: rgba(200, 72, 130, 0.1); color: #d07eaa; } +.type-image { background: rgba(34, 160, 80, 0.1); color: #5cb97a; } +.type-document { background: rgba(59, 120, 200, 0.1); color: #6ca0d4; } +.type-text { background: rgba(200, 160, 36, 0.1); color: #c4a840; } +.type-other { background: rgba(128, 128, 160, 0.08); color: var(--text-2); } + +/* ── Tags ── */ +.tag-list { display: flex; flex-wrap: wrap; gap: 4px; } + +.tag-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 10px; + background: var(--accent-dim); + color: var(--accent-text); + border-radius: 12px; + font-size: 11px; + font-weight: 500; +} + +.tag-badge .tag-remove { + cursor: pointer; + opacity: 0.4; + font-size: 13px; + line-height: 1; + transition: opacity 0.1s; +} +.tag-badge .tag-remove:hover { opacity: 1; } + +.tag-badge.selected { + background: var(--accent); + color: #fff; + cursor: pointer; +} + +.tag-badge:not(.selected) { + cursor: pointer; +} + +/* ── Empty state ── */ +.empty-state { + text-align: center; + padding: 48px 16px; + color: var(--text-2); +} + +.empty-icon { + font-size: 32px; + margin-bottom: 12px; + opacity: 0.3; +} + +.empty-title { + font-size: 15px; + font-weight: 600; + color: var(--text-1); + margin-bottom: 4px; +} + +.empty-subtitle { + font-size: 12px; + max-width: 320px; + margin: 0 auto; + line-height: 1.5; +} + +/* ── Settings ── */ +.settings-section { margin-bottom: 24px; } + +.section-title { + font-size: 14px; + font-weight: 600; + margin-bottom: 12px; + padding-bottom: 6px; + border-bottom: 1px solid var(--border-subtle); +} + +.root-list { list-style: none; } + +.root-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: var(--bg-0); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + margin-bottom: 4px; + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 12px; + color: var(--text-1); +} + +.toggle { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 13px; + color: var(--text-0); +} + +.toggle-track { + width: 32px; + height: 18px; + border-radius: 9px; + background: var(--bg-3); + border: 1px solid var(--border); + position: relative; + transition: background 0.15s; + flex-shrink: 0; +} + +.toggle-track.active { background: var(--accent); border-color: var(--accent); } + +.toggle-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--text-0); + position: absolute; + top: 1px; + left: 1px; + transition: transform 0.15s; +} + +.toggle-track.active .toggle-thumb { + transform: translateX(14px); +} + +.toggle.disabled { opacity: 0.4; cursor: not-allowed; } + +.info-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 0; + border-bottom: 1px solid var(--border-subtle); + font-size: 13px; +} + +.info-row:last-child { border-bottom: none; } + +.info-label { color: var(--text-1); font-weight: 500; } +.info-value { color: var(--text-0); } + +/* ── Scrollbar ── */ +::-webkit-scrollbar { width: 5px; height: 5px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.14); } + +* { + scrollbar-width: thin; + scrollbar-color: rgba(255,255,255,0.08) transparent; +} + +.content { + scrollbar-width: thin; + scrollbar-color: rgba(255,255,255,0.08) transparent; +} + +/* ── Import Tabs ── */ +.import-tabs { + display: flex; + gap: 0; + margin-bottom: 16px; + border-bottom: 1px solid var(--border); +} + +.import-tab { + padding: 8px 16px; + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-2); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: color 0.1s, border-color 0.1s; +} + +.import-tab:hover { + color: var(--text-0); +} + +.import-tab.active { + color: var(--accent-text); + border-bottom-color: var(--accent); +} + +/* ── Batch Actions ── */ +.batch-actions { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--accent-dim); + border: 1px solid rgba(124, 126, 245, 0.2); + border-radius: var(--radius-sm); + margin-bottom: 12px; + font-size: 12px; + font-weight: 500; + color: var(--accent-text); +} + +/* ── Action badges (audit) ── */ +.action-danger { + background: rgba(228, 88, 88, 0.1); + color: #d47070; +} + +/* ── Tag hierarchy ── */ +.tag-group { + margin-bottom: 6px; +} + +.tag-children { + margin-left: 16px; + margin-top: 4px; + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +/* ── Detail field inputs ── */ +.detail-field input[type="text"], +.detail-field textarea, +.detail-field select { + width: 100%; + margin-top: 4px; +} + +.detail-field textarea { + min-height: 64px; + resize: vertical; +} + +/* ── Checkbox ── */ +input[type="checkbox"] { + accent-color: var(--accent); + width: 14px; + height: 14px; + cursor: pointer; +} + +/* ── Select ── */ +select { + appearance: none; + -webkit-appearance: none; + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%236c6c84' d='M5 7L1 3h8z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; + padding-right: 26px; + min-width: 100px; +} + +/* ── Code ── */ +code { + padding: 1px 5px; + border-radius: var(--radius-sm); + background: var(--bg-0); + color: var(--accent-text); + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 11px; +} + +ul { list-style: none; padding: 0; } +ul li { padding: 3px 0; font-size: 12px; color: var(--text-1); } + +/* ── Status indicator ── */ +.status-indicator { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 500; +} + +.status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.status-dot.connected { background: var(--success); } +.status-dot.disconnected { background: var(--error); } +.status-dot.checking { background: var(--warning); animation: pulse 1.5s infinite; } + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +.status-text { color: var(--text-2); } + +/* ── Modal ── */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + animation: fade-in 0.1s ease-out; +} + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.modal { + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 20px; + min-width: 360px; + max-width: 480px; + box-shadow: var(--shadow-lg); +} + +.modal-title { + font-size: 15px; + font-weight: 600; + margin-bottom: 6px; +} + +.modal-body { + font-size: 12px; + color: var(--text-1); + margin-bottom: 16px; + line-height: 1.5; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 6px; +} + +/* ── Offline banner ── */ +.offline-banner { + background: rgba(228, 88, 88, 0.06); + border: 1px solid rgba(228, 88, 88, 0.2); + border-radius: var(--radius-sm); + padding: 8px 12px; + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: #d47070; +} + +.offline-banner .offline-icon { + font-size: 14px; + flex-shrink: 0; +} + +/* ── Utility ── */ +.flex-row { display: flex; align-items: center; gap: 8px; } +.flex-between { display: flex; justify-content: space-between; align-items: center; } +.mb-16 { margin-bottom: 16px; } +.mb-8 { margin-bottom: 8px; } +.text-muted { color: var(--text-1); } +.text-sm { font-size: 11px; } +.mono { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 12px; } + +/* ── Filter bar ── */ +.filter-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + padding: 8px 12px; + background: var(--bg-0); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + margin-bottom: 8px; + font-size: 12px; +} + +.filter-group { + display: flex; + align-items: center; + gap: 6px; +} + +.filter-group label { + display: flex; + align-items: center; + gap: 3px; + cursor: pointer; + color: var(--text-1); + font-size: 11px; + white-space: nowrap; +} + +.filter-group label:hover { + color: var(--text-0); +} + +.filter-separator { + width: 1px; + height: 20px; + background: var(--border); + flex-shrink: 0; +} + +.filter-size { + display: flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: var(--text-1); +} + +.filter-size input[type="text"] { + width: 60px; + padding: 3px 6px; + font-size: 11px; +} + +/* ── Tooltips ── */ +.tooltip-trigger { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--bg-3); + color: var(--text-2); + font-size: 9px; + font-weight: 700; + cursor: help; + position: relative; + flex-shrink: 0; + margin-left: 4px; +} + +.tooltip-trigger:hover { + background: var(--accent-dim); + color: var(--accent-text); +} + +.tooltip-text { + display: none; + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + padding: 6px 10px; + background: var(--bg-3); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-0); + font-size: 11px; + font-weight: 400; + line-height: 1.4; + white-space: normal; + width: 220px; + text-transform: none; + letter-spacing: normal; + box-shadow: var(--shadow); + z-index: 100; + pointer-events: none; +} + +.tooltip-trigger:hover .tooltip-text { + display: block; +} + +/* ── Form label row ── */ +.form-label-row { + display: flex; + align-items: center; + gap: 2px; + margin-bottom: 4px; +} + +.form-label-row .form-label { + margin-bottom: 0; +} + +/* ── Read-only banner ── */ +.readonly-banner { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: rgba(212, 160, 55, 0.06); + border: 1px solid rgba(212, 160, 55, 0.2); + border-radius: var(--radius-sm); + margin-bottom: 16px; + font-size: 12px; + color: var(--warning); +} + +/* ── Config path ── */ +.config-path { + font-size: 11px; + color: var(--text-2); + margin-bottom: 12px; + font-family: 'JetBrains Mono', ui-monospace, monospace; + padding: 6px 10px; + background: var(--bg-0); + border-radius: var(--radius-sm); + border: 1px solid var(--border-subtle); +} + +/* ── Settings cards ── */ +.settings-card { + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 20px; + margin-bottom: 16px; +} + +.settings-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-subtle); +} + +.settings-card-title { + font-size: 14px; + font-weight: 600; +} + +.config-status { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; +} + +.config-status.writable { + background: rgba(62, 201, 122, 0.1); + color: var(--success); +} + +.config-status.readonly { + background: rgba(228, 88, 88, 0.1); + color: var(--error); +} + +/* ── Disabled button ── */ +.btn:disabled, .btn[disabled] { + opacity: 0.4; + cursor: not-allowed; + pointer-events: none; +} + +/* ── Library Toolbar ── */ +.library-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + margin-bottom: 12px; + gap: 12px; + flex-wrap: wrap; +} + +.toolbar-left { + display: flex; + align-items: center; + gap: 10px; +} + +.toolbar-right { + display: flex; + align-items: center; + gap: 10px; +} + +/* ── View Toggle ── */ +.view-toggle { + display: flex; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + overflow: hidden; +} + +.view-btn { + padding: 4px 10px; + background: var(--bg-2); + border: none; + color: var(--text-2); + cursor: pointer; + font-size: 14px; + line-height: 1; + transition: background 0.1s, color 0.1s; +} + +.view-btn:first-child { + border-right: 1px solid var(--border); +} + +.view-btn:hover { + color: var(--text-0); + background: var(--bg-3); +} + +.view-btn.active { + background: var(--accent-dim); + color: var(--accent-text); +} + +/* ── Sort & Page Size Controls ── */ +.sort-control select, +.page-size-control select { + padding: 4px 24px 4px 8px; + font-size: 11px; + background: var(--bg-2); +} + +.page-size-control { + display: flex; + align-items: center; + gap: 4px; +} + +/* ── Media Grid ── */ +.media-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 12px; +} + +.media-card { + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + cursor: pointer; + transition: border-color 0.12s, box-shadow 0.12s; + position: relative; +} + +.media-card:hover { + border-color: var(--border-strong); + box-shadow: var(--shadow-sm); +} + +.media-card.selected { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); +} + +/* ── Card Checkbox ── */ +.card-checkbox { + position: absolute; + top: 6px; + left: 6px; + z-index: 2; + opacity: 0; + transition: opacity 0.1s; +} + +.media-card:hover .card-checkbox, +.media-card.selected .card-checkbox { + opacity: 1; +} + +.card-checkbox input[type="checkbox"] { + width: 16px; + height: 16px; + cursor: pointer; + filter: drop-shadow(0 1px 2px rgba(0,0,0,0.5)); +} + +/* ── Card Thumbnail ── */ +.card-thumbnail { + width: 100%; + aspect-ratio: 1; + background: var(--bg-0); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + position: relative; +} + +.card-thumbnail img, +.card-thumb-img { + width: 100%; + height: 100%; + object-fit: cover; + position: absolute; + top: 0; + left: 0; + z-index: 1; +} + +.card-type-icon { + font-size: 32px; + opacity: 0.4; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + z-index: 0; +} + +/* ── Card Info ── */ +.card-info { + padding: 8px 10px; +} + +.card-name { + font-size: 12px; + font-weight: 500; + color: var(--text-0); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 4px; +} + +.card-meta { + display: flex; + align-items: center; + gap: 6px; + font-size: 10px; +} + +.card-size { + color: var(--text-2); + font-size: 10px; +} + +/* ── Table Thumbnail ── */ +.table-thumb-cell { + width: 36px; + padding: 4px 6px !important; + position: relative; +} + +.table-thumb { + width: 28px; + height: 28px; + object-fit: cover; + border-radius: 3px; + display: block; +} + +.table-thumb-overlay { + position: absolute; + top: 4px; + left: 6px; + z-index: 1; +} + +.table-type-icon { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + font-size: 14px; + opacity: 0.5; + border-radius: 3px; + background: var(--bg-0); + z-index: 0; +} + +/* ── Type Filter Row ── */ +.type-filter-row { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 0; + margin-bottom: 6px; + flex-wrap: wrap; +} + +.filter-chip { + padding: 3px 12px; + border-radius: 12px; + border: 1px solid var(--border); + background: var(--bg-2); + color: var(--text-2); + font-size: 11px; + cursor: pointer; + transition: background 0.1s, color 0.1s, border-color 0.1s; +} + +.filter-chip:hover { + color: var(--text-0); + background: var(--bg-3); + border-color: var(--border-strong); +} + +.filter-chip.active { + background: var(--accent-dim); + color: var(--accent-text); + border-color: var(--accent); +} + +/* ── Library Stats Row ── */ +.library-stats { + display: flex; + justify-content: space-between; + align-items: center; + padding: 2px 0 6px 0; + font-size: 11px; +} + +/* ── Sortable Table Headers ── */ +.sortable-header { + cursor: pointer; + user-select: none; + transition: color 0.1s; +} + +.sortable-header:hover { + color: var(--accent-text); +} + +/* ── Card extra info ── */ +.card-title, +.card-artist { + font-size: 10px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.3; +} + +/* ── Pagination ── */ +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + margin-top: 16px; + padding: 8px 0; +} + +.page-btn { + min-width: 28px; + text-align: center; + font-variant-numeric: tabular-nums; +} + +.page-ellipsis { + color: var(--text-2); + padding: 0 4px; + font-size: 12px; + user-select: none; +} + +/* ── Loading indicator ── */ +.loading-overlay { + display: flex; + align-items: center; + justify-content: center; + padding: 48px 16px; + color: var(--text-2); + font-size: 13px; + gap: 10px; +} + +.spinner { + width: 18px; + height: 18px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.error-banner { + background: rgba(228, 88, 88, 0.06); + border: 1px solid rgba(228, 88, 88, 0.2); + border-radius: var(--radius-sm); + padding: 10px 14px; + margin-bottom: 12px; + font-size: 12px; + color: #d47070; + display: flex; + align-items: center; + gap: 8px; +} + +.error-banner .error-icon { + font-size: 14px; + flex-shrink: 0; +} + +/* ── Toast container (stacked) ── */ +.toast-container { + position: fixed; + bottom: 16px; + right: 16px; + z-index: 300; + display: flex; + flex-direction: column-reverse; + gap: 6px; + align-items: flex-end; +} + +.toast-container .toast { + position: static; + transform: none; +} + +/* ── Nav badge ── */ +.nav-badge { + margin-left: auto; + font-size: 10px; + font-weight: 600; + color: var(--text-2); + background: var(--bg-3); + padding: 1px 6px; + border-radius: 8px; + min-width: 20px; + text-align: center; + font-variant-numeric: tabular-nums; +} + +/* ── Detail preview ── */ +.detail-preview { + margin-bottom: 16px; + background: var(--bg-0); + border: 1px solid var(--border-subtle); + border-radius: var(--radius); + overflow: hidden; + text-align: center; +} +.detail-preview:has(.markdown-viewer) { + max-height: none; + overflow-y: auto; + text-align: left; +} +.detail-preview:not(:has(.markdown-viewer)) { + max-height: 450px; +} + +.detail-preview img { + max-width: 100%; + max-height: 400px; + object-fit: contain; + display: block; + margin: 0 auto; +} + +.detail-preview audio { + width: 100%; + padding: 16px; +} + +.detail-preview video { + max-width: 100%; + max-height: 400px; + display: block; + margin: 0 auto; +} + +/* ── Action badge styles (audit) ── */ +.action-updated { + background: rgba(59, 120, 200, 0.1); + color: #6ca0d4; +} + +.action-collection { + background: rgba(34, 160, 80, 0.1); + color: #5cb97a; +} + +.action-collection-remove { + background: rgba(212, 160, 55, 0.1); + color: #c4a840; +} + +.action-opened { + background: rgba(139, 92, 246, 0.1); + color: #9d8be0; +} + +.action-scanned { + background: rgba(128, 128, 160, 0.08); + color: var(--text-2); +} + +/* ── Audit controls ── */ +.audit-controls { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} + +.filter-select { + padding: 4px 24px 4px 8px; + font-size: 11px; + background: var(--bg-2); +} + +/* ── Clickable elements ── */ +.clickable { + cursor: pointer; + color: var(--accent-text); +} + +.clickable:hover { + text-decoration: underline; +} + +.clickable-row { + cursor: pointer; +} + +.clickable-row:hover { + background: rgba(255,255,255,0.03); +} + +/* ── Progress bar ── */ +.progress-bar { + width: 100%; + height: 8px; + background: var(--bg-3); + border-radius: 4px; + overflow: hidden; + margin-bottom: 6px; +} + +.progress-fill { + height: 100%; + background: var(--accent); + border-radius: 4px; + transition: width 0.3s ease; +} + +/* ── Tag confirmation ── */ +.tag-confirm-delete { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 10px; + color: var(--text-1); +} + +.tag-confirm-yes { + cursor: pointer; + color: var(--error); + font-weight: 600; +} + +.tag-confirm-yes:hover { + text-decoration: underline; +} + +.tag-confirm-no { + cursor: pointer; + color: var(--text-2); + font-weight: 500; +} + +.tag-confirm-no:hover { + text-decoration: underline; +} + +/* ── Help overlay ── */ +.help-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; + animation: fade-in 0.1s ease-out; +} + +.help-dialog { + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 24px; + min-width: 300px; + max-width: 400px; + box-shadow: var(--shadow-lg); +} + +.help-dialog h3 { + font-size: 16px; + font-weight: 600; + margin-bottom: 16px; +} + +.help-shortcuts { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; +} + +.shortcut-row { + display: flex; + align-items: center; + gap: 12px; +} + +.shortcut-row kbd { + display: inline-block; + padding: 2px 8px; + background: var(--bg-0); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 11px; + color: var(--text-0); + min-width: 32px; + text-align: center; +} + +.shortcut-row span { + font-size: 13px; + color: var(--text-1); +} + +.help-close { + display: block; + width: 100%; + padding: 6px 12px; + background: var(--bg-3); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-0); + font-size: 12px; + cursor: pointer; + text-align: center; +} + +.help-close:hover { + background: rgba(255,255,255,0.06); +} + +/* ── Add media modal (collections) ── */ +.modal.wide { + max-width: 600px; + max-height: 70vh; + overflow-y: auto; +} + +/* ── Database management ── */ +.db-actions { + display: flex; + flex-direction: column; + gap: 16px; + padding: 12px; +} + +.db-action-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px; + border-radius: 6px; + background: rgba(255,255,255,0.015); +} + +.db-action-info { + flex: 1; +} + +.db-action-info h4 { + font-size: 0.95rem; + font-weight: 600; + color: var(--text-0); + margin-bottom: 4px; +} + +.db-action-confirm { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.danger-card { + border: 1px solid rgba(228, 88, 88, 0.25); +} + +/* ── Library select-all banner ── */ +.select-all-banner { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 8px 16px; + background: rgba(99, 102, 241, 0.08); + border-radius: 6px; + margin-bottom: 8px; + font-size: 0.85rem; + color: var(--text-1); +} + +.select-all-banner button { + background: none; + border: none; + color: var(--accent); + cursor: pointer; + font-weight: 600; + text-decoration: underline; + font-size: 0.85rem; + padding: 0; +} + +.select-all-banner button:hover { + color: var(--text-0); +} + +/* ── Media Player ── */ +.media-player { + position: relative; + background: var(--bg-0); + border-radius: var(--radius); + overflow: hidden; +} + +.media-player:focus { outline: none; } + +.media-player-audio .player-artwork { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px 16px 8px; + gap: 8px; +} + +.player-artwork img { + max-width: 200px; + max-height: 200px; + border-radius: var(--radius); + object-fit: cover; +} + +.player-artwork-placeholder { + width: 120px; + height: 120px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-2); + border-radius: var(--radius); + font-size: 48px; + opacity: 0.3; +} + +.player-title { + font-size: 13px; + font-weight: 500; + color: var(--text-0); + text-align: center; +} + +.player-controls { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + background: var(--bg-2); +} + +.media-player-video .player-controls { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: rgba(0, 0, 0, 0.7); + opacity: 0; + transition: opacity 0.2s; +} + +.media-player-video:hover .player-controls { + opacity: 1; +} + +.play-btn, .mute-btn, .fullscreen-btn { + background: none; + border: none; + color: var(--text-0); + cursor: pointer; + font-size: 16px; + padding: 4px; + line-height: 1; + transition: color 0.1s; +} + +.play-btn:hover, .mute-btn:hover, .fullscreen-btn:hover { + color: var(--accent-text); +} + +.player-time { + font-size: 11px; + color: var(--text-2); + font-family: 'JetBrains Mono', ui-monospace, monospace; + min-width: 36px; + text-align: center; + user-select: none; +} + +.seek-bar { + flex: 1; + -webkit-appearance: none; + appearance: none; + height: 4px; + border-radius: 2px; + background: var(--bg-3); + outline: none; + cursor: pointer; +} + +.seek-bar::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--accent); + cursor: pointer; + border: none; +} + +.seek-bar::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--accent); + cursor: pointer; + border: none; +} + +.volume-slider { + width: 70px; + -webkit-appearance: none; + appearance: none; + height: 4px; + border-radius: 2px; + background: var(--bg-3); + outline: none; + cursor: pointer; +} + +.volume-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--text-1); + cursor: pointer; + border: none; +} + +.volume-slider::-moz-range-thumb { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--text-1); + cursor: pointer; + border: none; +} + +/* ── Image Viewer ── */ +.image-viewer-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.92); + z-index: 150; + display: flex; + flex-direction: column; + animation: fade-in 0.15s ease-out; +} + +.image-viewer-overlay:focus { outline: none; } + +.image-viewer-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + background: rgba(0, 0, 0, 0.5); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + z-index: 2; + user-select: none; +} + +.image-viewer-toolbar-left, +.image-viewer-toolbar-center, +.image-viewer-toolbar-right { + display: flex; + align-items: center; + gap: 6px; +} + +.iv-btn { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + color: var(--text-0); + border-radius: var(--radius-sm); + padding: 4px 10px; + font-size: 12px; + cursor: pointer; + transition: background 0.1s; +} + +.iv-btn:hover { + background: rgba(255, 255, 255, 0.12); +} + +.iv-close { + color: var(--error); + font-weight: 600; +} + +.iv-zoom-label { + font-size: 11px; + color: var(--text-1); + min-width: 40px; + text-align: center; + font-family: 'JetBrains Mono', ui-monospace, monospace; +} + +.image-viewer-canvas { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + position: relative; +} + +.image-viewer-canvas img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + user-select: none; + -webkit-user-drag: none; +} + +/* ── Markdown Viewer ── */ +.markdown-viewer { + padding: 16px; + text-align: left; +} + +.markdown-content { + max-width: 800px; + color: var(--text-0); + line-height: 1.7; + font-size: 14px; + text-align: left; +} + +.markdown-content h1 { font-size: 1.8em; font-weight: 700; margin: 1em 0 0.5em; border-bottom: 1px solid var(--border-subtle); padding-bottom: 0.3em; } +.markdown-content h2 { font-size: 1.5em; font-weight: 600; margin: 0.8em 0 0.4em; border-bottom: 1px solid var(--border-subtle); padding-bottom: 0.2em; } +.markdown-content h3 { font-size: 1.25em; font-weight: 600; margin: 0.6em 0 0.3em; } +.markdown-content h4 { font-size: 1.1em; font-weight: 600; margin: 0.5em 0 0.25em; } +.markdown-content h5, .markdown-content h6 { font-size: 1em; font-weight: 600; margin: 0.4em 0 0.2em; color: var(--text-1); } + +.markdown-content p { margin: 0 0 1em; } +.markdown-content a { color: var(--accent); text-decoration: none; } +.markdown-content a:hover { text-decoration: underline; } + +.markdown-content pre { + background: var(--bg-3); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 12px 16px; + overflow-x: auto; + margin: 0 0 1em; + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 12px; + line-height: 1.5; +} + +.markdown-content code { + background: var(--bg-3); + padding: 1px 5px; + border-radius: var(--radius-sm); + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 0.9em; +} + +.markdown-content pre code { + background: none; + padding: 0; +} + +.markdown-content blockquote { + border-left: 3px solid var(--accent); + padding: 4px 16px; + margin: 0 0 1em; + color: var(--text-1); + background: rgba(124, 126, 245, 0.04); +} + +.markdown-content table { + width: 100%; + border-collapse: collapse; + margin: 0 0 1em; +} + +.markdown-content th, .markdown-content td { + padding: 6px 12px; + border: 1px solid var(--border); + font-size: 13px; +} + +.markdown-content th { + background: var(--bg-3); + font-weight: 600; + text-align: left; +} + +.markdown-content ul, .markdown-content ol { + margin: 0 0 1em; + padding-left: 24px; +} + +.markdown-content ul { list-style: disc; } +.markdown-content ol { list-style: decimal; } +.markdown-content li { padding: 2px 0; font-size: 14px; color: var(--text-0); } + +.markdown-content hr { + border: none; + border-top: 1px solid var(--border); + margin: 1.5em 0; +} + +.markdown-content img { + max-width: 100%; + border-radius: var(--radius); +} + +.markdown-content tr:nth-child(even) { + background: var(--bg-2); +} + +.markdown-content .footnote-definition { + font-size: 0.85em; + color: var(--text-1); + margin-top: 0.5em; + padding-left: 1.5em; +} + +.markdown-content .footnote-definition sup { + color: var(--accent); + margin-right: 4px; +} + +.markdown-content sup a { + color: var(--accent); + text-decoration: none; + font-size: 0.8em; +} + +/* ── Frontmatter Card ── */ +.frontmatter-card { + max-width: 800px; + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px 16px; + margin-bottom: 16px; +} + +.frontmatter-fields { + display: grid; + grid-template-columns: auto 1fr; + gap: 4px 12px; + margin: 0; +} + +.frontmatter-fields dt { + font-weight: 600; + font-size: 12px; + color: var(--text-1); + text-transform: capitalize; +} + +.frontmatter-fields dd { + font-size: 13px; + color: var(--text-0); + margin: 0; +} + +/* ── Duplicates ── */ +.duplicates-view { + padding: 0; +} + +.duplicates-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.duplicates-header h3 { + margin: 0; +} + +.duplicates-summary { + display: flex; + align-items: center; + gap: 12px; +} + +.duplicate-group { + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 8px; + overflow: hidden; +} + +.duplicate-group-header { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 10px 14px; + background: var(--bg-2); + border: none; + cursor: pointer; + text-align: left; + color: var(--text-0); + font-size: 13px; +} + +.duplicate-group-header:hover { + background: var(--bg-3); +} + +.expand-icon { + font-size: 10px; + width: 14px; + flex-shrink: 0; +} + +.group-name { + font-weight: 600; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.group-badge { + background: var(--accent); + color: white; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + flex-shrink: 0; +} + +.group-size { + flex-shrink: 0; + font-size: 12px; +} + +.group-hash { + font-size: 11px; + flex-shrink: 0; +} + +.duplicate-items { + border-top: 1px solid var(--border); +} + +.duplicate-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + border-bottom: 1px solid var(--border-subtle); +} + +.duplicate-item:last-child { + border-bottom: none; +} + +.duplicate-item-keep { + background: rgba(76, 175, 80, 0.06); +} + +.dup-thumb { + width: 48px; + height: 48px; + flex-shrink: 0; + border-radius: var(--radius-sm); + overflow: hidden; +} + +.dup-thumb-img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.dup-thumb-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-3); + font-size: 20px; + color: var(--text-2); +} + +.dup-info { + flex: 1; + min-width: 0; +} + +.dup-filename { + font-weight: 600; + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dup-path { + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dup-meta { + font-size: 12px; + margin-top: 2px; +} + +.dup-actions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.keep-badge { + background: rgba(76, 175, 80, 0.15); + color: #4caf50; + padding: 2px 10px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; +} + +/* ── Login ── */ +.login-container { + display: flex; + align-items: center; + justify-content: center; + height: 100vh; + background: var(--bg-0); +} + +.login-card { + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 32px; + width: 360px; + box-shadow: var(--shadow-lg); +} + +.login-title { + font-size: 20px; + font-weight: 700; + color: var(--text-0); + text-align: center; + margin-bottom: 4px; +} + +.login-subtitle { + font-size: 13px; + color: var(--text-2); + text-align: center; + margin-bottom: 20px; +} + +.login-error { + background: rgba(228, 88, 88, 0.08); + border: 1px solid rgba(228, 88, 88, 0.2); + border-radius: var(--radius-sm); + padding: 8px 12px; + margin-bottom: 12px; + font-size: 12px; + color: var(--error); +} + +.login-form input[type="text"], +.login-form input[type="password"] { + width: 100%; +} + +.login-btn { + width: 100%; + padding: 8px 16px; + font-size: 13px; + margin-top: 4px; +} + +/* ── User Info (sidebar) ── */ +.user-info { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + flex-wrap: wrap; +} + +.user-name { + font-weight: 500; + color: var(--text-0); +} + +.role-badge { + display: inline-block; + padding: 1px 6px; + border-radius: var(--radius-sm); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; +} + +.role-admin { background: rgba(139, 92, 246, 0.1); color: #9d8be0; } +.role-editor { background: rgba(34, 160, 80, 0.1); color: #5cb97a; } +.role-viewer { background: rgba(59, 120, 200, 0.1); color: #6ca0d4; } + +/* ── Settings fields ── */ +.settings-field { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid var(--border-subtle); +} + +.settings-field:last-child { border-bottom: none; } + +.settings-field select { + min-width: 120px; +} + +.settings-card-body { + padding-top: 4px; +} + +/* ── Detail no preview ── */ +.detail-no-preview { + padding: 32px 16px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +/* ── Light Theme ── */ +.theme-light { + --bg-0: #f5f5f7; + --bg-1: #eeeef0; + --bg-2: #ffffff; + --bg-3: #e8e8ec; + --border-subtle: rgba(0, 0, 0, 0.06); + --border: rgba(0, 0, 0, 0.1); + --border-strong: rgba(0, 0, 0, 0.16); + --text-0: #1a1a2e; + --text-1: #555570; + --text-2: #8888a0; + --accent: #6366f1; + --accent-dim: rgba(99, 102, 241, 0.1); + --accent-text: #4f52e8; + --shadow-sm: 0 1px 3px rgba(0,0,0,0.08); + --shadow: 0 2px 8px rgba(0,0,0,0.1); + --shadow-lg: 0 4px 20px rgba(0,0,0,0.12); +} + +.theme-light ::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); +} + +.theme-light ::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.35); +} + +.theme-light ::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.05); +} + +/* ── Skeleton Loading States ── */ +@keyframes skeleton-pulse { + 0% { opacity: 0.6; } + 50% { opacity: 0.3; } + 100% { opacity: 0.6; } +} + +.skeleton-pulse { + animation: skeleton-pulse 1.5s ease-in-out infinite; + background: var(--bg-3); + border-radius: 4px; +} + +.skeleton-card { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px; +} + +.skeleton-thumb { + width: 100%; + aspect-ratio: 1; + border-radius: 6px; +} + +.skeleton-text { + height: 14px; + width: 80%; +} + +.skeleton-text-short { + width: 50%; +} + +.skeleton-row { + display: flex; + gap: 12px; + padding: 10px 16px; + align-items: center; +} + +.skeleton-cell { + height: 14px; + flex: 1; + border-radius: 4px; +} + +.skeleton-cell-icon { + width: 32px; + height: 32px; + flex: none; + border-radius: 4px; +} + +.skeleton-cell-wide { + flex: 3; +} + +.loading-overlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + background: rgba(0, 0, 0, 0.3); + z-index: 100; + border-radius: 8px; +} + +.loading-spinner { + width: 32px; + height: 32px; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.loading-message { + color: var(--text-1); + font-size: 0.9rem; +} + +/* ── Breadcrumb ── */ +.breadcrumb { + display: flex; + align-items: center; + gap: 4px; + padding: 8px 16px; + font-size: 0.85rem; + color: var(--text-2); +} + +.breadcrumb-sep { + color: var(--text-2); + opacity: 0.5; +} + +.breadcrumb-link { + color: var(--accent-text); + text-decoration: none; + cursor: pointer; +} + +.breadcrumb-link:hover { + text-decoration: underline; +} + +.breadcrumb-current { + color: var(--text-0); + font-weight: 500; +} + +/* ── Queue Panel ── */ +.queue-panel { + display: flex; + flex-direction: column; + border-left: 1px solid var(--border); + background: var(--bg-1); + min-width: 280px; + max-width: 320px; +} + +.queue-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border-subtle); +} + +.queue-header h3 { + margin: 0; + font-size: 0.9rem; + color: var(--text-0); +} + +.queue-controls { + display: flex; + gap: 2px; +} + +.queue-list { + overflow-y: auto; + flex: 1; +} + +.queue-item { + display: flex; + align-items: center; + padding: 8px 16px; + cursor: pointer; + border-bottom: 1px solid var(--border-subtle); + transition: background 0.15s; +} + +.queue-item:hover { + background: var(--bg-2); +} + +.queue-item-active { + background: var(--accent-dim); + border-left: 3px solid var(--accent); +} + +.queue-item-info { + flex: 1; + min-width: 0; +} + +.queue-item-title { + display: block; + font-size: 0.85rem; + color: var(--text-0); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.queue-item-artist { + display: block; + font-size: 0.75rem; + color: var(--text-2); +} + +.queue-item-remove { + opacity: 0; + transition: opacity 0.15s; +} + +.queue-item:hover .queue-item-remove { + opacity: 1; +} + +.queue-empty { + padding: 24px 16px; + text-align: center; + color: var(--text-2); + font-size: 0.85rem; +} +"#; diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..6b950e0 --- /dev/null +++ b/flake.lock @@ -0,0 +1,48 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1769461804, + "narHash": "sha256-msG8SU5WsBUfVVa/9RPLaymvi5bI8edTavbIq3vRlhI=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "bfc1b8a4574108ceef22f02bafcf6611380c100d", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1769742225, + "narHash": "sha256-roSD/OJ3x9nF+Dxr+/bLClX3U8FP9EkCQIFpzxKjSUM=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "bcdd8d37594f0e201639f55889c01c827baf5c75", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..eef4312 --- /dev/null +++ b/flake.nix @@ -0,0 +1,35 @@ +{ + description = "Rust Project Template"; + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs?ref=nixos-unstable"; + rust-overlay = { + url = "github:oxalica/rust-overlay"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { + self, + nixpkgs, + rust-overlay, + }: let + systems = ["x86_64-linux" "aarch64-linux"]; + forEachSystem = nixpkgs.lib.genAttrs systems; + pkgsForEach = nixpkgs.legacyPackages; + in { + packages = forEachSystem (system: let + pkgs = pkgsForEach.${system}; + in { + mercant = pkgs.callPackage ./nix/packages/mercant.nix {}; + webview-sdk = pkgs.callPackage ./nix/packages/webview.nix {}; + }); + + devShells = forEachSystem (system: let + pkgs = nixpkgs.legacyPackages.${system}.extend rust-overlay.overlays.default; + in { + default = pkgs.callPackage ./nix/shell.nix {}; + }); + + hydraJobs = self.packages; + }; +} diff --git a/migrations/postgres/V1__initial_schema.sql b/migrations/postgres/V1__initial_schema.sql new file mode 100644 index 0000000..cd6a0c8 --- /dev/null +++ b/migrations/postgres/V1__initial_schema.sql @@ -0,0 +1,73 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE TABLE IF NOT EXISTS root_dirs ( + path TEXT PRIMARY KEY NOT NULL +); + +CREATE TABLE IF NOT EXISTS media_items ( + id UUID PRIMARY KEY NOT NULL, + path TEXT NOT NULL UNIQUE, + file_name TEXT NOT NULL, + media_type TEXT NOT NULL, + content_hash TEXT NOT NULL UNIQUE, + file_size BIGINT NOT NULL, + title TEXT, + artist TEXT, + album TEXT, + genre TEXT, + year INTEGER, + duration_secs DOUBLE PRECISION, + description TEXT, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS tags ( + id UUID PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + parent_id UUID REFERENCES tags(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_name_parent ON tags(name, COALESCE(parent_id, '00000000-0000-0000-0000-000000000000')); + +CREATE TABLE IF NOT EXISTS media_tags ( + media_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, + tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (media_id, tag_id) +); + +CREATE TABLE IF NOT EXISTS collections ( + id UUID PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + description TEXT, + kind TEXT NOT NULL, + filter_query TEXT, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS collection_members ( + collection_id UUID NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + media_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, + position INTEGER NOT NULL DEFAULT 0, + added_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (collection_id, media_id) +); + +CREATE TABLE IF NOT EXISTS audit_log ( + id UUID PRIMARY KEY NOT NULL, + media_id UUID REFERENCES media_items(id) ON DELETE SET NULL, + action TEXT NOT NULL, + details TEXT, + timestamp TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS custom_fields ( + media_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, + field_name TEXT NOT NULL, + field_type TEXT NOT NULL, + field_value TEXT NOT NULL, + PRIMARY KEY (media_id, field_name) +); diff --git a/migrations/postgres/V2__fts_indexes.sql b/migrations/postgres/V2__fts_indexes.sql new file mode 100644 index 0000000..510fce6 --- /dev/null +++ b/migrations/postgres/V2__fts_indexes.sql @@ -0,0 +1,11 @@ +ALTER TABLE media_items ADD COLUMN IF NOT EXISTS search_vector tsvector + GENERATED ALWAYS AS ( + setweight(to_tsvector('english', COALESCE(title, '')), 'A') || + setweight(to_tsvector('english', COALESCE(artist, '')), 'B') || + setweight(to_tsvector('english', COALESCE(album, '')), 'B') || + setweight(to_tsvector('english', COALESCE(genre, '')), 'C') || + setweight(to_tsvector('english', COALESCE(description, '')), 'C') || + setweight(to_tsvector('english', COALESCE(file_name, '')), 'D') + ) STORED; + +CREATE INDEX IF NOT EXISTS idx_media_search ON media_items USING GIN(search_vector); diff --git a/migrations/postgres/V3__audit_indexes.sql b/migrations/postgres/V3__audit_indexes.sql new file mode 100644 index 0000000..d8c423a --- /dev/null +++ b/migrations/postgres/V3__audit_indexes.sql @@ -0,0 +1,8 @@ +CREATE INDEX IF NOT EXISTS idx_audit_media_id ON audit_log(media_id); +CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp); +CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action); +CREATE INDEX IF NOT EXISTS idx_media_content_hash ON media_items(content_hash); +CREATE INDEX IF NOT EXISTS idx_media_media_type ON media_items(media_type); +CREATE INDEX IF NOT EXISTS idx_media_created_at ON media_items(created_at); +CREATE INDEX IF NOT EXISTS idx_media_title_trgm ON media_items USING GIN(title gin_trgm_ops); +CREATE INDEX IF NOT EXISTS idx_media_artist_trgm ON media_items USING GIN(artist gin_trgm_ops); diff --git a/migrations/postgres/V4__thumbnail_path.sql b/migrations/postgres/V4__thumbnail_path.sql new file mode 100644 index 0000000..9021884 --- /dev/null +++ b/migrations/postgres/V4__thumbnail_path.sql @@ -0,0 +1 @@ +ALTER TABLE media_items ADD COLUMN thumbnail_path TEXT; diff --git a/migrations/sqlite/V1__initial_schema.sql b/migrations/sqlite/V1__initial_schema.sql new file mode 100644 index 0000000..5b16abf --- /dev/null +++ b/migrations/sqlite/V1__initial_schema.sql @@ -0,0 +1,77 @@ +CREATE TABLE IF NOT EXISTS root_dirs ( + path TEXT PRIMARY KEY NOT NULL +); + +CREATE TABLE IF NOT EXISTS media_items ( + id TEXT PRIMARY KEY NOT NULL, + path TEXT NOT NULL UNIQUE, + file_name TEXT NOT NULL, + media_type TEXT NOT NULL, + content_hash TEXT NOT NULL UNIQUE, + file_size INTEGER NOT NULL, + title TEXT, + artist TEXT, + album TEXT, + genre TEXT, + year INTEGER, + duration_secs REAL, + description TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS tags ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + parent_id TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (parent_id) REFERENCES tags(id) ON DELETE SET NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_name_parent ON tags(name, parent_id); + +CREATE TABLE IF NOT EXISTS media_tags ( + media_id TEXT NOT NULL, + tag_id TEXT NOT NULL, + PRIMARY KEY (media_id, tag_id), + FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS collections ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + description TEXT, + kind TEXT NOT NULL, + filter_query TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS collection_members ( + collection_id TEXT NOT NULL, + media_id TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + added_at TEXT NOT NULL, + PRIMARY KEY (collection_id, media_id), + FOREIGN KEY (collection_id) REFERENCES collections(id) ON DELETE CASCADE, + FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS audit_log ( + id TEXT PRIMARY KEY NOT NULL, + media_id TEXT, + action TEXT NOT NULL, + details TEXT, + timestamp TEXT NOT NULL, + FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS custom_fields ( + media_id TEXT NOT NULL, + field_name TEXT NOT NULL, + field_type TEXT NOT NULL, + field_value TEXT NOT NULL, + PRIMARY KEY (media_id, field_name), + FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE CASCADE +); diff --git a/migrations/sqlite/V2__fts5_indexes.sql b/migrations/sqlite/V2__fts5_indexes.sql new file mode 100644 index 0000000..00c5597 --- /dev/null +++ b/migrations/sqlite/V2__fts5_indexes.sql @@ -0,0 +1,27 @@ +CREATE VIRTUAL TABLE IF NOT EXISTS media_fts USING fts5( + title, + artist, + album, + genre, + description, + file_name, + content='media_items', + content_rowid='rowid' +); + +CREATE TRIGGER IF NOT EXISTS media_fts_insert AFTER INSERT ON media_items BEGIN + INSERT INTO media_fts(rowid, title, artist, album, genre, description, file_name) + VALUES (new.rowid, new.title, new.artist, new.album, new.genre, new.description, new.file_name); +END; + +CREATE TRIGGER IF NOT EXISTS media_fts_update AFTER UPDATE ON media_items BEGIN + INSERT INTO media_fts(media_fts, rowid, title, artist, album, genre, description, file_name) + VALUES ('delete', old.rowid, old.title, old.artist, old.album, old.genre, old.description, old.file_name); + INSERT INTO media_fts(rowid, title, artist, album, genre, description, file_name) + VALUES (new.rowid, new.title, new.artist, new.album, new.genre, new.description, new.file_name); +END; + +CREATE TRIGGER IF NOT EXISTS media_fts_delete AFTER DELETE ON media_items BEGIN + INSERT INTO media_fts(media_fts, rowid, title, artist, album, genre, description, file_name) + VALUES ('delete', old.rowid, old.title, old.artist, old.album, old.genre, old.description, old.file_name); +END; diff --git a/migrations/sqlite/V3__audit_indexes.sql b/migrations/sqlite/V3__audit_indexes.sql new file mode 100644 index 0000000..1c741fe --- /dev/null +++ b/migrations/sqlite/V3__audit_indexes.sql @@ -0,0 +1,6 @@ +CREATE INDEX IF NOT EXISTS idx_audit_media_id ON audit_log(media_id); +CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp); +CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action); +CREATE INDEX IF NOT EXISTS idx_media_content_hash ON media_items(content_hash); +CREATE INDEX IF NOT EXISTS idx_media_media_type ON media_items(media_type); +CREATE INDEX IF NOT EXISTS idx_media_created_at ON media_items(created_at); diff --git a/migrations/sqlite/V4__thumbnail_path.sql b/migrations/sqlite/V4__thumbnail_path.sql new file mode 100644 index 0000000..9021884 --- /dev/null +++ b/migrations/sqlite/V4__thumbnail_path.sql @@ -0,0 +1 @@ +ALTER TABLE media_items ADD COLUMN thumbnail_path TEXT; diff --git a/nix/shell.nix b/nix/shell.nix new file mode 100644 index 0000000..ff6a45b --- /dev/null +++ b/nix/shell.nix @@ -0,0 +1,81 @@ +{ + pkgs, + rust-bin, + # rust-overlay params + extraComponents ? [], + extraTargets ? [], +}: +pkgs.mkShell { + name = "mercant-devshell"; + packages = [ + pkgs.taplo # TOML formatter + pkgs.lldb # debugger + pkgs.rust-analyzer-unwrapped # LSP + pkgs.llvm + pkgs.libiconv + + # Additional Cargo Tooling + pkgs.cargo-nextest + pkgs.cargo-deny + + # Build tools + # We use the rust-overlay to get the stable Rust toolchain for various targets. + # This is not exactly necessary, but it allows for compiling for various targets + # with the least amount of friction. + (rust-bin.nightly.latest.default.override { + extensions = ["rustfmt" "rust-analyzer" "clippy"] ++ extraComponents; + targets = + [ + "wasm32-unknown-unknown" # web + ] + ++ extraTargets; + }) + + # Link with Clang & lld + pkgs.clang + pkgs.lld + + # Handy CLI for packaging Dioxus apps and such + pkgs.dioxus-cli + + # Dioxus desktop dependencies (GTK/WebKit) + pkgs.pkg-config + pkgs.glib + pkgs.gtk3 + pkgs.webkitgtk_4_1 + pkgs.libsoup_3 + pkgs.cairo + pkgs.pango + pkgs.gdk-pixbuf + pkgs.atk + pkgs.xdotool # provides libxdo + pkgs.openssl + pkgs.kdePackages.wayland + ]; + + env = { + # Allow Cargo to use lld and clang properly + LIBCLANG_PATH = "${pkgs.libclang.lib}/lib"; + RUSTFLAGS = "-C link-arg=-fuse-ld=lld"; + + # 'cargo llvm-cov' reads these environment variables to find these + # binaries, which are needed to run the tests. + LLVM_COV = "${pkgs.llvm}/bin/llvm-cov"; + LLVM_PROFDATA = "${pkgs.llvm}/bin/llvm-profdata"; + + # Runtime library path for GTK/WebKit/xdotool + LD_LIBRARY_PATH = "${pkgs.lib.makeLibraryPath [ + pkgs.xdotool + pkgs.gtk3 + pkgs.webkitgtk_4_1 + pkgs.glib + pkgs.cairo + pkgs.pango + pkgs.gdk-pixbuf + pkgs.atk + pkgs.libsoup_3 + pkgs.openssl + pkgs.kdePackages.wayland + ]}"; + }; +} diff --git a/pinakes.toml.example b/pinakes.toml.example new file mode 100644 index 0000000..fb6885d --- /dev/null +++ b/pinakes.toml.example @@ -0,0 +1,46 @@ +# Pinakes Configuration +# Copy this file to pinakes.toml and adjust values as needed. + +[storage] +# Storage backend: "sqlite" or "postgres" +backend = "sqlite" + +[storage.sqlite] +# Path to the SQLite database file +path = "pinakes.db" + +# Uncomment and configure for PostgreSQL backend: +# [storage.postgres] +# host = "localhost" +# port = 5432 +# database = "pinakes" +# username = "pinakes" +# password = "secret" +# max_connections = 10 + +[directories] +# Root directories to scan for media files +roots = [ + "/home/user/Music", + "/home/user/Documents", + "/home/user/Videos", +] + +[scanning] +# Watch directories for changes +watch = true +# Polling interval in seconds (used as fallback when native fs events unavailable) +poll_interval_secs = 300 +# File patterns to ignore during scanning +ignore_patterns = [ + ".*", + "node_modules", + "target", + ".git", +] + +[server] +# Server bind address +host = "127.0.0.1" +# Server port +port = 3000