diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..240180a --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,74 @@ +name: Rust CI + +on: + push: + branches: [main, master, develop] + pull_request: + branches: [main, master] + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -D warnings + +jobs: + check: + name: Cargo Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + components: clippy + + - name: Cache cargo dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Check compilation + run: cargo check --all-targets + + - name: Clippy lint + run: cargo clippy --all-targets -- -D warnings + + build: + name: Build Release + needs: check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + + - name: Cache cargo dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Build release + run: cargo build --release + + - name: Upload binary + uses: actions/upload-artifact@v4 + with: + name: imager-${{ runner.os }} + path: target/release/imager + if-no-files-found: error \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8bac997..897a208 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target -/albmums \ No newline at end of file +/albums/ +.idea \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..fa82d82 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3417 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "actix-codec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-core", + "futures-sink", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-files" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8c4f30e3272d7c345f88ae0aac3848507ef5ba871f9cc2a41c8085a0f0523b" +dependencies = [ + "actix-http", + "actix-service", + "actix-utils", + "actix-web", + "bitflags 2.13.1", + "bytes", + "derive_more", + "futures-core", + "http-range", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "v_htmlescape", +] + +[[package]] +name = "actix-http" +version = "3.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e2faa3e7418ed780cca54829d32782a4008a077230f67457caa063415e99c2" +dependencies = [ + "actix-codec", + "actix-rt", + "actix-service", + "actix-utils", + "base64 0.22.1", + "bitflags 2.13.1", + "brotli", + "bytes", + "bytestring", + "derive_more", + "encoding_rs", + "flate2", + "foldhash", + "futures-core", + "h2", + "http", + "httparse", + "httpdate", + "itoa", + "language-tags", + "local-channel", + "mime", + "percent-encoding", + "pin-project-lite", + "rand 0.10.2", + "sha1 0.11.0", + "smallvec", + "tokio", + "tokio-util", + "tracing", + "zstd", +] + +[[package]] +name = "actix-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "actix-router" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14f8c75c51892f18d9c46150c5ac7beb81c95f78c8b83a634d49f4ca32551fe7" +dependencies = [ + "bytestring", + "cfg-if", + "http", + "regex", + "regex-lite", + "serde", + "tracing", +] + +[[package]] +name = "actix-rt" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" +dependencies = [ + "futures-core", + "tokio", +] + +[[package]] +name = "actix-server" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "futures-util", + "mio", + "socket2 0.5.10", + "tokio", + "tracing", +] + +[[package]] +name = "actix-service" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "actix-utils" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" +dependencies = [ + "local-waker", + "pin-project-lite", +] + +[[package]] +name = "actix-web" +version = "4.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df09e2d9239703dd64056359c920c7f3fba6535ec61a0059e0f44e095ffe02b4" +dependencies = [ + "actix-codec", + "actix-http", + "actix-macros", + "actix-router", + "actix-rt", + "actix-server", + "actix-service", + "actix-utils", + "actix-web-codegen", + "bytes", + "bytestring", + "cfg-if", + "cookie", + "derive_more", + "encoding_rs", + "foldhash", + "futures-core", + "futures-util", + "impl-more", + "itoa", + "language-tags", + "log", + "mime", + "once_cell", + "pin-project-lite", + "regex", + "regex-lite", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "socket2 0.6.5", + "time", + "tracing", + "url", +] + +[[package]] +name = "actix-web-codegen" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f591380e2e68490b5dfaf1dd1aa0ebe78d84ba7067078512b4ea6e4492d622b8" +dependencies = [ + "actix-router", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[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 = "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.119", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[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 = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[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-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "bytestring" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86566c496f2f47d9b8147a4c8b02ffdb69c919fe0c2b2e7195d22cbba0e635c9" +dependencies = [ + "bytes", +] + +[[package]] +name = "cc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "config" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23738e11972c7643e4ec947840fc463b6a571afcd3e735bdfce7d03c7a784aca" +dependencies = [ + "async-trait", + "json5", + "lazy_static", + "nom", + "pathdiff", + "ron", + "rust-ini", + "serde", + "serde_json", + "toml", + "yaml-rust", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[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.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" +dependencies = [ + "percent-encoding", + "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-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[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-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[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 = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[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", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dlv-list" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257" + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] + +[[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 = "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 = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[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 = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[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 = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + +[[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 = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http", + "hyper", + "rustls", + "tokio", + "tokio-rustls", +] + +[[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", +] + +[[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.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.24.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "gif", + "jpeg-decoder", + "num-traits", + "png", +] + +[[package]] +name = "imager" +version = "1.0.0" +dependencies = [ + "actix-files", + "actix-web", + "chrono", + "config", + "image", + "lazy_static", + "mimalloc", + "rand 0.8.7", + "regex", + "reqwest", + "serde", + "serde_json", + "sqlx", + "thiserror", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "impl-more" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a84fd5aa25fae5c0f4a33d9cac2ca017fc622cbd089be2229993514990f870" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libmimalloc-sys" +version = "0.1.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" +dependencies = [ + "cc", +] + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "bitflags 2.13.1", + "libc", + "plain", + "redox_syscall 0.9.0", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4e226dcd58b4be396f7bd3c20da8fdee2911400705297ba7d2d7cc2c30f716" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "local-channel" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" +dependencies = [ + "futures-core", + "futures-sink", + "local-waker", +] + +[[package]] +name = "local-waker" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mimalloc" +version = "0.1.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" +dependencies = [ + "libmimalloc-sys", +] + +[[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.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[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 = "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-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.7", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ordered-multimap" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccd746e37177e1711c20dd619a1620f34f5c8b569c53590a72dedd5344d8924a" +dependencies = [ + "dlv-list", + "hashbrown 0.12.3", +] + +[[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 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" +dependencies = [ + "pest", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[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 = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +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 = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +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 = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.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_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.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_syscall" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-rustls", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-rustls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", + "winreg", +] + +[[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 = "ron" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88073939a61e5b7680558e6be56b419e208420c2adb92be54921fa6b72283f1a" +dependencies = [ + "base64 0.13.1", + "bitflags 1.3.2", + "serde", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust-ini" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6d5f2436026b4f6e79dc829837d467cc7e9a55ee40e750d716713540715a2df" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki", + "sct", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[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_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.119", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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 = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[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 = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[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 = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlformat" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bba3a93db0cc4f7bdece8bb09e77e2e785c20bfebf79eb8340ed80708048790" +dependencies = [ + "nom", + "unicode_categories", +] + +[[package]] +name = "sqlx" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9a2ccff1a000a5a59cd33da541d9f2fdcd9e6e8229cc200565942bff36d0aaa" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24ba59a9342a3d9bab6c56c118be528b27c9b60e490080e9711a04dccac83ef6" +dependencies = [ + "ahash 0.8.12", + "atoi", + "byteorder", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-channel", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashlink", + "hex", + "indexmap", + "log", + "memchr", + "once_cell", + "paste", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlformat", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea40e2345eb2faa9e1e5e326db8c34711317d2b5e08d0d5741619048a803127" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 1.0.109", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5833ef53aaa16d860e92123292f1f6a3d53c34ba8b1969f152ef1a7bb803f3c8" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-sqlite", + "syn 1.0.109", + "tempfile", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418" +dependencies = [ + "atoi", + "base64 0.21.7", + "bitflags 2.13.1", + "byteorder", + "bytes", + "crc", + "digest 0.10.7", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.7", + "rsa", + "serde", + "sha1 0.10.7", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e" +dependencies = [ + "atoi", + "base64 0.21.7", + "bitflags 2.13.1", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.7", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b244ef0a8414da0bed4bb1910426e890b19e5e9bccc27ada6b797d05c55ae0aa" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "tracing", + "url", + "urlencoding", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[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 = "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.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[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.119", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[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.119", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +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.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.5", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "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", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[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.119", +] + +[[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-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[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.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[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.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[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 = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "v_htmlescape" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" + +[[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_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[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", + "windows-result", + "windows-strings", +] + +[[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.119", +] + +[[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.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[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.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[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", + "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_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[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_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[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_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[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_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/migrations/002_metadata.sql b/migrations/002_metadata.sql new file mode 100644 index 0000000..c6cf83f --- /dev/null +++ b/migrations/002_metadata.sql @@ -0,0 +1,52 @@ +-- 相册元数据扩展字段 +ALTER TABLE albums ADD COLUMN person_name_original TEXT; +ALTER TABLE albums ADD COLUMN person_name_cleaned TEXT; +ALTER TABLE albums ADD COLUMN tags TEXT; -- JSON: [tag, ...] +ALTER TABLE albums ADD COLUMN all_words TEXT; -- JSON: [[word, pos], ...] +ALTER TABLE albums ADD COLUMN raw_title TEXT; +ALTER TABLE albums ADD COLUMN processed_at TEXT; + +-- 标签索引表(倒排索引) +CREATE TABLE IF NOT EXISTS album_tags ( + album_id TEXT NOT NULL, + tag TEXT NOT NULL, + PRIMARY KEY (album_id, tag), + FOREIGN KEY (album_id) REFERENCES albums(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_album_tags_tag ON album_tags(tag); + +-- 人物表 +CREATE TABLE IF NOT EXISTS persons ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + display_name TEXT, + total_albums INTEGER DEFAULT 0, + total_images INTEGER DEFAULT 0, + original_names TEXT, -- JSON: [raw_name, ...] + created_at TEXT DEFAULT (datetime('now', 'localtime')), + updated_at TEXT DEFAULT (datetime('now', 'localtime')) +); +CREATE INDEX IF NOT EXISTS idx_persons_name ON persons(name); + +-- 别名表 +CREATE TABLE IF NOT EXISTS person_aliases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + person_id INTEGER NOT NULL, + alias_type TEXT NOT NULL, -- prefix / english / nickname / chinese + alias_name TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now', 'localtime')), + FOREIGN KEY (person_id) REFERENCES persons(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_aliases_name ON person_aliases(alias_name); +CREATE INDEX IF NOT EXISTS idx_aliases_person ON person_aliases(person_id); + +-- 人物-相册关联表 +CREATE TABLE IF NOT EXISTS person_albums ( + person_id INTEGER NOT NULL, + album_id TEXT NOT NULL, + PRIMARY KEY (person_id, album_id), + FOREIGN KEY (person_id) REFERENCES persons(id), + FOREIGN KEY (album_id) REFERENCES albums(id) +); + +CREATE INDEX IF NOT EXISTS idx_albums_cleaned ON albums(person_name_cleaned); \ No newline at end of file diff --git a/scripts/import_test_data.py b/scripts/import_test_data.py new file mode 100644 index 0000000..46f572b --- /dev/null +++ b/scripts/import_test_data.py @@ -0,0 +1,284 @@ +""" +从 192.168.1.100:4500 API 拉取指定数量相册作为测试数据(多线程并行版) +导入到 Rust imager 项目的 SQLite 数据库和 albums 目录 +""" +import requests +import sqlite3 +import os +import sys +import time +import uuid +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime + +API_BASE = "http://192.168.1.100:4500" +DB_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "imager.db") +ALBUMS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "albums") + +# 全局 DB 写锁 +db_lock = threading.Lock() +# 进度计数器 +progress_lock = threading.Lock() +completed_count = [0] +total_count = [0] + +# 线程本地 Session(复用连接) +thread_local = threading.local() + + +def get_session(): + if not hasattr(thread_local, "session"): + thread_local.session = requests.Session() + thread_local.session.timeout = 60 + return thread_local.session + + +def fetch_albums(limit=100): + """获取相册列表""" + url = f"{API_BASE}/api/albums" + print(f"[1/4] 获取相册列表: {url}") + resp = requests.get(url, timeout=30) + data = resp.json() + + if isinstance(data, dict) and "albums" in data: + albums = data["albums"] + elif isinstance(data, list): + albums = data + else: + print(f"未知响应格式: {type(data)}") + return [] + + albums = albums[:limit] + print(f" 获取到 {len(albums)} 个相册") + return albums + + +def fetch_album_images(album_id): + """获取相册的图片列表""" + url = f"{API_BASE}/api/album/{album_id}" + try: + resp = requests.get(url, timeout=30) + data = resp.json() + if isinstance(data, dict) and "images" in data: + return data["images"] + elif isinstance(data, list): + return data + except Exception as e: + print(f" 获取图片列表失败 {album_id}: {e}") + return [] + + +def download_single_image(img): + """下载单张图片(线程安全)""" + img_id = img.get("id", str(uuid.uuid4())) + mime = img.get("mime_type", "image/jpeg") + ext = "jpg" + if mime and "/" in mime: + ext = mime.split("/")[-1] + + album_id = str(img.get("_album_id", "")) + filename = f"{img_id}.{ext}" + db_filename = f"{album_id}/{filename}" # 数据库路径:{album_id}/{img_id}.ext + save_path = os.path.join(ALBUMS_DIR, album_id, filename) + + # 跳过已下载 + if os.path.exists(save_path) and os.path.getsize(save_path) > 0: + file_size = os.path.getsize(save_path) + width, height = get_image_dimensions(save_path) + return (img_id, db_filename, file_size, width, height, mime) + + url = f"{API_BASE}/api/image/{img_id}" + try: + session = get_session() + resp = session.get(url, timeout=60) + if resp.status_code == 200: + os.makedirs(os.path.dirname(save_path), exist_ok=True) + with open(save_path, "wb") as f: + f.write(resp.content) + file_size = len(resp.content) + width, height = get_image_dimensions(save_path) + return (img_id, db_filename, file_size, width, height, resp.headers.get("content-type", mime)) + except Exception as e: + pass + return None + + +def get_image_dimensions(path): + """获取图片尺寸(二进制头解析)""" + try: + with open(path, "rb") as f: + header = f.read(32) + if header[:2] == b"\xff\xd8": + f.seek(2) + while True: + chunk = f.read(4) + if len(chunk) < 4: + break + if chunk[0] != 0xFF: + break + seg_type = chunk[1] + if seg_type == 0xDA: + break + seg_len = int.from_bytes(chunk[2:4], "big") + if seg_type in (0xC0, 0xC1, 0xC2): + data = f.read(seg_len - 2) + if len(data) >= 5: + return int.from_bytes(data[1:3], "big"), int.from_bytes(data[3:5], "big") + f.seek(seg_len - 2, 1) + return 0, 0 + elif header[:8] == b"\x89PNG\r\n\x1a\n": + return int.from_bytes(header[16:20], "big"), int.from_bytes(header[20:24], "big") + elif header[:4] == b"RIFF" and header[8:12] == b"WEBP": + return int.from_bytes(header[24:26], "little"), int.from_bytes(header[26:28], "little") + elif header[:6] in (b"GIF87a", b"GIF89a"): + return int.from_bytes(header[6:8], "little"), int.from_bytes(header[8:10], "little") + return 0, 0 + except Exception: + return 0, 0 + + +def sync_album_to_db(conn, album, images_info): + """将相册和图片写入数据库(调用方持有 db_lock)""" + cursor = conn.cursor() + now = datetime.utcnow().isoformat() + "Z" + album_id = album["id"] + title = album.get("title", "Untitled") + + cursor.execute( + """INSERT OR REPLACE INTO albums + (id, title, document, description, cover_image_id, image_count, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + ( + album_id, title, album_id, + album.get("description"), + images_info[0][0] if images_info else None, + len(images_info), + album.get("created_at", now), + now, + ), + ) + + for img_id, filename, file_size, width, height, mime_type in images_info: + cursor.execute( + """INSERT OR REPLACE INTO images + (id, album_id, filename, original_url, title, description, file_size, width, height, mime_type, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (img_id, album_id, filename, f"{API_BASE}/api/image/{img_id}", + f"图片 {img_id[:8]}", None, file_size, width, height, mime_type or "image/jpeg", now), + ) + + conn.commit() + + +def process_album(album, conn, dl_workers): + """处理单个相册:下载所有图片并写入数据库""" + aid = album["id"] + title = album.get("title", "Untitled")[:60] + + # 获取图片列表 + images = fetch_album_images(aid) + if not images: + with progress_lock: + completed_count[0] += 1 + print(f"[{completed_count[0]}/{total_count[0]}] {title} — 无图片,跳过") + return 0 + + # 为每个图片打上 album_id 标记 + for img in images: + img["_album_id"] = aid + + # 并行下载 + downloaded = [] + with ThreadPoolExecutor(max_workers=dl_workers) as pool: + futures = {pool.submit(download_single_image, img): img for img in images} + for future in as_completed(futures): + result = future.result() + if result: + downloaded.append(result) + + if not downloaded: + with progress_lock: + completed_count[0] += 1 + print(f"[{completed_count[0]}/{total_count[0]}] {title} — 0 张下载成功") + return 0 + + # 写入数据库 + with db_lock: + sync_album_to_db(conn, album, downloaded) + + n = len(downloaded) + with progress_lock: + completed_count[0] += 1 + print(f"[{completed_count[0]}/{total_count[0]}] {title} — {n} 张图片") + + return n + + +def main(): + total = int(sys.argv[1]) if len(sys.argv) > 1 else 100 + dl_workers = int(sys.argv[2]) if len(sys.argv) > 2 else 8 + album_workers = int(sys.argv[3]) if len(sys.argv) > 3 else 4 + + print(f"=== 从 API 导入 {total} 个相册(并行下载: {dl_workers}线程/相册, 并行相册: {album_workers})===\n") + + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + os.makedirs(ALBUMS_DIR, exist_ok=True) + + conn = sqlite3.connect(DB_PATH, check_same_thread=False) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + conn.execute("PRAGMA synchronous=NORMAL") + + # 获取已有相册 + existing = set() + for row in conn.execute("SELECT id FROM albums"): + existing.add(row[0]) + print(f" 数据库中已有 {len(existing)} 个相册") + + # 获取相册列表 + albums = fetch_albums(total) + if not albums: + print("未获取到相册,退出") + return + + new_albums = [a for a in albums if a["id"] not in existing] + skipped = len(albums) - len(new_albums) + if skipped > 0: + print(f" 跳过 {skipped} 个已存在的相册") + print(f" 需要导入 {len(new_albums)} 个新相册\n") + + if not new_albums: + print("没有新相册需要导入") + conn.close() + return + + total_count[0] = len(new_albums) + total_images = 0 + t0 = time.time() + + # 并行处理相册 + with ThreadPoolExecutor(max_workers=album_workers) as pool: + futures = {pool.submit(process_album, album, conn, dl_workers): album for album in new_albums} + for future in as_completed(futures): + n = future.result() + total_images += n + + elapsed = time.time() - t0 + + # 重建 image_count + print(f"\n[4/4] 更新相册图片计数...") + conn.execute("""UPDATE albums SET image_count = (SELECT COUNT(*) FROM images WHERE images.album_id = albums.id)""") + conn.commit() + conn.close() + + print(f"\n=== 导入完成 ===") + print(f" 成功导入: {len(new_albums)} 个相册") + print(f" 总图片数: {total_images} 张") + print(f" 耗时: {elapsed:.1f} 秒") + print(f" 数据库: {DB_PATH}") + print(f" 图片目录: {ALBUMS_DIR}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/bin/migrate.rs b/src/bin/migrate.rs new file mode 100644 index 0000000..cea3935 --- /dev/null +++ b/src/bin/migrate.rs @@ -0,0 +1,872 @@ +//! 数据库迁移 CLI 工具 +//! +//! 功能: +//! 1. 从老数据库 (persons.db) 读取数据 +//! 2. 对 title 进行解析 (人名提取、标签提取) +//! 3. 生成新的数据库 +//! 4. 验证搜索等功能 +//! +//! 用法: +//! cargo run --bin migrate -- --old-db ../yolov8_face_recognition/persons.db --new-db ./data/imager.db +//! cargo run --bin migrate -- --old-db ../yolov8_face_recognition/persons.db --new-db ./data/imager.db --reparse +//! cargo run --bin migrate -- --verify --new-db ./data/imager.db + +use serde::{Deserialize, Serialize}; +use sqlx::{SqlitePool, sqlite::SqlitePoolOptions, Row}; +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::time::Instant; + +// ============================================================ +// 标题解析模块 (Rust 原生实现) +// ============================================================ + +mod title_parser { + use regex::Regex; + use std::collections::HashSet; + use lazy_static::lazy_static; + + /// 模特称呼前缀 + const MODEL_PREFIXES: &[&str] = &[ + "美女模特", "性感女神", "新人模特", "新人嫩模", "新人妹子", + "气质女神", "气质美女", "性感美女", "宅男女神", "丽质女神", + "丽质美女", "红粉佳人", "美乳女神", "清纯女神", "性感嫩模", + "性感御姐", "美女", "模特", "女神", "御姐", "嫩模", + "新晋女神", "新晋美模", "日本", "日本人气", + ]; + + /// 停用词 + const STOP_WORDS: &[&str] = &[ + "photos", "photo", "phot", "images", "image", "video", "videos", + "picture", "pictures", "pic", "gif", "gifs", "mp4", "webp", + "cosplay", "cos", "set", "part", "collection", "series", + "special", "vol", "no", "no.", "ver", "version", "edition", + "new", "xxx", "preview", "teaser", "sample", + "写真", "套图", "旅拍", "系列", "诱惑", "性感", "美女", + "模特", "女神", "写真集", "首套", "版", "主题", "风格", + "the", "a", "an", "in", "on", "at", "of", "to", "for", + "with", "by", "and", "or", "but", "from", "is", "are", + "this", "that", "these", "those", "it", "its", + "的", "了", "在", "是", "我", "有", "和", "就", "不", "人", "都", "一", + "一个", "上", "也", "很", "到", "说", "要", "去", "你", "会", "着", + "没有", "看", "好", "自己", "这", "他", "她", "它", "们", "那", "些", + ]; + + /// 工作室排除名 + const EXCLUDED_NAMES: &[&str] = &[ + "xiuren", "秀人", "秀人网", "youmi", "尤蜜", "尤蜜荟", + "xingyan", "星颜", "魅妍社", "mistar", + "mygirl", "美媛馆", "imiss", "爱蜜社", "huayang", "花漾", + "bololi", "波萝社", "feilin", "嗲囡囡", "qingdouke", "青豆客", + "toutiao", "头条女神", "xiaoyu", "语画界", "youmei", "尤美", + "ugirls", "尤果", "尤果网", "mfstar", "模范学院", + "tgod", "推女神", "dgkirl", "御女郎", "candy", "糖果画报", + "wanibooks", "bluecake", "pure media", "puremedia", + ]; + + lazy_static! { + static ref RE_PHOTOS: Regex = Regex::new( + r"(?i)\s*\d+\s*photos?\s*(?:and\s*\d+\s*videos?)?\s*$" + ).unwrap(); + static ref RE_COSPLAY: Regex = Regex::new( + r"(?i)^(.+?)\s+cosplay\s+(.+?)(?:\s*[–—\-]\s*(.+))?$" + ).unwrap(); + static ref RE_COSPLAY_BY: Regex = Regex::new( + r"(?i)^(.+?)\s+cosplay\s+by\s+(.+?)(?:\s*[–—\-]\s*(.+))?$" + ).unwrap(); + static ref RE_BRACKET: Regex = Regex::new(r"^[\s]*[\[【][^\]]*[\]】][\s]*").unwrap(); + static ref RE_NUMBER_PREFIX: Regex = Regex::new( + r"(?i)^(?:No\.?\s*\d+|VOL\.?\s*\d+)\s+" + ).unwrap(); + static ref RE_SPLIT: Regex = Regex::new(r"\s*[–—\-]\s*").unwrap(); + static ref RE_PAREN: Regex = Regex::new(r"[((]([^))]*)[))]").unwrap(); + static ref RE_TRAILING: Regex = Regex::new(r"[\s.,;:,。;:\-–—]+$").unwrap(); + static ref RE_EN_SUFFIX: Regex = Regex::new(r"^([\u{4e00}-\u{9fff}]+)([A-Za-z].*)$").unwrap(); + static ref RE_CHINESE: Regex = Regex::new(r"[\u{4e00}-\u{9fff}]").unwrap(); + static ref RE_WORD: Regex = Regex::new(r"[a-zA-Z0-9\u{4e00}-\u{9fff}]+").unwrap(); + + static ref STOP_SET: HashSet<&'static str> = { + let mut s = HashSet::new(); + for w in STOP_WORDS { s.insert(*w); } + s + }; + + static ref EXCLUDED_LOWER: HashSet = { + let mut s = HashSet::new(); + for n in EXCLUDED_NAMES { s.insert(n.to_lowercase()); } + s + }; + } + + /// 清理标题末尾 + pub fn clean_title_suffix(title: &str) -> String { + RE_PHOTOS.replace(title, "").trim().to_string() + } + + /// 从标题提取人名 + pub fn extract_person_name(title: &str) -> Option { + let clean = clean_title_suffix(title); + if clean.is_empty() { + return None; + } + + // cosplay 格式 + if let Some(caps) = RE_COSPLAY_BY.captures(&clean) { + return Some(caps[2].trim().to_string()); + } + if let Some(caps) = RE_COSPLAY.captures(&clean) { + return Some(caps[1].trim().to_string()); + } + + // 移除工作室前缀 + let no_studio = RE_BRACKET.replace(&clean, "").trim().to_string(); + + // 移除模特称呼前缀 + let mut no_prefix = no_studio; + for prefix in MODEL_PREFIXES { + if no_prefix.starts_with(prefix) { + no_prefix = no_prefix[prefix.len()..].trim().to_string(); + break; + } + } + + // 移除编号前缀 + let no_prefix = RE_NUMBER_PREFIX.replace(&no_prefix, "").trim().to_string(); + + // 用分隔符拆分 + let parts: Vec<&str> = RE_SPLIT.splitn(&no_prefix, 2).collect(); + if parts.len() >= 2 { + let name = parts[0].trim(); + let name = RE_PHOTOS.replace(name, "").trim().to_string(); + if !name.is_empty() && name.len() >= 2 { + return Some(name); + } + } + + // 没有分隔符 + let name = RE_PHOTOS.replace(&no_prefix, "").trim().to_string(); + if name.len() >= 2 { + Some(name) + } else { + None + } + } + + /// 清洗人名 + pub fn clean_person_name(raw_name: &str) -> (String, Vec<(String, String)>) { + let mut aliases = Vec::new(); + let mut name = raw_name.to_string(); + + // 移除前缀 + for prefix in MODEL_PREFIXES { + if name.starts_with(prefix) { + aliases.push(("prefix".to_string(), prefix.to_string())); + name = name[prefix.len()..].trim().to_string(); + break; + } + } + + // 移除括号内容 + let mut cleaned = String::new(); + let mut last_end = 0; + for cap in RE_PAREN.captures_iter(&name) { + let m = cap.get(0).unwrap(); + let content = cap.get(1).map_or("", |c| c.as_str()); + cleaned.push_str(&name[last_end..m.start()]); + last_end = m.end(); + if !content.is_empty() { + if RE_CHINESE.is_match(content) { + aliases.push(("nickname".to_string(), content.to_string())); + } else { + aliases.push(("english".to_string(), content.to_string())); + } + } + } + cleaned.push_str(&name[last_end..]); + let name = cleaned.trim().to_string(); + + // 清理末尾标点 + let name = RE_TRAILING.replace(&name, "").trim().to_string(); + + // 提取英文名别名 + if let Some(caps) = RE_EN_SUFFIX.captures(&name) { + let chinese = caps[1].to_string(); + let english = caps[2].trim().to_string(); + if !english.is_empty() { + aliases.push(("chinese".to_string(), chinese)); + aliases.push(("english".to_string(), english)); + } + } + + let name = if name.is_empty() { raw_name.to_string() } else { name }; + (name, aliases) + } + + /// 从标题提取标签 + pub fn extract_tags(title: &str, person_name: Option<&str>) -> Vec<(String, String)> { + let mut clean = clean_title_suffix(title); + + // 移除人名 + if let Some(name) = person_name { + clean = clean.replace(name, " "); + for part in name.split_whitespace() { + clean = clean.replace(part, " "); + } + } + + // 移除工作室前缀 + clean = RE_BRACKET.replace(&clean, "").trim().to_string(); + + let mut tags = Vec::new(); + let mut seen = HashSet::new(); + + for cap in RE_WORD.find_iter(&clean) { + let word = cap.as_str().trim(); + if word.len() < 2 { continue; } + let word_lower = word.to_lowercase(); + if STOP_SET.contains(word_lower.as_str()) { continue; } + if word.chars().all(|c| c.is_ascii_digit() || c == '.') { continue; } + if !seen.insert(word_lower.clone()) { continue; } + + let tag_type = if RE_CHINESE.is_match(word) { "chinese" } else { "english" }; + tags.push((word.to_lowercase(), tag_type.to_string())); + } + + tags + } + + /// 判断是否为排除名 + pub fn is_excluded(name: &str) -> bool { + EXCLUDED_LOWER.contains(&name.to_lowercase()) + } +} + +// ============================================================ +// 数据模型 +// ============================================================ + +#[derive(Debug, Default)] +struct MigrationStats { + persons_imported: i64, + persons_skipped: i64, + persons_created: i64, + aliases_imported: i64, + albums_processed: i64, + albums_missing: i64, + tags_imported: i64, + person_albums_created: i64, +} + +#[derive(Debug, Clone)] +struct OldAlbum { + id: String, + title: String, + image_count: i64, + person_name_original: Option, + person_name_cleaned: Option, + tags: Option, + all_words: Option, +} + +#[derive(Debug, Clone)] +struct OldPerson { + id: i64, + name: String, + display_name: Option, + total_albums: i64, + total_images: i64, + original_names: Option, +} + +// ============================================================ +// 迁移器 +// ============================================================ + +struct Migrator { + old_pool: SqlitePool, + new_pool: SqlitePool, + stats: MigrationStats, +} + +impl Migrator { + async fn new(old_db_path: &str, new_db_path: &str) -> Result { + if !Path::new(old_db_path).exists() { + return Err(format!("老数据库不存在: {}", old_db_path)); + } + + // 创建新数据库目录 + if let Some(parent) = Path::new(new_db_path).parent() { + if !parent.as_os_str().is_empty() && !parent.exists() { + std::fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {}", e))?; + } + } + + println!("[连接] 老数据库: {}", old_db_path); + let old_pool = SqlitePoolOptions::new() + .max_connections(1) + .connect(&format!("sqlite:{}", old_db_path)) + .await + .map_err(|e| format!("连接老数据库失败: {}", e))?; + + println!("[连接] 新数据库: {}", new_db_path); + let new_pool = SqlitePoolOptions::new() + .max_connections(5) + .connect(&format!("sqlite:{}", new_db_path)) + .await + .map_err(|e| format!("连接新数据库失败: {}", e))?; + + // SQLite 性能优化 + for p in [&new_pool] { + sqlx::query("PRAGMA journal_mode=WAL;").execute(p).await.ok(); + sqlx::query("PRAGMA synchronous=NORMAL;").execute(p).await.ok(); + sqlx::query("PRAGMA foreign_keys=ON;").execute(p).await.ok(); + } + + Ok(Self { old_pool, new_pool, stats: MigrationStats::default() }) + } + + async fn init_new_schema(&self) -> Result<(), String> { + println!("[Schema] 初始化新数据库表结构..."); + + let schema_sql = include_str!("../migrations/001_initial.sql"); + for stmt in schema_sql.split(';') { + let stmt = stmt.trim(); + if !stmt.is_empty() { + sqlx::query(stmt).execute(&self.new_pool).await + .map_err(|e| format!("执行 Schema 失败: {}", e))?; + } + } + + let schema_sql2 = include_str!("../migrations/002_metadata.sql"); + for stmt in schema_sql2.split(';') { + let stmt = stmt.trim(); + if !stmt.is_empty() { + sqlx::query(stmt).execute(&self.new_pool).await + .map_err(|e| format!("执行 Schema2 失败: {}", e))?; + } + } + + println!("[Schema] 表结构初始化完成"); + Ok(()) + } + + async fn read_old_albums(&self) -> Result, String> { + println!("[读取] 从老数据库读取相册..."); + let rows = sqlx::query( + "SELECT id, title, image_count, person_name_original, person_name_cleaned, tags, all_words + FROM albums ORDER BY image_count DESC" + ).fetch_all(&self.old_pool).await.map_err(|e| format!("读取相册失败: {}", e))?; + + let albums: Vec = rows.iter().map(|r| OldAlbum { + id: r.get("id"), + title: r.get("title"), + image_count: r.get::, _>("image_count").unwrap_or(0), + person_name_original: r.get("person_name_original"), + person_name_cleaned: r.get("person_name_cleaned"), + tags: r.get("tags"), + all_words: r.get("all_words"), + }).collect(); + + println!("[读取] 共读取 {} 个相册", albums.len()); + Ok(albums) + } + + async fn read_old_persons(&self) -> Result, String> { + println!("[读取] 从老数据库读取人物..."); + let rows = sqlx::query( + "SELECT id, name, display_name, total_albums, total_images, original_names + FROM persons ORDER BY total_albums DESC" + ).fetch_all(&self.old_pool).await.map_err(|e| format!("读取人物失败: {}", e))?; + + let persons: Vec = rows.iter().map(|r| OldPerson { + id: r.get("id"), + name: r.get("name"), + display_name: r.get("display_name"), + total_albums: r.get::, _>("total_albums").unwrap_or(0), + total_images: r.get::, _>("total_images").unwrap_or(0), + original_names: r.get("original_names"), + }).collect(); + + println!("[读取] 共读取 {} 个人物", persons.len()); + Ok(persons) + } + + async fn read_old_aliases(&self) -> Result, String> { + let rows = sqlx::query( + "SELECT person_id, alias_type, alias_name FROM person_aliases" + ).fetch_all(&self.old_pool).await.unwrap_or_default(); + + let aliases: Vec<(i64, String, String)> = rows.iter().map(|r| ( + r.get("person_id"), r.get("alias_type"), r.get("alias_name") + )).collect(); + + println!("[读取] 共读取 {} 个别名", aliases.len()); + Ok(aliases) + } + + async fn read_old_person_albums(&self) -> Result, String> { + let rows = sqlx::query( + "SELECT person_id, album_id FROM person_albums" + ).fetch_all(&self.old_pool).await.unwrap_or_default(); + + let links: Vec<(i64, String)> = rows.iter().map(|r| ( + r.get("person_id"), r.get("album_id") + )).collect(); + + println!("[读取] 共读取 {} 个人物-相册关联", links.len()); + Ok(links) + } + + async fn migrate(&mut self, reparse: bool) -> Result<(), String> { + self.init_new_schema().await?; + + // 读取老数据 + let old_albums = self.read_old_albums().await?; + let old_persons = self.read_old_persons().await?; + let old_aliases = self.read_old_aliases().await?; + let old_links = self.read_old_person_albums().await?; + + let mut old_to_new_person: HashMap = HashMap::new(); + + // Step 1: 迁移人物 + println!("\n[迁移] 步骤 1/5: 迁移人物..."); + for p in &old_persons { + if title_parser::is_excluded(&p.name) { + self.stats.persons_skipped += 1; + continue; + } + + sqlx::query( + "INSERT OR IGNORE INTO persons (name, display_name, total_albums, total_images, original_names) + VALUES (?, ?, ?, ?, ?)" + ).bind(&p.name).bind(p.display_name.as_deref().unwrap_or(&p.name)) + .bind(p.total_albums).bind(p.total_images).bind(p.original_names.as_deref()) + .execute(&self.new_pool).await.ok(); + + let new_id: i64 = sqlx::query_scalar("SELECT id FROM persons WHERE name = ?") + .bind(&p.name).fetch_one(&self.new_pool).await.unwrap_or(0); + + if new_id > 0 { + old_to_new_person.insert(p.id, new_id); + self.stats.persons_imported += 1; + } + } + println!("[迁移] 导入 {} 人,跳过 {} 人", self.stats.persons_imported, self.stats.persons_skipped); + + // Step 2: 迁移别名 + println!("\n[迁移] 步骤 2/5: 迁移别名..."); + for (old_pid, alias_type, alias_name) in &old_aliases { + if let Some(&new_pid) = old_to_new_person.get(old_pid) { + sqlx::query( + "INSERT OR IGNORE INTO person_aliases (person_id, alias_type, alias_name) VALUES (?, ?, ?)" + ).bind(new_pid).bind(alias_type).bind(alias_name) + .execute(&self.new_pool).await.ok(); + self.stats.aliases_imported += 1; + } + } + println!("[迁移] 导入 {} 个别名", self.stats.aliases_imported); + + // Step 3: 迁移相册 + 解析title + println!( + "\n[迁移] 步骤 3/5: 迁移相册并解析title ({})...", + if reparse { "重新解析" } else { "保留原解析" } + ); + + let mut person_map: HashMap = HashMap::new(); + + for (i, album) in old_albums.iter().enumerate() { + if (i + 1) % 200 == 0 { + println!(" 处理进度: {}/{}", i + 1, old_albums.len()); + } + + let (person_orig, person_cleaned, tags, all_words) = + if reparse || album.person_name_cleaned.as_deref().unwrap_or("").is_empty() { + // 重新解析 + let person_name = title_parser::extract_person_name(&album.title); + let (cleaned, _aliases) = match &person_name { + Some(n) => { + let (c, a) = title_parser::clean_person_name(n); + if title_parser::is_excluded(&c) { + (None, a) + } else { + (Some(c), a) + } + } + None => (None, Vec::new()), + }; + + let tags_raw = title_parser::extract_tags(&album.title, person_name.as_deref()); + let tag_names: Vec = tags_raw.iter().map(|(t, _)| t.clone()).collect(); + let all_words: Vec<[String; 2]> = tags_raw.iter() + .map(|(t, tp)| [t.clone(), tp.clone()]).collect(); + + ( + person_name, + cleaned, + serde_json::to_string(&tag_names).unwrap_or_else(|_| "[]".to_string()), + serde_json::to_string(&all_words).unwrap_or_else(|_| "[]".to_string()), + ) + } else { + ( + album.person_name_original.clone(), + album.person_name_cleaned.clone(), + album.tags.clone().unwrap_or_else(|| "[]".to_string()), + album.all_words.clone().unwrap_or_else(|| "[]".to_string()), + ) + }; + + // 写入相册 + sqlx::query( + "INSERT OR REPLACE INTO albums + (id, title, image_count, person_name_original, person_name_cleaned, + tags, all_words, raw_title, processed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))" + ).bind(&album.id).bind(&album.title).bind(album.image_count) + .bind(person_orig.as_deref()).bind(person_cleaned.as_deref()) + .bind(&tags).bind(&all_words).bind(&album.title) + .execute(&self.new_pool).await.ok(); + + // 写入标签索引 + if let Ok(tag_list) = serde_json::from_str::>(&tags) { + if !tag_list.is_empty() { + for tag in &tag_list { + sqlx::query( + "INSERT OR IGNORE INTO album_tags (album_id, tag) VALUES (?, ?)" + ).bind(&album.id).bind(tag).execute(&self.new_pool).await.ok(); + } + self.stats.tags_imported += 1; + } + } + + // 关联人物 + if let Some(pc) = &person_cleaned { + if !pc.is_empty() && !title_parser::is_excluded(pc) { + let pid = if let Some(&id) = person_map.get(pc) { + id + } else { + // 查找或创建 + let existing: Option = sqlx::query_scalar( + "SELECT id FROM persons WHERE name = ?" + ).bind(pc).fetch_optional(&self.new_pool).await.unwrap_or(None); + + let id = if let Some(eid) = existing { + eid + } else { + sqlx::query("INSERT INTO persons (name, display_name) VALUES (?, ?)") + .bind(pc).bind(pc).execute(&self.new_pool).await.ok(); + self.stats.persons_created += 1; + sqlx::query_scalar::<_, i64>("SELECT last_insert_rowid()") + .fetch_one(&self.new_pool).await.unwrap_or(0) + }; + person_map.insert(pc.clone(), id); + id + }; + + if pid > 0 { + sqlx::query( + "INSERT OR IGNORE INTO person_albums (person_id, album_id) VALUES (?, ?)" + ).bind(pid).bind(&album.id).execute(&self.new_pool).await.ok(); + } + } + } + + self.stats.albums_processed += 1; + } + + println!( + "[迁移] 处理 {} 个相册,{} 个标签导入,{} 个人物从title创建", + self.stats.albums_processed, self.stats.tags_imported, self.stats.persons_created + ); + + // Step 4: 迁移人物-相册关联 + println!("\n[迁移] 步骤 4/5: 迁移人物-相册关联..."); + for (old_pid, album_id) in &old_links { + if let Some(&new_pid) = old_to_new_person.get(old_pid) { + sqlx::query( + "INSERT OR IGNORE INTO person_albums (person_id, album_id) VALUES (?, ?)" + ).bind(new_pid).bind(album_id).execute(&self.new_pool).await.ok(); + self.stats.person_albums_created += 1; + } + } + println!("[迁移] 导入 {} 个关联", self.stats.person_albums_created); + + // Step 5: 更新人物统计 + println!("\n[迁移] 步骤 5/5: 更新人物统计..."); + let person_ids: Vec = sqlx::query_scalar("SELECT id FROM persons") + .fetch_all(&self.new_pool).await.unwrap_or_default(); + + for pid in &person_ids { + sqlx::query( + "UPDATE persons SET + total_albums = (SELECT COUNT(*) FROM person_albums WHERE person_id = ?), + total_images = ( + SELECT COALESCE(SUM(a.image_count), 0) + FROM albums a JOIN person_albums pa ON a.id = pa.album_id + WHERE pa.person_id = ? + ), + updated_at = datetime('now') + WHERE id = ?" + ).bind(pid).bind(pid).bind(pid).execute(&self.new_pool).await.ok(); + } + println!("[迁移] 更新 {} 个人物统计", person_ids.len()); + + Ok(()) + } + + async fn print_stats(&self) { + let stats = self.get_new_stats().await; + println!("\n 新数据库统计:"); + println!(" 相册数: {}", stats.total_albums); + println!(" 图片数: {}", stats.total_images); + println!(" 人物数: {}", stats.total_persons); + println!(" 别名数: {}", stats.total_aliases); + println!(" 标签数: {}", stats.unique_tags); + println!(" 有标签相册: {}", stats.albums_with_tags); + println!(" 有人物相册: {}", stats.albums_with_person); + } + + async fn get_new_stats(&self) -> NewDbStats { + NewDbStats { + total_albums: sqlx::query_scalar("SELECT COUNT(*) FROM albums") + .fetch_one(&self.new_pool).await.unwrap_or(0), + total_images: sqlx::query_scalar("SELECT COALESCE(SUM(image_count), 0) FROM albums") + .fetch_one(&self.new_pool).await.unwrap_or(0), + total_persons: sqlx::query_scalar("SELECT COUNT(*) FROM persons") + .fetch_one(&self.new_pool).await.unwrap_or(0), + total_aliases: sqlx::query_scalar("SELECT COUNT(*) FROM person_aliases") + .fetch_one(&self.new_pool).await.unwrap_or(0), + unique_tags: sqlx::query_scalar("SELECT COUNT(DISTINCT tag) FROM album_tags") + .fetch_one(&self.new_pool).await.unwrap_or(0), + albums_with_tags: sqlx::query_scalar( + "SELECT COUNT(*) FROM albums WHERE tags IS NOT NULL AND tags != '[]'" + ).fetch_one(&self.new_pool).await.unwrap_or(0), + albums_with_person: sqlx::query_scalar( + "SELECT COUNT(*) FROM albums WHERE person_name_cleaned IS NOT NULL AND person_name_cleaned != ''" + ).fetch_one(&self.new_pool).await.unwrap_or(0), + } + } +} + +#[derive(Debug)] +struct NewDbStats { + total_albums: i64, + total_images: i64, + total_persons: i64, + total_aliases: i64, + unique_tags: i64, + albums_with_tags: i64, + albums_with_person: i64, +} + +// ============================================================ +// 搜索验证器 +// ============================================================ + +struct SearchVerifier { + pool: SqlitePool, +} + +impl SearchVerifier { + async fn new(db_path: &str) -> Result { + let pool = SqlitePoolOptions::new() + .max_connections(2) + .connect(&format!("sqlite:{}", db_path)) + .await + .map_err(|e| format!("连接数据库失败: {}", e))?; + Ok(Self { pool }) + } + + async fn verify_all(&self) { + println!("\n{}", "=".repeat(60)); + println!(" 🔍 搜索功能验证 (Rust 实现)"); + println!("{}", "=".repeat(60)); + + // 1. 热门人物 + println!("\n[验证1] 热门人物 (Top 10):"); + let top_persons: Vec<(String, i64, i64)> = sqlx::query( + "SELECT name, total_albums, total_images FROM persons ORDER BY total_albums DESC LIMIT 10" + ).fetch_all(&self.pool).await.unwrap_or_default() + .iter().map(|r| (r.get("name"), r.get("total_albums"), r.get("total_images"))).collect(); + + for (i, (name, albums, images)) in top_persons.iter().enumerate() { + println!(" {:2d}. {:<25} {:4} 相册, {:5} 张图片", i + 1, name, albums, images); + } + + // 2. 热门标签 + println!("\n[验证2] 热门标签 (Top 20):"); + let top_tags: Vec<(String, i64, i64)> = sqlx::query( + "SELECT tag, COUNT(*) as cnt, COUNT(DISTINCT album_id) as album_cnt + FROM album_tags GROUP BY tag ORDER BY cnt DESC LIMIT 20" + ).fetch_all(&self.pool).await.unwrap_or_default() + .iter().map(|r| (r.get("tag"), r.get::("cnt"), r.get::("album_cnt"))).collect(); + + for (i, (tag, cnt, album_cnt)) in top_tags.iter().enumerate() { + println!(" {:2d}. {:<20} 出现 {:4} 次, {:4} 个相册", i + 1, tag, cnt, album_cnt); + } + + // 3. 人物搜索 + if let Some((test_person, _, _)) = top_persons.first() { + println!("\n[验证3] 人物搜索: \"{}\"", test_person); + let like = format!("%{}%", test_person); + let results: Vec<(String, i64)> = sqlx::query( + "SELECT DISTINCT p.name, p.total_albums FROM persons p + LEFT JOIN person_aliases pa ON pa.person_id = p.id + WHERE p.name LIKE ? OR pa.alias_name LIKE ? + ORDER BY p.total_albums DESC LIMIT 5" + ).bind(&like).bind(&like).fetch_all(&self.pool).await.unwrap_or_default() + .iter().map(|r| (r.get("name"), r.get("total_albums"))).collect(); + + for (name, albums) in &results { + println!(" - {} ({} 相册)", name, albums); + } + + // 4. 人物相册 + println!("\n[验证4] 人物相册: \"{}\" (前5个)", test_person); + let person_albums: Vec<(i64, String)> = sqlx::query( + "SELECT a.image_count, a.title FROM albums a + JOIN person_albums pa ON pa.album_id = a.id + JOIN persons p ON p.id = pa.person_id + WHERE p.name = ? ORDER BY a.image_count DESC LIMIT 5" + ).bind(test_person).fetch_all(&self.pool).await.unwrap_or_default() + .iter().map(|r| (r.get("image_count"), r.get("title"))).collect(); + + for (ic, title) in &person_albums { + let short: String = title.chars().take(60).collect(); + println!(" - [{:3}张] {}", ic, short); + } + } + + // 5. 标签搜索 + if let Some((test_tag, _, _)) = top_tags.first() { + println!("\n[验证5] 标签搜索: \"{}\" (前5个)", test_tag); + let tag_albums: Vec<(i64, String)> = sqlx::query( + "SELECT a.image_count, a.title FROM albums a + JOIN album_tags t ON t.album_id = a.id + WHERE t.tag = ? ORDER BY a.image_count DESC LIMIT 5" + ).bind(test_tag).fetch_all(&self.pool).await.unwrap_or_default() + .iter().map(|r| (r.get("image_count"), r.get("title"))).collect(); + + for (ic, title) in &tag_albums { + let short: String = title.chars().take(60).collect(); + println!(" - [{:3}张] {}", ic, short); + } + } + + // 6. 标题搜索 + println!("\n[验证6] 标题搜索: \"性感\" (前5个)"); + let like = "%性感%".to_string(); + let title_albums: Vec<(i64, String)> = sqlx::query( + "SELECT image_count, title FROM albums WHERE title LIKE ? ORDER BY image_count DESC LIMIT 5" + ).bind(&like).fetch_all(&self.pool).await.unwrap_or_default() + .iter().map(|r| (r.get("image_count"), r.get("title"))).collect(); + + for (ic, title) in &title_albums { + let short: String = title.chars().take(60).collect(); + println!(" - [{:3}张] {}", ic, short); + } + + println!("\n{}", "=".repeat(60)); + println!(" ✅ 搜索功能验证完成!"); + println!("{}", "=".repeat(60)); + } +} + +// ============================================================ +// CLI 入口 +// ============================================================ + +#[derive(Debug, clap::Parser)] +#[command(name = "migrate", about = "数据库迁移工具 (Rust 实现)")] +struct Cli { + /// 老数据库路径 + #[arg(long, default_value = "../yolov8_face_recognition/persons.db")] + old_db: String, + + /// 新数据库路径 + #[arg(long, default_value = "./data/imager.db")] + new_db: String, + + /// 重新解析所有 title + #[arg(long)] + reparse: bool, + + /// 迁移后验证搜索功能 + #[arg(long)] + verify: bool, + + /// 仅验证搜索功能 (不执行迁移) + #[arg(long)] + verify_only: bool, +} + +#[tokio::main] +async fn main() -> Result<(), String> { + // 检查是否有 clap + let args: Vec = std::env::args().collect(); + + let mut old_db = "../yolov8_face_recognition/persons.db".to_string(); + let mut new_db = "./data/imager.db".to_string(); + let mut reparse = false; + let mut verify = false; + let mut verify_only = false; + + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--old-db" => { i += 1; if i < args.len() { old_db = args[i].clone(); } } + "--new-db" => { i += 1; if i < args.len() { new_db = args[i].clone(); } } + "--reparse" => reparse = true, + "--verify" => verify = true, + "--verify-only" => verify_only = true, + "--help" | "-h" => { + println!("数据库迁移工具 (Rust 实现)"); + println!(); + println!("用法: cargo run --bin migrate -- [选项]"); + println!(); + println!("选项:"); + println!(" --old-db <路径> 老数据库路径 (默认: ../yolov8_face_recognition/persons.db)"); + println!(" --new-db <路径> 新数据库路径 (默认: ./data/imager.db)"); + println!(" --reparse 重新解析所有 title"); + println!(" --verify 迁移后验证搜索功能"); + println!(" --verify-only 仅验证搜索功能 (不执行迁移)"); + println!(" --help, -h 显示帮助"); + return Ok(()); + } + _ => {} + } + i += 1; + } + + let start = Instant::now(); + + // 仅验证模式 + if verify_only { + if !Path::new(&new_db).exists() { + return Err(format!("数据库不存在: {}", new_db)); + } + let verifier = SearchVerifier::new(&new_db).await?; + verifier.verify_all().await; + return Ok(()); + } + + println!("{}", "=".repeat(60)); + println!(" 🚀 数据库迁移工具 (Rust 原生实现)"); + println!("{}", "=".repeat(60)); + + // 执行迁移 + let mut migrator = Migrator::new(&old_db, &new_db).await?; + migrator.migrate(reparse).await?; + + println!("\n{}", "=".repeat(60)); + println!(" ✅ 迁移完成!用时: {:.2?}", start.elapsed()); + println!("{}", "=".repeat(60)); + migrator.print_stats().await; + + // 验证搜索 + if verify { + let verifier = SearchVerifier::new(&new_db).await?; + verifier.verify_all().await; + } + + Ok(()) +} diff --git a/src/cache/mod.rs b/src/cache/mod.rs new file mode 100644 index 0000000..a58db5f --- /dev/null +++ b/src/cache/mod.rs @@ -0,0 +1,132 @@ +//! 缓存层 — 内存缓存管理 + +use crate::models::*; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; +use tracing::info; + +/// 相册缓存 +#[derive(Debug, Clone)] +pub struct AlbumsCache { + pub albums: Vec, + pub search_index: HashMap>, + pub last_updated: chrono::DateTime, +} + +impl AlbumsCache { + pub fn new() -> Self { + Self { albums: Vec::new(), search_index: HashMap::new(), last_updated: chrono::Utc::now() } + } + + pub fn is_expired(&self) -> bool { + (chrono::Utc::now() - self.last_updated).num_minutes() >= 30 + } + + pub fn update(&mut self, albums: Vec) { + self.search_index.clear(); + for album in &albums { + let title_lower = album.title.to_lowercase(); + for word in title_lower.split_whitespace() { + if word.len() >= 2 { + self.search_index.entry(word.to_string()).or_default().push(album.clone()); + } + } + self.search_index.entry(title_lower).or_default().push(album.clone()); + } + self.albums = albums; + self.last_updated = chrono::Utc::now(); + info!("相册缓存已更新,共 {} 个相册,{} 个搜索索引项", self.albums.len(), self.search_index.len()); + } + + pub fn search(&self, query: &str) -> Vec { + let q = query.to_lowercase().trim().to_string(); + if q.is_empty() { return Vec::new(); } + + let mut seen = std::collections::HashSet::new(); + let mut results = Vec::new(); + + // 1. 完整匹配 + if let Some(matches) = self.search_index.get(&q) { + for album in matches { + if seen.insert(album.id) { results.push(album.clone()); } + } + } + // 2. 单词匹配 + for word in q.split_whitespace() { + if word.len() >= 2 { + if let Some(matches) = self.search_index.get(word) { + for album in matches { + if seen.insert(album.id) { results.push(album.clone()); } + } + } + } + } + // 3. 模糊搜索 + if results.is_empty() { + for album in &self.albums { + let tl = album.title.to_lowercase(); + if tl.contains(&q) { + if seen.insert(album.id) { results.push(album.clone()); } + } + } + } + // 4. 部分匹配 + if results.is_empty() && q.len() >= 2 { + for album in &self.albums { + let tl = album.title.to_lowercase(); + if q.split_whitespace().any(|w| w.len() >= 2 && tl.contains(w)) { + if seen.insert(album.id) { results.push(album.clone()); } + } + } + } + results + } +} + +/// 标签缓存 +#[derive(Debug, Clone, Default)] +pub struct TagCache { + pub popular_tags: Vec, + pub last_updated: chrono::DateTime, +} + +impl TagCache { + pub fn update(&mut self, tags: Vec) { + self.popular_tags = tags; + self.last_updated = chrono::Utc::now(); + info!("标签缓存已更新,共 {} 个热门标签", self.popular_tags.len()); + } +} + +/// 人物缓存 +#[derive(Debug, Clone, Default)] +pub struct PersonCache { + pub persons: Vec, + pub last_updated: chrono::DateTime, +} + +impl PersonCache { + pub fn update(&mut self, persons: Vec) { + self.persons = persons; + self.last_updated = chrono::Utc::now(); + info!("人物缓存已更新,共 {} 人", self.persons.len()); + } +} + +/// 共享缓存 +pub struct AppCache { + pub albums: Arc>, + pub tags: Arc>, + pub persons: Arc>, +} + +impl AppCache { + pub fn new() -> Self { + Self { + albums: Arc::new(RwLock::new(AlbumsCache::new())), + tags: Arc::new(RwLock::new(TagCache::default())), + persons: Arc::new(RwLock::new(PersonCache::default())), + } + } +} \ No newline at end of file diff --git a/src/db/album.rs b/src/db/album.rs new file mode 100644 index 0000000..3e05928 --- /dev/null +++ b/src/db/album.rs @@ -0,0 +1,132 @@ +use super::Database; +use crate::models::*; +use sqlx::Row; +use uuid::Uuid; + +impl Database { + // ── 查询 ── + + pub async fn get_all_albums(&self) -> Result, sqlx::Error> { + let rows = sqlx::query( + r#"SELECT id, title, document, description, cover_image_id, image_count, created_at + FROM albums ORDER BY created_at DESC"# + ).fetch_all(&self.pool).await?; + + Ok(rows.iter().map(|r| { + let id_str = r.get::("id"); + AlbumInfo { + id: Uuid::parse_str(&id_str).unwrap_or_else(|_| Uuid::nil()), + title: r.get("title"), + document: r.get("document"), + description: r.get("description"), + cover_image_id: r.get("cover_image_id"), + image_count: r.get("image_count"), + created_at: r.get("created_at"), + } + }).collect()) + } + + pub async fn get_album_with_images(&self, album_id: Uuid) -> Result, sqlx::Error> { + let row = sqlx::query("SELECT * FROM albums WHERE id = ?") + .bind(album_id.to_string()) + .fetch_optional(&self.pool).await?; + + let Some(row) = row else { return Ok(None) }; + + let id_str = row.get::("id"); + let id = Uuid::parse_str(&id_str).unwrap_or_else(|_| Uuid::nil()); + let cover = row.get::, _>("cover_image_id") + .and_then(|s| Uuid::parse_str(&s).ok()); + + let album = Album { + id, + title: row.get("title"), + description: row.get("description"), + cover_image_id: cover, + image_count: row.get("image_count"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + person_name_cleaned: row.get("person_name_cleaned"), + tags: row.get("tags"), + }; + + let img_rows = sqlx::query("SELECT * FROM images WHERE album_id = ?") + .bind(album_id.to_string()).fetch_all(&self.pool).await?; + + let mut images = Vec::new(); + for r in &img_rows { + let img_id = Uuid::parse_str(&r.get::("id")).unwrap_or_else(|_| Uuid::nil()); + let aid = Uuid::parse_str(&r.get::("album_id")).unwrap_or_else(|_| Uuid::nil()); + images.push(super::image::Image { + id: img_id, album_id: aid, + filename: r.get("filename"), + original_url: r.get("original_url"), + title: r.get("title"), + description: r.get("description"), + file_size: r.get("file_size"), + width: r.get("width"), + height: r.get("height"), + mime_type: r.get("mime_type"), + created_at: r.get("created_at"), + }); + } + + Ok(Some(AlbumWithImages { album, images: images.clone(), image_count: images.len() as i64 })) + } + + pub async fn get_all_albums_full(&self) -> Vec { + let has_col = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM pragma_table_info('albums') WHERE name = 'person_name_original'" + ).fetch_one(&self.pool).await.unwrap_or(0); + if has_col == 0 { return Vec::new(); } + + let rows = sqlx::query( + r#"SELECT id, title, person_name_original, person_name_cleaned, tags, all_words, raw_title, processed_at, image_count + FROM albums ORDER BY created_at DESC"# + ).fetch_all(&self.pool).await.unwrap_or_default(); + + rows.iter().map(|r| AlbumMeta { + id: r.get("id"), title: r.get("title"), + person_name_original: r.get("person_name_original"), + person_name_cleaned: r.get("person_name_cleaned"), + tags: r.get("tags"), all_words: r.get("all_words"), + raw_title: r.get("raw_title"), processed_at: r.get("processed_at"), + image_count: r.get("image_count"), + }).collect() + } + + // ── 写入 ── + + pub async fn create_album(&self, album: CreateAlbum) -> Result { + let id = Uuid::new_v4(); + let now = chrono::Utc::now().to_rfc3339(); + sqlx::query( + "INSERT INTO albums (id, title, document, description, image_count, created_at, updated_at) VALUES (?, ?, ?, ?, 0, ?, ?)" + ).bind(id.to_string()).bind(album.title).bind(album.document).bind(album.description).bind(&now).bind(&now) + .execute(&self.pool).await?; + Ok(id) + } + + pub async fn delete_album(&self, album_id: Uuid) -> Result<(), sqlx::Error> { + sqlx::query("DELETE FROM albums WHERE id = ?").bind(album_id.to_string()).execute(&self.pool).await?; + Ok(()) + } + + pub async fn update_album_cover(&self, album_id: Uuid, image_id: Uuid) -> Result<(), sqlx::Error> { + sqlx::query("UPDATE albums SET cover_image_id = ? WHERE id = ?") + .bind(image_id.to_string()).bind(album_id.to_string()).execute(&self.pool).await?; + Ok(()) + } + + pub async fn update_album_image_count(&self, album_id: Uuid) -> Result<(), sqlx::Error> { + sqlx::query( + r#"UPDATE albums SET image_count = (SELECT COUNT(*) FROM images WHERE album_id = ?), updated_at = datetime('now') WHERE id = ?"# + ).bind(album_id.to_string()).bind(album_id.to_string()).execute(&self.pool).await?; + Ok(()) + } + + pub async fn get_album_image_count(&self, album_id: Uuid) -> Result { + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM images WHERE album_id = ?") + .bind(album_id.to_string()).fetch_one(&self.pool).await + } +} \ No newline at end of file diff --git a/src/db/connection.rs b/src/db/connection.rs new file mode 100644 index 0000000..e164da2 --- /dev/null +++ b/src/db/connection.rs @@ -0,0 +1,63 @@ +use sqlx::{SqlitePool, sqlite::SqlitePoolOptions}; +use crate::config::Config; + +pub struct Database { + pub pool: SqlitePool, + pub config: Config, +} + +impl Database { + pub async fn new(config: &Config) -> Result { + let url = &config.database.url; + let file_path = if url.starts_with("sqlite:") { &url[7..] } else { url }; + + if let Some(parent) = std::path::Path::new(file_path).parent() { + if !parent.exists() { + std::fs::create_dir_all(parent)?; + } + } + if !std::path::Path::new(file_path).exists() { + std::fs::File::create(file_path)?; + println!("创建新的数据库文件: {}", file_path); + } + + let pool = SqlitePoolOptions::new() + .max_connections(10) + .min_connections(2) + .acquire_timeout(std::time::Duration::from_secs(5)) + .idle_timeout(std::time::Duration::from_secs(300)) + .max_lifetime(std::time::Duration::from_secs(1800)) + .connect(url) + .await?; + + // SQLite 性能优化 + sqlx::query("PRAGMA journal_mode=WAL;").execute(&pool).await?; + sqlx::query("PRAGMA wal_autocheckpoint=4000;").execute(&pool).await?; + sqlx::query("PRAGMA synchronous=NORMAL;").execute(&pool).await?; + sqlx::query("PRAGMA cache_size=-64000;").execute(&pool).await?; + sqlx::query("PRAGMA mmap_size=268435456;").execute(&pool).await?; + sqlx::query("PRAGMA temp_store=MEMORY;").execute(&pool).await?; + sqlx::query("PRAGMA foreign_keys=ON;").execute(&pool).await?; + + sqlx::migrate!("./migrations").run(&pool).await?; + + let db = Database { pool, config: config.clone() }; + db.ensure_image_count_field().await?; + Ok(db) + } + + /// 确保 image_count 字段存在 + async fn ensure_image_count_field(&self) -> Result<(), sqlx::Error> { + let has_col = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM pragma_table_info('albums') WHERE name = 'image_count'" + ).fetch_one(&self.pool).await.unwrap_or(0); + + if has_col == 0 { + sqlx::query("ALTER TABLE albums ADD COLUMN image_count INTEGER DEFAULT 0").execute(&self.pool).await?; + println!("✅ image_count 字段已添加"); + } else { + println!("ℹ️ image_count字段已存在,跳过初始化"); + } + Ok(()) + } +} \ No newline at end of file diff --git a/src/db/favorite.rs b/src/db/favorite.rs new file mode 100644 index 0000000..9c5efe7 --- /dev/null +++ b/src/db/favorite.rs @@ -0,0 +1,67 @@ +use super::Database; +use crate::models::*; +use sqlx::Row; +use uuid::Uuid; + +impl Database { + pub async fn get_user_favorites(&self, user_id: &str) -> Result, sqlx::Error> { + let rows = sqlx::query( + r#"SELECT f.id, f.user_id, f.image_id, f.album_id, f.created_at, + i.album_id as img_album_id, i.filename, i.original_url, i.title, i.description, + i.file_size, i.width, i.height, i.mime_type, i.created_at as img_created_at, + a.title as album_title + FROM favorites f + JOIN images i ON f.image_id = i.id + JOIN albums a ON f.album_id = a.id + WHERE f.user_id = ? ORDER BY f.created_at DESC"# + ).bind(user_id).fetch_all(&self.pool).await?; + + Ok(rows.iter().map(|r| { + let fav_id = Uuid::parse_str(&r.get::("id")).unwrap_or_else(|_| Uuid::nil()); + let img_id = Uuid::parse_str(&r.get::("image_id")).unwrap_or_else(|_| Uuid::nil()); + let aid = Uuid::parse_str(&r.get::("album_id")).unwrap_or_else(|_| Uuid::nil()); + let img_aid = Uuid::parse_str(&r.get::("img_album_id")).unwrap_or_else(|_| Uuid::nil()); + FavoriteWithImage { + favorite: Favorite { id: fav_id, user_id: r.get("user_id"), image_id: img_id, album_id: aid, created_at: r.get("created_at") }, + image: crate::models::image::Image { + id: img_id, album_id: img_aid, + filename: r.get("filename"), original_url: r.get("original_url"), + title: r.get("title"), description: r.get("description"), + file_size: r.get("file_size"), width: r.get("width"), + height: r.get("height"), mime_type: r.get("mime_type"), + created_at: r.get("img_created_at"), + }, + album_title: r.get("album_title"), + } + }).collect()) + } + + pub async fn get_favorite_status(&self, user_id: &str, image_id: Uuid) -> Result { + let row = sqlx::query("SELECT id FROM favorites WHERE user_id = ? AND image_id = ?") + .bind(user_id).bind(image_id.to_string()).fetch_optional(&self.pool).await?; + Ok(FavoriteStatus { + is_favorited: row.is_some(), + favorite_id: row.map(|r| Uuid::parse_str(&r.get::("id")).unwrap_or_else(|_| Uuid::nil())), + }) + } + + pub async fn add_favorite(&self, user_id: &str, fav: CreateFavorite) -> Result { + let id = Uuid::new_v4(); + let now = chrono::Utc::now().to_rfc3339(); + sqlx::query("INSERT INTO favorites (id, user_id, image_id, album_id, created_at) VALUES (?, ?, ?, ?, ?)") + .bind(id.to_string()).bind(user_id).bind(fav.image_id.to_string()).bind(fav.album_id.to_string()).bind(&now) + .execute(&self.pool).await?; + Ok(id) + } + + pub async fn remove_favorite(&self, favorite_id: Uuid) -> Result<(), sqlx::Error> { + sqlx::query("DELETE FROM favorites WHERE id = ?").bind(favorite_id.to_string()).execute(&self.pool).await?; + Ok(()) + } + + pub async fn remove_favorite_by_image(&self, user_id: &str, image_id: Uuid) -> Result<(), sqlx::Error> { + sqlx::query("DELETE FROM favorites WHERE user_id = ? AND image_id = ?") + .bind(user_id).bind(image_id.to_string()).execute(&self.pool).await?; + Ok(()) + } +} \ No newline at end of file diff --git a/src/db/image.rs b/src/db/image.rs new file mode 100644 index 0000000..90e1269 --- /dev/null +++ b/src/db/image.rs @@ -0,0 +1,74 @@ +use super::Database; +use crate::models::*; +use sqlx::Row; +use uuid::Uuid; + +/// 重新导出用于 cross-module access +pub use crate::models::image::Image; + +impl Database { + pub async fn add_images(&self, images: Vec) -> Result, sqlx::Error> { + if images.is_empty() { return Ok(Vec::new()); } + let now = chrono::Utc::now().to_rfc3339(); + let mut ids = Vec::new(); + let first_album_id = images.first().map(|img| img.album_id); + + for img in &images { + let id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO images (id, album_id, filename, original_url, title, description, file_size, width, height, mime_type, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + ).bind(id.to_string()).bind(img.album_id.to_string()).bind(&img.filename) + .bind(&img.original_url).bind(&img.title).bind(&img.description) + .bind(img.file_size).bind(img.width).bind(img.height).bind(&img.mime_type).bind(&now) + .execute(&self.pool).await?; + ids.push(id); + } + + if let Some(aid) = first_album_id { + self.update_album_image_count(aid).await?; + let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM images WHERE album_id = ?") + .bind(aid.to_string()).fetch_one(&self.pool).await?; + if count == images.len() as i64 { + self.update_album_cover(aid, ids[0]).await?; + } + } + Ok(ids) + } + + pub async fn get_image(&self, image_id: Uuid) -> Result, sqlx::Error> { + let row = sqlx::query("SELECT * FROM images WHERE id = ?") + .bind(image_id.to_string()).fetch_optional(&self.pool).await?; + let Some(r) = row else { return Ok(None) }; + Ok(Some(Image { + id: image_id, + album_id: Uuid::parse_str(&r.get::("album_id")).unwrap_or_else(|_| Uuid::nil()), + filename: r.get("filename"), + original_url: r.get("original_url"), + title: r.get("title"), + description: r.get("description"), + file_size: r.get("file_size"), + width: r.get("width"), + height: r.get("height"), + mime_type: r.get("mime_type"), + created_at: r.get("created_at"), + })) + } + + pub async fn get_unprocessed_images(&self, limit: i64) -> Result, sqlx::Error> { + let rows = sqlx::query("SELECT * FROM images WHERE file_size > 0 LIMIT ?") + .bind(limit).fetch_all(&self.pool).await?; + Ok(rows.iter().map(|r| Image { + id: Uuid::parse_str(&r.get::("id")).unwrap_or_else(|_| Uuid::nil()), + album_id: Uuid::parse_str(&r.get::("album_id")).unwrap_or_else(|_| Uuid::nil()), + filename: r.get("filename"), + original_url: r.get("original_url"), + title: r.get("title"), + description: r.get("description"), + file_size: r.get("file_size"), + width: r.get("width"), + height: r.get("height"), + mime_type: r.get("mime_type"), + created_at: r.get("created_at"), + }).collect()) + } +} \ No newline at end of file diff --git a/src/db/metadata.rs b/src/db/metadata.rs new file mode 100644 index 0000000..3c48ea8 --- /dev/null +++ b/src/db/metadata.rs @@ -0,0 +1,203 @@ +use super::Database; +use crate::models::*; +use sqlx::Row; +use uuid::Uuid; + +impl Database { + // ── 人员查询 ── + + pub async fn get_all_person_names(&self) -> Vec<(String, i64)> { + sqlx::query("SELECT p.name, p.total_albums FROM persons p ORDER BY p.total_albums DESC") + .fetch_all(&self.pool).await.unwrap_or_default() + .iter().map(|r| (r.get::("name"), r.get::("total_albums"))).collect() + } + + pub async fn get_all_aliases(&self) -> Vec<(String, String)> { + sqlx::query("SELECT a.alias_name, p.name as person_name FROM person_aliases a JOIN persons p ON a.person_id = p.id") + .fetch_all(&self.pool).await.unwrap_or_default() + .iter().map(|r| (r.get::("alias_name"), r.get::("person_name"))).collect() + } + + pub async fn get_all_persons_with_counts(&self) -> Vec<(i64, String, i64, i64)> { + sqlx::query("SELECT id, name, total_albums, total_images FROM persons ORDER BY total_albums DESC") + .fetch_all(&self.pool).await.unwrap_or_default() + .iter().map(|r| (r.get::("id"), r.get::("name"), r.get::("total_albums"), r.get::("total_images"))).collect() + } + + pub async fn get_person_by_name(&self, name: &str) -> Option { + let r = sqlx::query("SELECT * FROM persons WHERE name = ?").bind(name).fetch_optional(&self.pool).await.ok()??; + Some(Person { + id: r.get("id"), name: r.get("name"), display_name: r.get("display_name"), + total_albums: r.get("total_albums"), total_images: r.get("total_images"), + original_names: r.get("original_names"), created_at: r.get("created_at"), updated_at: r.get("updated_at"), + }) + } + + pub async fn get_person_albums(&self, person_id: i64) -> Vec { + sqlx::query( + r#"SELECT a.id, a.title, a.document, a.description, a.cover_image_id, a.image_count, a.created_at + FROM albums a JOIN person_albums pa ON a.id = pa.album_id WHERE pa.person_id = ? ORDER BY a.created_at DESC"# + ).bind(person_id).fetch_all(&self.pool).await.unwrap_or_default() + .iter().map(|r| AlbumInfo { + id: Uuid::parse_str(&r.get::("id")).unwrap_or_else(|_| Uuid::nil()), + title: r.get("title"), document: r.get("document"), + description: r.get("description"), cover_image_id: r.get("cover_image_id"), + image_count: r.get("image_count"), created_at: r.get("created_at"), + }).collect() + } + + pub async fn get_person_aliases(&self, person_id: i64) -> Vec { + sqlx::query("SELECT * FROM person_aliases WHERE person_id = ?").bind(person_id) + .fetch_all(&self.pool).await.unwrap_or_default() + .iter().map(|r| PersonAlias { + id: r.get("id"), person_id: r.get("person_id"), + alias_type: r.get("alias_type"), alias_name: r.get("alias_name"), + created_at: r.get("created_at"), + }).collect() + } + + pub async fn search_persons(&self, query: &str) -> Vec { + let pattern = format!("%{}%", query); + let rows = sqlx::query( + r#"SELECT DISTINCT p.id, p.name, p.display_name, p.total_albums, p.total_images + FROM persons p LEFT JOIN person_aliases a ON p.id = a.person_id + WHERE p.name LIKE ? OR a.alias_name LIKE ? ORDER BY p.total_albums DESC"# + ).bind(&pattern).bind(&pattern).fetch_all(&self.pool).await.unwrap_or_default(); + + let mut results = Vec::new(); + for r in &rows { + let pid = r.get::("id"); + let aliases = self.get_person_aliases(pid).await; + results.push(PersonWithCounts { + id: pid, name: r.get("name"), display_name: r.get("display_name"), + total_albums: r.get("total_albums"), total_images: r.get("total_images"), + aliases: aliases.into_iter().map(|a| a.alias_name).collect(), + }); + } + results + } + + pub async fn get_all_persons_with_aliases(&self) -> Vec { + let rows = sqlx::query("SELECT id, name, display_name, total_albums, total_images FROM persons ORDER BY total_albums DESC") + .fetch_all(&self.pool).await.unwrap_or_default(); + let mut results = Vec::new(); + for r in &rows { + let pid = r.get::("id"); + let aliases = self.get_person_aliases(pid).await; + results.push(PersonWithCounts { + id: pid, name: r.get("name"), display_name: r.get("display_name"), + total_albums: r.get("total_albums"), total_images: r.get("total_images"), + aliases: aliases.into_iter().map(|a| a.alias_name).collect(), + }); + } + results + } + + // ── 标签查询 ── + + pub async fn get_popular_tags(&self, limit: i64) -> Vec { + sqlx::query( + r#"SELECT tag, COUNT(*) as cnt, COUNT(DISTINCT album_id) as album_cnt + FROM album_tags GROUP BY tag ORDER BY cnt DESC LIMIT ?"# + ).bind(limit).fetch_all(&self.pool).await.unwrap_or_default() + .iter().map(|r| TagStat { + tag: r.get("tag"), count: r.get("cnt"), album_count: r.get("album_cnt"), + }).collect() + } + + pub async fn get_tag_co_occurrences(&self, tag: &str, limit: i64) -> Vec { + let rows = sqlx::query( + r#"SELECT t2.tag as co_tag, COUNT(*) as cnt + FROM album_tags t1 JOIN album_tags t2 ON t1.album_id = t2.album_id + WHERE t1.tag = ? AND t2.tag != ? GROUP BY t2.tag ORDER BY cnt DESC LIMIT ?"# + ).bind(tag).bind(tag).bind(limit).fetch_all(&self.pool).await.unwrap_or_default(); + + let total = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM album_tags WHERE tag = ?") + .bind(tag).fetch_one(&self.pool).await.unwrap_or(1); + rows.iter().map(|r| { + let cnt: i64 = r.get("cnt"); + TagCoOccurrence { + tag: tag.to_string(), co_tag: r.get("co_tag"), + count: cnt, score: cnt as f64 / total as f64, + } + }).collect() + } + + pub async fn search_albums_by_tag(&self, tag: &str) -> Vec { + sqlx::query( + r#"SELECT a.id, a.title, a.document, a.description, a.cover_image_id, a.image_count, a.created_at + FROM albums a JOIN album_tags t ON a.id = t.album_id WHERE t.tag = ? ORDER BY a.created_at DESC"# + ).bind(tag).fetch_all(&self.pool).await.unwrap_or_default() + .iter().map(|r| AlbumInfo { + id: Uuid::parse_str(&r.get::("id")).unwrap_or_else(|_| Uuid::nil()), + title: r.get("title"), document: r.get("document"), + description: r.get("description"), cover_image_id: r.get("cover_image_id"), + image_count: r.get("image_count"), created_at: r.get("created_at"), + }).collect() + } + + // ── 统计 ── + + pub async fn get_metadata_stats(&self) -> MetadataStats { + MetadataStats { + total_persons: sqlx::query_scalar("SELECT COUNT(*) FROM persons").fetch_one(&self.pool).await.unwrap_or(0), + total_aliases: sqlx::query_scalar("SELECT COUNT(*) FROM person_aliases").fetch_one(&self.pool).await.unwrap_or(0), + total_albums_with_person: sqlx::query_scalar("SELECT COUNT(*) FROM albums WHERE person_name_original IS NOT NULL AND person_name_original != ''").fetch_one(&self.pool).await.unwrap_or(0), + total_albums_without_person: sqlx::query_scalar("SELECT COUNT(*) FROM albums WHERE person_name_original IS NULL OR person_name_original = ''").fetch_one(&self.pool).await.unwrap_or(0), + total_tags: sqlx::query_scalar("SELECT COUNT(*) FROM album_tags").fetch_one(&self.pool).await.unwrap_or(0), + } + } + + // ── 写入 ── + + pub async fn insert_person(&self, name: &str, display_name: Option<&str>) -> i64 { + if let Some(p) = self.get_person_by_name(name).await { return p.id; } + sqlx::query("INSERT OR IGNORE INTO persons (name, display_name) VALUES (?, ?)") + .bind(name).bind(display_name).execute(&self.pool).await.ok(); + self.get_person_by_name(name).await.map(|p| p.id).unwrap_or(0) + } + + pub async fn insert_person_alias(&self, person_id: i64, alias_type: &str, alias_name: &str) { + sqlx::query("INSERT OR IGNORE INTO person_aliases (person_id, alias_type, alias_name) VALUES (?, ?, ?)") + .bind(person_id).bind(alias_type).bind(alias_name).execute(&self.pool).await.ok(); + } + + pub async fn insert_person_album(&self, person_id: i64, album_id: &str) { + sqlx::query("INSERT OR IGNORE INTO person_albums (person_id, album_id) VALUES (?, ?)") + .bind(person_id).bind(album_id).execute(&self.pool).await.ok(); + } + + pub async fn delete_person_albums(&self, person_id: i64) { + sqlx::query("DELETE FROM person_albums WHERE person_id = ?").bind(person_id).execute(&self.pool).await.ok(); + } + + pub async fn update_album_person(&self, album_id: &str, person_name_original: &str, person_name_cleaned: &str) { + sqlx::query("UPDATE albums SET person_name_original = ?, person_name_cleaned = ?, processed_at = datetime('now') WHERE id = ?") + .bind(person_name_original).bind(person_name_cleaned).bind(album_id).execute(&self.pool).await.ok(); + } + + pub async fn update_album_tags(&self, album_id: &str, tags_json: &str, all_words_json: &str) { + sqlx::query("UPDATE albums SET tags = ?, all_words = ?, processed_at = datetime('now') WHERE id = ?") + .bind(tags_json).bind(all_words_json).bind(album_id).execute(&self.pool).await.ok(); + sqlx::query("DELETE FROM album_tags WHERE album_id = ?").bind(album_id).execute(&self.pool).await.ok(); + if let Ok(tags) = serde_json::from_str::>(tags_json) { + for tag in &tags { + sqlx::query("INSERT OR IGNORE INTO album_tags (album_id, tag) VALUES (?, ?)") + .bind(album_id).bind(tag).execute(&self.pool).await.ok(); + } + } + } + + pub async fn update_person_counts(&self, person_id: i64) { + sqlx::query( + r#"UPDATE persons SET total_albums = (SELECT COUNT(*) FROM person_albums WHERE person_id = ?), + total_images = (SELECT COALESCE(SUM(a.image_count), 0) FROM albums a JOIN person_albums pa ON a.id = pa.album_id WHERE pa.person_id = ?), + updated_at = datetime('now') WHERE id = ?"# + ).bind(person_id).bind(person_id).bind(person_id).execute(&self.pool).await.ok(); + } + + /// 获取所有相册 ID 集合(用于迁移) + pub async fn get_all_album_ids(&self) -> Vec { + sqlx::query_scalar::<_, String>("SELECT id FROM albums").fetch_all(&self.pool).await.unwrap_or_default() + } +} \ No newline at end of file diff --git a/src/db/migration.rs b/src/db/migration.rs new file mode 100644 index 0000000..69c9660 --- /dev/null +++ b/src/db/migration.rs @@ -0,0 +1,161 @@ +//! 从老数据库(Python 项目 persons.db)迁移元数据到新数据库 +//! +//! 迁移策略: +//! 1. 读取老 DB 的 persons/aliases/albums 数据 +//! 2. 按 album_id 对齐新 DB 中的相册 +//! 3. 批量插入人员、别名、标签、关联 + +use super::Database; +use sqlx::sqlite::SqlitePoolOptions; +use sqlx::Row; + +/// 迁移结果统计 +#[derive(Debug, Default)] +pub struct MigrationStats { + pub persons_imported: usize, + pub persons_skipped: usize, + pub aliases_imported: usize, + pub albums_updated: usize, + pub albums_missing: usize, + pub tags_imported: usize, + pub person_albums_created: usize, +} + +impl Database { + /// 从老数据库迁移元数据 + pub async fn migrate_from_old_db(&self, old_db_path: &str) -> Result { + if !std::path::Path::new(old_db_path).exists() { + return Err(format!("老数据库不存在: {}", old_db_path)); + } + + println!("[迁移] 打开老数据库: {}", old_db_path); + let old_pool = SqlitePoolOptions::new() + .max_connections(1) + .connect(&format!("sqlite:{}", old_db_path)) + .await + .map_err(|e| format!("连接老数据库失败: {}", e))?; + + // 先获取新 DB 中所有相册 ID + let new_album_ids: std::collections::HashSet = self.get_all_album_ids().await.into_iter().collect(); + println!("[迁移] 新数据库中有 {} 个相册", new_album_ids.len()); + + let mut stats = MigrationStats::default(); + + // ── 1. 迁移人员 ── + println!("[迁移] 步骤 1/4: 迁移人员..."); + let old_persons: Vec<(i64, String, Option, i64, i64, Option)> = sqlx::query( + "SELECT id, name, display_name, total_albums, total_images, original_names FROM persons" + ).fetch_all(&old_pool).await.map_err(|e| format!("读取老人员失败: {}", e))? + .iter().map(|r| ( + r.get("id"), r.get("name"), r.get("display_name"), + r.get("total_albums"), r.get("total_images"), r.get("original_names"), + )).collect(); + + let mut old_to_new_person: std::collections::HashMap = std::collections::HashMap::new(); + + for (old_pid, name, display_name, _albums, _images, original_names) in &old_persons { + let new_pid = self.insert_person(name, display_name.as_deref()).await; + if new_pid > 0 { + old_to_new_person.insert(*old_pid, new_pid); + // 更新 original_names + if let Some(on) = original_names { + sqlx::query("UPDATE persons SET original_names = ? WHERE id = ?") + .bind(on).bind(new_pid).execute(&self.pool).await.ok(); + } + stats.persons_imported += 1; + } else { + stats.persons_skipped += 1; + } + } + println!("[迁移] 导入 {} 人,跳过 {} 人", stats.persons_imported, stats.persons_skipped); + + // ── 2. 迁移别名 ── + println!("[迁移] 步骤 2/4: 迁移别名..."); + let old_aliases: Vec<(i64, String, String)> = sqlx::query( + "SELECT person_id, alias_type, alias_name FROM person_aliases" + ).fetch_all(&old_pool).await.unwrap_or_default() + .iter().map(|r| (r.get("person_id"), r.get("alias_type"), r.get("alias_name"))).collect(); + + for (old_pid, alias_type, alias_name) in &old_aliases { + if let Some(&new_pid) = old_to_new_person.get(old_pid) { + self.insert_person_alias(new_pid, alias_type, alias_name).await; + stats.aliases_imported += 1; + } + } + println!("[迁移] 导入 {} 个别名", stats.aliases_imported); + + // ── 3. 迁移相册人员标签 ── + println!("[迁移] 步骤 3/4: 迁移相册人员+标签..."); + let old_albums: Vec<(String, Option, Option, Option, Option, Option)> = sqlx::query( + "SELECT id, title, person_name_original, person_name_cleaned, tags, all_words FROM albums" + ).fetch_all(&old_pool).await.unwrap_or_default() + .iter().map(|r| ( + r.get("id"), r.get("title"), r.get("person_name_original"), + r.get("person_name_cleaned"), r.get("tags"), r.get("all_words"), + )).collect(); + + for (album_id, _title, person_orig, person_cleaned, tags, all_words) in &old_albums { + if !new_album_ids.contains(album_id) { + stats.albums_missing += 1; + continue; + } + + // 更新人员信息 + if let (Some(po), Some(pc)) = (person_orig, person_cleaned) { + if !po.is_empty() { + self.update_album_person(album_id, po, pc).await; + stats.albums_updated += 1; + } + } + + // 更新标签 + if let Some(t) = tags { + if t != "[]" && !t.is_empty() { + let aw = all_words.clone().unwrap_or_else(|| "[]".to_string()); + self.update_album_tags(album_id, t, &aw).await; + stats.tags_imported += 1; + } + } + } + println!("[迁移] 更新 {} 个相册,{} 个不在新库中,{} 个标签导入", + stats.albums_updated, stats.albums_missing, stats.tags_imported); + + // ── 4. 迁移人物-相册关联 ── + println!("[迁移] 步骤 4/4: 迁移人物-相册关联..."); + let old_person_albums: Vec<(i64, String)> = sqlx::query( + "SELECT person_id, album_id FROM person_albums" + ).fetch_all(&old_pool).await.unwrap_or_default() + .iter().map(|r| (r.get("person_id"), r.get("album_id"))).collect(); + + for (old_pid, album_id) in &old_person_albums { + if let Some(&new_pid) = old_to_new_person.get(old_pid) { + if new_album_ids.contains(album_id) { + self.insert_person_album(new_pid, album_id).await; + stats.person_albums_created += 1; + } + } + } + println!("[迁移] 创建 {} 个关联", stats.person_albums_created); + + // 更新所有人员的计数 + println!("[迁移] 更新人员统计..."); + for &new_pid in old_to_new_person.values() { + self.update_person_counts(new_pid).await; + } + + println!("[迁移] ✅ 完成!"); + Ok(stats) + } + + /// 获取迁移统计摘要 + pub fn migration_summary(&self, stats: &MigrationStats) -> String { + format!( + "迁移完成:\n - 人员: {} 导入, {} 跳过\n - 别名: {} 导入\n - 相册: {} 更新, {} 缺失\n - 标签: {} 导入\n - 关联: {} 创建", + stats.persons_imported, stats.persons_skipped, + stats.aliases_imported, + stats.albums_updated, stats.albums_missing, + stats.tags_imported, + stats.person_albums_created, + ) + } +} \ No newline at end of file diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100644 index 0000000..937d9e3 --- /dev/null +++ b/src/db/mod.rs @@ -0,0 +1,15 @@ +//! 数据库操作层 — 按领域拆分 + +mod connection; +pub mod album; +pub mod image; +pub mod favorite; +pub mod metadata; +pub mod migration; + +pub use connection::Database; +pub use album::*; +pub use image::*; +pub use favorite::*; +pub use metadata::*; +pub use migration::MigrationStats; \ No newline at end of file diff --git a/src/engine/crawler.rs b/src/engine/crawler.rs new file mode 100644 index 0000000..ee15a8d --- /dev/null +++ b/src/engine/crawler.rs @@ -0,0 +1,176 @@ +//! API 数据获取 +//! 从 http://192.168.1.100:4500 获取相册和图片数据 +//! 对应 Python 的 fetch_from_api.py + +use serde::Deserialize; +use std::time::Duration; + +/// 从 API 获取的相册信息 +#[derive(Debug, Clone, Deserialize)] +pub struct ApiAlbum { + pub id: String, + pub title: String, + #[serde(default)] + pub image_count: i64, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub created_at: Option, +} + +/// 从 API 获取的图片详情 +#[derive(Debug, Clone, Deserialize)] +pub struct ApiImage { + pub id: String, + #[serde(default)] + pub filename: Option, + #[serde(default)] + pub original_url: Option, + #[serde(default)] + pub url: Option, + #[serde(default)] + pub width: Option, + #[serde(default)] + pub height: Option, + #[serde(default)] + pub file_size: Option, + #[serde(default)] + pub mime_type: Option, +} + +/// 相册详情(含图片列表) +#[derive(Debug, Clone, Deserialize)] +pub struct ApiAlbumDetail { + #[serde(default)] + pub images: Vec, + #[serde(default)] + pub id: Option, + #[serde(default)] + pub title: Option, +} + +/// API 数据获取器 +#[derive(Debug, Clone)] +pub struct Crawler { + api_base: String, + client: reqwest::Client, +} + +impl Crawler { + /// 创建 Crawler 实例 + pub fn new(api_base: &str) -> Self { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .user_agent("imager-rs/1.0") + .build() + .expect("创建 HTTP 客户端失败"); + Self { + api_base: api_base.trim_end_matches('/').to_string(), + client, + } + } + + /// 获取所有相册列表 + /// GET /api/albums + pub async fn get_albums(&self) -> Result, Box> { + let url = format!("{}/api/albums", self.api_base); + let resp = self.client.get(&url).send().await?; + let data: serde_json::Value = resp.json().await?; + + let albums: Vec = if let Some(arr) = data.as_array() { + serde_json::from_value(data)? + } else if let Some(arr) = data.get("albums").and_then(|v: &serde_json::Value| v.as_array()) { + serde_json::from_value(serde_json::Value::Array(arr.clone()))? + } else { + Vec::new() + }; + + Ok(albums) + } + + /// 获取相册详情(含图片列表) + /// GET /api/album/{id} + pub async fn get_album_detail(&self, album_id: &str) -> Result> { + let url = format!("{}/api/album/{}", self.api_base, album_id); + let resp = self.client.get(&url).send().await?; + let data: serde_json::Value = resp.json().await?; + + let detail = if let Some(images) = data.get("images").and_then(|v: &serde_json::Value| v.as_array()) { + ApiAlbumDetail { + images: serde_json::from_value(serde_json::Value::Array(images.clone()))?, + id: data.get("id").and_then(|v: &serde_json::Value| v.as_str()).map(|s| s.to_string()), + title: data.get("title").and_then(|v: &serde_json::Value| v.as_str()).map(|s| s.to_string()), + } + } else if let Some(_arr) = data.as_array() { + ApiAlbumDetail { + images: serde_json::from_value(data)?, + id: None, + title: None, + } + } else { + ApiAlbumDetail { + images: Vec::new(), + id: None, + title: None, + } + }; + + Ok(detail) + } + + /// 获取图片二进制数据 + /// GET /api/image/{id} + pub async fn get_image_data(&self, image_id: &str) -> Result, Box> { + let url = format!("{}/api/image/{}", self.api_base, image_id); + let resp = self.client.get(&url).send().await?; + let bytes = resp.bytes().await?; + Ok(bytes.to_vec()) + } + + /// 获取图片的下载 URL(不下载,只返回 URL 字符串) + pub fn get_image_url(&self, image_id: &str) -> String { + format!("{}/api/image/{}", self.api_base, image_id) + } + + /// 获取多个相册的详情(批量) + pub async fn get_album_details_batch( + &self, + album_ids: &[String], + ) -> Result, Box> { + let mut results = Vec::new(); + for aid in album_ids { + match self.get_album_detail(aid).await { + Ok(detail) => results.push((aid.clone(), detail)), + Err(e) => { + eprintln!("[Crawler] 获取相册 {} 详情失败: {}", aid, e); + } + } + } + Ok(results) + } + + /// 获取所有相册详情(含图片) + pub async fn fetch_all_albums_with_images( + &self, + ) -> Result, Box> { + let albums = self.get_albums().await?; + let mut results = Vec::new(); + + for album in &albums { + match self.get_album_detail(&album.id).await { + Ok(detail) => results.push((album.clone(), detail)), + Err(e) => { + eprintln!("[Crawler] 获取相册 {} 详情失败: {}", album.id, e); + } + } + } + + Ok(results) + } + + /// 获取指定相册的图片列表(仅返回 id 列表) + pub async fn get_album_image_ids(&self, album_id: &str) -> Result, Box> { + let detail = self.get_album_detail(album_id).await?; + Ok(detail.images.into_iter().map(|img| img.id).collect()) + } +} \ No newline at end of file diff --git a/src/engine/mod.rs b/src/engine/mod.rs new file mode 100644 index 0000000..b1714a9 --- /dev/null +++ b/src/engine/mod.rs @@ -0,0 +1,15 @@ +//! 核心引擎层 — 标题解析、人名匹配、标签提取、推荐计算 + +pub mod title_parser; +pub mod person_merger; +pub mod tag_extractor; +pub mod recommend; +pub mod pipeline; +pub mod crawler; + +pub use title_parser::*; +pub use person_merger::*; +pub use tag_extractor::*; +pub use recommend::*; +pub use pipeline::*; +pub use crawler::*; \ No newline at end of file diff --git a/src/engine/person_merger.rs b/src/engine/person_merger.rs new file mode 100644 index 0000000..16e8cb8 --- /dev/null +++ b/src/engine/person_merger.rs @@ -0,0 +1,383 @@ +//! 人名匹配与合并 +//! 对应 Python 的 match_person_from_title.py + merge_persons_by_core.py +//! 所有函数均为 async,因为底层 Database 操作是异步的 + +use crate::db::Database; +use crate::engine::title_parser::{extract_cn, is_excluded_name}; +use std::collections::{HashMap, HashSet}; + +/// 匹配模式 +#[derive(Debug, Clone)] +pub struct Pattern { + pub text: String, + pub person_name: String, + pub source: String, // "name" | "alias" + pub length: usize, +} + +/// 匹配结果 +#[derive(Debug, Clone)] +pub struct MatchResult { + pub matched: bool, + pub method: String, + pub person: Option, + pub matched_text: Option, + pub all_persons: Vec, + pub multi_person: bool, +} + +/// 深度匹配结果 +#[derive(Debug, Clone)] +pub struct DeepMatchResult { + pub matched: bool, + pub method: String, // "jieba_extract" | "exact_name_match" | "alias_fuzzy_match" + pub person: Option, + pub matched_text: Option, + pub all_persons: Vec, + pub multi_person: bool, +} + +// ── Pass1: 收集模式 ── + +/// 收集所有人名和别名,构建匹配模式列表 +/// 按长度降序排列,确保长名优先匹配 +pub async fn collect_person_patterns(db: &Database) -> Vec { + let mut patterns = Vec::new(); + + // 1. 收集所有人名 + let rows = db.get_all_person_names().await; + for (name, _albums) in &rows { + if is_excluded_name(name) { + continue; + } + patterns.push(Pattern { + text: name.clone(), + person_name: name.clone(), + source: "name".to_string(), + length: name.len(), + }); + } + + // 2. 收集所有别名 + let aliases = db.get_all_aliases().await; + for (alias_name, person_name) in &aliases { + if is_excluded_name(person_name) || is_excluded_name(alias_name) { + continue; + } + if alias_name != person_name { + patterns.push(Pattern { + text: alias_name.clone(), + person_name: person_name.clone(), + source: "alias".to_string(), + length: alias_name.len(), + }); + } + } + + // 3. 去重 + let mut best_map: HashMap = HashMap::new(); + for p in patterns { + let key = p.text.to_lowercase(); + best_map.entry(key).or_insert(p); + } + let mut patterns: Vec = best_map.into_values().collect(); + + // 4. 按长度降序排列 + patterns.sort_by(|a, b| b.length.cmp(&a.length)); + + patterns +} + +/// Pass1: 简单子串匹配 +pub fn match_title(title: &str, patterns: &[Pattern]) -> Option<(String, String, String)> { + if title.is_empty() { + return None; + } + + for p in patterns { + if p.text.len() < 2 { + continue; + } + if title.contains(&p.text) { + return Some((p.text.clone(), p.person_name.clone(), p.source.clone())); + } + } + + None +} + +/// Pass2: 深度匹配(多重策略) +pub fn deep_match_title( + title: &str, + known_names: &HashSet, + known_aliases: &HashSet, +) -> DeepMatchResult { + let mut result = DeepMatchResult { + matched: false, + method: String::new(), + person: None, + matched_text: None, + all_persons: Vec::new(), + multi_person: false, + }; + + if title.is_empty() { + return result; + } + + let title_clean = title.trim(); + + // 策略1: 用正则提取人名 + if let Some(extracted) = crate::engine::title_parser::extract_person_name_from_title(title_clean) { + if known_names.contains(&extracted) { + result.matched = true; + result.method = "jieba_extract".to_string(); + result.person = Some(extracted.clone()); + result.matched_text = Some(extracted); + return result; + } + } + + // 策略2: 标题中精确子串匹配已知人名(长名优先) + let mut found: Vec = Vec::new(); + let mut sorted_names: Vec<&String> = known_names.iter().collect(); + sorted_names.sort_by(|a, b| b.len().cmp(&a.len())); + + for name in &sorted_names { + if name.len() < 2 { + continue; + } + if title_clean.contains(name.as_str()) { + found.push(name.to_string()); + } + } + + if !found.is_empty() { + found.sort_by(|a, b| b.len().cmp(&a.len())); + let best = found[0].clone(); + result.matched = true; + result.method = "exact_name_match".to_string(); + result.person = Some(best.clone()); + result.matched_text = Some(best); + result.all_persons = found.clone(); + result.multi_person = found.len() > 1; + return result; + } + + // 策略3: 别名模糊匹配 + let title_lower = title_clean.to_lowercase(); + let mut sorted_aliases: Vec<&String> = known_aliases.iter().collect(); + sorted_aliases.sort_by(|a, b| b.len().cmp(&a.len())); + + for alias in &sorted_aliases { + if alias.len() < 2 { + continue; + } + if title_lower.contains(alias.to_lowercase().as_str()) { + result.matched = true; + result.method = "alias_fuzzy_match".to_string(); + result.person = Some(alias.to_string()); + result.matched_text = Some(alias.to_string()); + return result; + } + } + + result +} + +/// 收集所有已知人名和别名 +pub async fn collect_known_names(db: &Database) -> (HashSet, HashSet) { + let mut known_names = HashSet::new(); + let mut known_aliases = HashSet::new(); + + for (name, _) in db.get_all_person_names().await { + known_names.insert(name); + } + for (alias_name, _) in db.get_all_aliases().await { + known_aliases.insert(alias_name); + } + + (known_names, known_aliases) +} + +/// 检测含多个人名的相册 +pub async fn detect_multi_person_titles( + db: &Database, + known_names: Option<&HashSet>, +) -> Vec<(String, String, Vec)> { + let names = match known_names { + Some(n) => n.clone(), + None => collect_known_names(db).await.0, + }; + + let albums = db.get_all_albums_full().await; + let mut multi_person = Vec::new(); + + for album in &albums { + let title = album.title.as_deref().unwrap_or(""); + if title.is_empty() { + continue; + } + + let mut found: Vec = Vec::new(); + let mut sorted_names: Vec<&String> = names.iter().collect(); + sorted_names.sort_by(|a, b| b.len().cmp(&a.len())); + + for name in &sorted_names { + if name.len() < 2 { + continue; + } + if title.contains(name.as_str()) { + found.push(name.to_string()); + } + } + + if found.len() > 1 { + multi_person.push(( + album.id.clone(), + title.to_string(), + found, + )); + } + } + + multi_person +} + +// ── 中文核心合并分析 ── + +/// 分析所有人名的中文核心分组 +/// 返回: {core: [(id, name, albums, images), ...]} +pub async fn analyze_groups(db: &Database) -> HashMap> { + let persons = db.get_all_persons_with_counts().await; + let mut groups: HashMap> = HashMap::new(); + + for (pid, name, albums, images) in persons { + if let Some(cn) = extract_cn(&name) { + if cn.len() >= 2 { + groups.entry(cn).or_default().push((pid, name, albums, images)); + } + } + } + + for items in groups.values_mut() { + items.sort_by(|a, b| b.2.cmp(&a.2)); + } + + groups.retain(|_, v| v.len() >= 2); + + groups +} + +/// 统计匹配分析 +#[derive(Debug, Clone)] +pub struct MatchStats { + pub total_albums: usize, + pub matched: usize, + pub unmatched: usize, + pub already_set: usize, + pub changed: usize, + pub by_source: HashMap, + pub name_frequency: HashMap, +} + +/// 执行 Pass1 匹配分析 +pub async fn analyze_matches(db: &Database, patterns: &[Pattern]) -> MatchStats { + let albums = db.get_all_albums_full().await; + let mut stats = MatchStats { + total_albums: albums.len(), + matched: 0, + unmatched: 0, + already_set: 0, + changed: 0, + by_source: HashMap::new(), + name_frequency: HashMap::new(), + }; + + for album in &albums { + let title = album.title.as_deref().unwrap_or(""); + let current = album.person_name_original.as_deref().unwrap_or(""); + + if let Some((text, person, source)) = match_title(title, patterns) { + *stats.by_source.entry(source).or_insert(0) += 1; + *stats.name_frequency.entry(person.clone()).or_insert(0) += 1; + + if current.is_empty() { + stats.matched += 1; + } else if current != person { + stats.changed += 1; + } else { + stats.already_set += 1; + } + } else { + stats.unmatched += 1; + } + } + + stats +} + +/// 深度匹配分析统计 +#[derive(Debug, Clone)] +pub struct DeepMatchStats { + pub total_albums: usize, + pub pass1_matched: usize, + pub pass1_unmatched: usize, + pub pass2_jieba_match: usize, + pub pass2_exact_match: usize, + pub pass2_alias_match: usize, + pub pass2_still_unmatched: usize, + pub pass2_multi_person: usize, +} + +/// 执行 Pass2 深度匹配分析 +pub async fn analyze_deep_matches(db: &Database) -> DeepMatchStats { + let (known_names, known_aliases) = collect_known_names(db).await; + let albums = db.get_all_albums_full().await; + + let mut stats = DeepMatchStats { + total_albums: albums.len(), + pass1_matched: 0, + pass1_unmatched: 0, + pass2_jieba_match: 0, + pass2_exact_match: 0, + pass2_alias_match: 0, + pass2_still_unmatched: 0, + pass2_multi_person: 0, + }; + + for album in &albums { + let title = album.title.as_deref().unwrap_or(""); + let current = album.person_name_original.as_deref().unwrap_or(""); + + if title.is_empty() { + continue; + } + + if !current.is_empty() && known_names.contains(current) { + stats.pass1_matched += 1; + continue; + } + + stats.pass1_unmatched += 1; + + let result = deep_match_title(title, &known_names, &known_aliases); + if !result.matched { + stats.pass2_still_unmatched += 1; + continue; + } + + match result.method.as_str() { + "jieba_extract" => stats.pass2_jieba_match += 1, + "exact_name_match" => stats.pass2_exact_match += 1, + "alias_fuzzy_match" => stats.pass2_alias_match += 1, + _ => {} + } + + if result.multi_person { + stats.pass2_multi_person += 1; + } + } + + stats +} \ No newline at end of file diff --git a/src/engine/pipeline.rs b/src/engine/pipeline.rs new file mode 100644 index 0000000..155c3fe --- /dev/null +++ b/src/engine/pipeline.rs @@ -0,0 +1,81 @@ +//! 处理管线 — 批量处理相册元数据 + +use crate::db::Database; +use crate::engine::title_parser; +use crate::engine::tag_extractor; +use crate::engine::person_merger; +use std::collections::HashMap; + +/// 处理所有相册的元数据:提取人名 + 标签 +/// 两阶段:先用正则提取人名入库,再用匹配精炼,最后提取标签 +pub async fn process_all_albums(db: &Database) -> (usize, usize) { + let albums = db.get_all_albums_full().await; + let mut person_count = 0; + let mut tag_count = 0; + + // Phase 1: 先用正则从标题直接提取人名(冷启动,不需要已有 persons) + let patterns = person_merger::collect_person_patterns(db).await; + let use_patterns = !patterns.is_empty(); + + for album in &albums { + let title = album.title.as_deref().unwrap_or(""); + if title.is_empty() { continue; } + + // 提取人名 + if album.person_name_original.is_none() || album.person_name_original.as_deref() == Some("") { + let person_name = if use_patterns { + // 已有人物库,用匹配 + person_merger::match_title(title, &patterns).map(|(_, name, _)| name) + } else { + // 冷启动,直接用正则提取 + title_parser::extract_person_name_from_title(title) + }; + + if let Some(person_name) = person_name { + let (cleaned, _aliases) = title_parser::clean_person_name(&person_name); + let final_name = if cleaned.is_empty() { person_name.clone() } else { cleaned }; + db.update_album_person(&album.id, &person_name, &final_name).await; + + // 创建人员 + let pid = db.insert_person(&final_name, None).await; + db.insert_person_album(pid, &album.id).await; + db.update_person_counts(pid).await; + person_count += 1; + } + } + } + + // Phase 2: 提取标签(使用已填充的人名来排除) + // 重新获取 albums(因为 person_name_cleaned 已更新) + let albums_refreshed = db.get_all_albums_full().await; + let mut seen_albums: HashMap = HashMap::new(); + for album in &albums { + if let Some(pn) = &album.person_name_cleaned { + seen_albums.insert(album.id.clone(), pn.clone()); + } + } + + for album in &albums_refreshed { + let title = album.title.as_deref().unwrap_or(""); + if title.is_empty() { continue; } + + let existing_tags = album.tags.as_deref().unwrap_or(""); + if existing_tags.is_empty() || existing_tags == "[]" { + let person = album.person_name_cleaned.as_deref() + .or_else(|| seen_albums.get(&album.id).map(|s| s.as_str())); + let tags = tag_extractor::extract_tags_from_title(title, person); + let unique = tag_extractor::unique_tags(tags); + if !unique.is_empty() { + let tag_names: Vec = unique.iter().map(|(t, _)| t.clone()).collect(); + let tags_json = serde_json::to_string(&tag_names).unwrap_or_else(|_| "[]".to_string()); + let all_words: Vec<[String; 2]> = unique.iter().map(|(t, tp)| [t.clone(), tp.clone()]).collect(); + let words_json = serde_json::to_string(&all_words).unwrap_or_else(|_| "[]".to_string()); + db.update_album_tags(&album.id, &tags_json, &words_json).await; + tag_count += 1; + } + } + } + + println!("[管线] 处理完成:{} 个人名,{} 个标签", person_count, tag_count); + (person_count, tag_count) +} \ No newline at end of file diff --git a/src/engine/recommend.rs b/src/engine/recommend.rs new file mode 100644 index 0000000..8d68bde --- /dev/null +++ b/src/engine/recommend.rs @@ -0,0 +1,203 @@ +//! 推荐引擎 — 标签推荐、人物推荐、关联推荐 +//! +//! 推荐策略: +//! 1. 标签推荐:基于输入前缀匹配 + 热门度排序 +//! 2. 人物推荐:基于名称匹配 + 相册数排序 +//! 3. 关联推荐:基于标签共现关系 + 人物共现关系 + +use crate::db::Database; +use crate::models::*; +use sqlx::Row; + +/// 推荐引擎 +pub struct RecommendEngine; + +impl RecommendEngine { + /// 标签自动补全推荐 + pub async fn recommend_tags( + db: &Database, + query: &str, + limit: usize, + ) -> RecommendResponse { + let limit = limit.min(20); + let q = query.trim().to_lowercase(); + + let tags = db.get_popular_tags(200).await; + let mut items: Vec = Vec::new(); + + for t in &tags { + let tag_lower = t.tag.to_lowercase(); + let score = if q.is_empty() { + // 无查询时按热门度 + t.count as f64 + } else if tag_lower == q { + // 精确匹配 + 1000.0 + t.count as f64 + } else if tag_lower.starts_with(&q) { + // 前缀匹配 + 500.0 + t.count as f64 + } else if tag_lower.contains(&q) { + // 包含匹配 + 100.0 + t.count as f64 + } else { + continue; + }; + + items.push(RecommendItem { + value: t.tag.clone(), + label: format!("{} ({})", t.tag, t.count), + score, + count: Some(t.count), + source: "tag".to_string(), + }); + } + + items.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal)); + let total = items.len(); + items.truncate(limit); + + RecommendResponse { items, total, category: "tags".to_string() } + } + + /// 人物推荐 + pub async fn recommend_persons( + db: &Database, + query: &str, + limit: usize, + ) -> RecommendResponse { + let limit = limit.min(20); + let q = query.trim().to_lowercase(); + + let persons = db.get_all_persons_with_aliases().await; + let mut items: Vec = Vec::new(); + + for p in &persons { + let name_lower = p.name.to_lowercase(); + let score = if q.is_empty() { + p.total_albums as f64 + } else if name_lower == q { + 1000.0 + p.total_albums as f64 + } else if name_lower.starts_with(&q) { + 500.0 + p.total_albums as f64 + } else if name_lower.contains(&q) { + 100.0 + p.total_albums as f64 + } else { + // 检查别名 + let alias_match = p.aliases.iter().any(|a| a.to_lowercase().contains(&q)); + if alias_match { 200.0 + p.total_albums as f64 } else { continue } + }; + + let label = if p.aliases.is_empty() { + format!("{} ({}相册)", p.name, p.total_albums) + } else { + format!("{} [{}] ({}相册)", p.name, p.aliases.join(", "), p.total_albums) + }; + + items.push(RecommendItem { + value: p.name.clone(), + label, + score, + count: Some(p.total_albums), + source: "person".to_string(), + }); + } + + items.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal)); + let total = items.len(); + items.truncate(limit); + + RecommendResponse { items, total, category: "persons".to_string() } + } + + /// 关联推荐(共现标签 + 共现人物) + pub async fn recommend_related( + db: &Database, + context: &str, // 标签或人名 + limit: usize, + ) -> TagRecommendation { + let limit = limit.min(15); + + // 获取共现标签 + let co_tags = db.get_tag_co_occurrences(context, limit as i64).await; + let related_tags: Vec = co_tags.iter().map(|c| { + RecommendItem { + value: c.co_tag.clone(), + label: format!("{} (共现{}次)", c.co_tag, c.count), + score: c.score, + count: Some(c.count), + source: "co-occurrence".to_string(), + } + }).collect(); + + // 获取相关人物(通过标签) + let albums = db.search_albums_by_tag(context).await; + let mut person_scores: std::collections::HashMap = std::collections::HashMap::new(); + let total = albums.len() as f64; + for album in &albums { + let meta = db.get_all_albums_full().await; + for m in &meta { + if m.id == album.id.to_string() { + if let Some(ref name) = m.person_name_cleaned { + let entry = person_scores.entry(name.clone()).or_insert((0, 0.0)); + entry.0 += 1; + entry.1 = entry.0 as f64 / total; + } + } + } + } + + let mut similar_persons: Vec = person_scores.into_iter() + .filter(|(_, (cnt, _))| *cnt >= 1) + .map(|(name, (cnt, score))| RecommendItem { + value: name.clone(), + label: format!("{} ({}次)", name, cnt), + score, + count: Some(cnt), + source: "co-occurrence".to_string(), + }).collect(); + similar_persons.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal)); + similar_persons.truncate(limit); + + TagRecommendation { + tag: context.to_string(), + related_tags, + similar_persons, + } + } + + /// 热门人物 + pub async fn popular_persons(db: &Database, limit: usize) -> Vec { + let persons = db.get_all_persons_with_aliases().await; + let mut results: Vec = persons.iter().take(limit).map(|p| { + PopularPerson { + name: p.name.clone(), + display_name: p.display_name.clone(), + total_albums: p.total_albums, + total_images: p.total_images, + top_tags: Vec::new(), + aliases: p.aliases.clone(), + } + }).collect(); + + // 为热门人物获取 top tags + // (简化:从 person_albums 查标签) + for rp in &mut results { + if let Some(person) = db.get_person_by_name(&rp.name).await { + let albums = db.get_person_albums(person.id).await; + let mut tag_counts: std::collections::HashMap = std::collections::HashMap::new(); + for album in &albums { + let aid = album.id.to_string(); + let rows = sqlx::query("SELECT tag FROM album_tags WHERE album_id = ?") + .bind(&aid).fetch_all(&db.pool).await.unwrap_or_default(); + for row in &rows { + *tag_counts.entry(row.get::("tag")).or_insert(0) += 1; + } + } + let mut tags: Vec<(String, i64)> = tag_counts.into_iter().collect(); + tags.sort_by(|a, b| b.1.cmp(&a.1)); + rp.top_tags = tags.into_iter().take(5).map(|(t, _)| t).collect(); + } + } + results + } +} \ No newline at end of file diff --git a/src/engine/tag_extractor.rs b/src/engine/tag_extractor.rs new file mode 100644 index 0000000..6a2684d --- /dev/null +++ b/src/engine/tag_extractor.rs @@ -0,0 +1,81 @@ +//! 标签提取器 — 从标题中提取标签词 + +use regex::Regex; +use lazy_static::lazy_static; +use std::collections::HashSet; + +lazy_static! { + static ref WORD_RE: Regex = Regex::new(r"[a-zA-Z0-9\u{4e00}-\u{9fff}]+").unwrap(); + static ref HAS_CHINESE: Regex = Regex::new(r"[\u{4e00}-\u{9fff}]").unwrap(); +} + +/// 停用词 +const STOP_WORDS: &[&str] = &[ + "photos", "photo", "pictures", "picture", "images", "image", "video", "videos", + "cosplay", "cos", "and", "the", "of", "in", "a", "an", "is", "to", "for", + "part", "vol", "collection", "special", "extra", "bonus", + "new", "old", "selfie", "official", "original", "preview", "patreon", + "秀人", "网", "写真", "图", "套", "合集", "系列", "作品", +]; + +/// 从标题中提取标签 +pub fn extract_tags_from_title( + title: &str, + person_name: Option<&str>, +) -> Vec<(String, String)> { + let mut tags = Vec::new(); + let mut title_lower = title.to_lowercase(); + + // 移除人名 + if let Some(name) = person_name { + title_lower = title_lower.replace(&name.to_lowercase(), " "); + } + + let stop_words: HashSet<&str> = STOP_WORDS.iter().copied().collect(); + let person_name_lower = person_name.map(|n| n.to_lowercase()); + + for cap in WORD_RE.find_iter(&title_lower) { + let word = cap.as_str().trim(); + if word.len() < 2 { continue; } + if word.chars().all(|c| c.is_ascii_digit()) { continue; } + if stop_words.contains(&word.to_lowercase().as_str()) { continue; } + if let Some(ref pn) = person_name_lower { + if word.to_lowercase().contains(pn.as_str()) { continue; } + } + + let tag_type = if HAS_CHINESE.is_match(word) { "chinese" } else { "english" }; + tags.push((word.to_string(), tag_type.to_string())); + } + + tags +} + +/// 标签去重并排序 +pub fn unique_tags(tags: Vec<(String, String)>) -> Vec<(String, String)> { + let mut seen = HashSet::new(); + let mut result = Vec::new(); + for (tag, tag_type) in tags { + let key = tag.to_lowercase(); + if seen.insert(key) { + result.push((tag, tag_type)); + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_tags() { + let tags = extract_tags_from_title( + "XiuRen秀人网 – 杨晨晨Yome – Night Dress '81 photos'", + Some("杨晨晨Yome"), + ); + let names: Vec<&str> = tags.iter().map(|(t, _)| t.as_str()).collect(); + assert!(names.contains(&"Night")); + assert!(names.contains(&"Dress")); + assert!(!names.contains(&"photos")); + } +} \ No newline at end of file diff --git a/src/engine/title_parser.rs b/src/engine/title_parser.rs new file mode 100644 index 0000000..d67b319 --- /dev/null +++ b/src/engine/title_parser.rs @@ -0,0 +1,285 @@ +//! 标题解析、人名提取、标签提取 +//! 对应 Python 的 tag_index.py + clean_person_names.py 的核心逻辑 + +use regex::Regex; +use std::collections::HashSet; +use lazy_static::lazy_static; + +// ── 常量 ── + +/// 模特称呼前缀(移除用) +const MODEL_TITLE_PREFIX: &[&str] = &[ + "美女模特", "性感女神", "清纯美女", "气质美女", "可爱女孩", + "甜美少女", "时尚达人", "女神", "美女", "模特", "小姐姐", + "小仙女", "御姐", "萝莉", "辣妹", "潮人", +]; + +/// 停用词(标签过滤用) +const STOP_WORDS: &[&str] = &[ + "写真", "套图", "图片", "照片", "摄影", "拍摄", "风格", + "系列", "作品", "专辑", "主题", "人像", "艺术", "唯美", + "诱惑", "性感", "迷人", "魅力", "时尚", "潮流", "高清", + "原版", "无水印", "私房", "内衣", "比基尼", "泳装", + "旗袍", "制服", "丝袜", "美腿", "美胸", "翘臀", + "cosplay", "cos", "角色扮演", "动漫", "游戏", + "classic", "exclusive", "premium", "collection", + "photo", "photos", "video", "videos", "set", "vol", + "no", "番号", "编号", "期号", +]; + +/// 保留词性(仅用于 jieba 分词场景,这里做占位符) +const _KEEP_POS: &[&str] = &["n", "nr", "ns", "nz", "v", "vn", "a", "an", "eng", "x"]; + +/// 工作室/平台排除名 +const EXCLUDED_NAMES: &[&str] = &[ + "Pure Media", "XiuRen秀人网", "尤蜜", "魅妍社", "花漾", + "瑞丝馆", "撸图", "情欲萝莉", "蜜桃社", "tgod", + "MiStar", "BLUECAKE", "HuaYang", "YouMi", "秀人", +]; + +lazy_static! { + /// 编译后的正则 + static ref RE_COSPLAY: Regex = Regex::new( + r"(?i)^(.+?)\s+cos(play)?\s+(.+?)(?:\s*[–—\-]\s*(.+))?$" + ).unwrap(); + static ref RE_COSPLAY_BY: Regex = Regex::new( + r"(?i)^(.+?)\s+cos(play)?\s+by\s+(.+?)(?:\s*[–—\-]\s*(.+))?$" + ).unwrap(); + static ref RE_BRACKET_PREFIX: Regex = Regex::new(r"^\[([^\]]+)\]\s*").unwrap(); + static ref RE_NUMBER_SUFFIX: Regex = Regex::new(r"[\s#]*(?:no\.?\s*|vol\.?\s*|#)?\d+[\s]*$").unwrap(); + static ref RE_PAREN_CONTENT: Regex = Regex::new(r"[((]([^))]*)[))]").unwrap(); + static ref RE_SPLIT_DASH: Regex = Regex::new(r"\s*[–—\-]\s*").unwrap(); + static ref RE_PHOTOS_SUFFIX: Regex = Regex::new(r"(?i)\s*\d+\s*photos?\s*(?:and\s*\d+\s*videos?)?\s*$").unwrap(); + static ref RE_CHINESE: Regex = Regex::new(r"[\u{4e00}-\u{9fff}]").unwrap(); + static ref RE_ENGLISH_SUFFIX: Regex = Regex::new(r"^([\u{4e00}-\u{9fff}]+)([A-Za-z].*)$").unwrap(); + static ref RE_TRAILING_PUNCT: Regex = Regex::new(r"[\s.,;:,。;:\-–—]+$").unwrap(); + + /// 停用词集合 + static ref STOP_WORDS_SET: HashSet<&'static str> = { + let mut s = HashSet::new(); + for w in STOP_WORDS { s.insert(*w); } + s + }; + + /// 排除名集合(小写) + static ref EXCLUDED_LOWER: HashSet = { + let mut s = HashSet::new(); + for n in EXCLUDED_NAMES { s.insert(n.to_lowercase()); } + s.extend(["youmi", "huayang", "bluecake", "mistar", "xiuren", "秀人"].map(|s| s.to_string())); + s + }; +} + +// ── 人名提取 ── + +/// 从标题中提取模特/人物名称 +/// 对应 Python tag_index.extract_person_name_from_title +pub fn extract_person_name_from_title(title: &str) -> Option { + let title = title.trim(); + if title.is_empty() { + return None; + } + + // 清理末尾的 "N photos" 等 + let title = RE_PHOTOS_SUFFIX.replace(title, "").trim().to_string(); + + // 1. cosplay 格式: "Name cosplay Character – Series" + if let Some(caps) = RE_COSPLAY_BY.captures(&title) { + return Some(caps[3].trim().to_string()); + } + if let Some(caps) = RE_COSPLAY.captures(&title) { + return Some(caps[1].trim().to_string()); + } + + // 2. 中文摄影工作室格式 + let title = RE_BRACKET_PREFIX.replace(&title, "").trim().to_string(); + + // 移除模特称呼前缀 + let mut title = title; + for prefix in MODEL_TITLE_PREFIX { + if title.starts_with(prefix) { + title = title[prefix.len()..].trim().to_string(); + break; + } + } + + // 移除编号后缀 + let title = RE_NUMBER_SUFFIX.replace(&title, "").trim().to_string(); + + // 用 `–—\-` 分割,取第一部分作为人名 + let parts: Vec<&str> = RE_SPLIT_DASH.split(&title).collect(); + let name = parts[0].trim().to_string(); + + if name.len() >= 2 { + Some(name) + } else { + None + } +} + +/// 提取中文核心(开头的连续中文字符) +/// 对应 Python merge_persons_by_core.extract_cn +pub fn extract_cn(name: &str) -> Option { + let re = Regex::new(r"^([\u{4e00}-\u{9fff}]+)").unwrap(); + re.captures(name).map(|c| c[1].to_string()) +} + +/// 判断后缀是否有效(不含中文=有效英文后缀) +/// 对应 Python merge_persons_by_core.is_valid_suffix +pub fn is_valid_suffix(suffix: &str) -> bool { + if suffix.is_empty() { + return true; + } + !RE_CHINESE.is_match(suffix) +} + +/// 判断是否为工作室/平台名(需排除) +pub fn is_excluded_name(name: &str) -> bool { + EXCLUDED_LOWER.contains(&name.to_lowercase()) +} + +// ── 人名清洗 ── + +/// 清洗人名:移除前缀、括号内容、末尾标点 +/// 返回 (cleaned_name, aliases) +/// 对应 Python clean_person_names.clean_person_name +pub fn clean_person_name(raw_name: &str) -> (String, Vec<(String, String)>) { + let mut aliases = Vec::new(); + let mut name = raw_name.to_string(); + + // 1. 移除前缀 + for prefix in MODEL_TITLE_PREFIX { + if name.starts_with(prefix) { + aliases.push(("prefix".to_string(), prefix.to_string())); + name = name[prefix.len()..].trim().to_string(); + break; + } + } + + // 2. 移除末尾括号内容 + let mut cleaned = String::new(); + let mut last_end = 0; + for cap in RE_PAREN_CONTENT.captures_iter(&name) { + let m = cap.get(0).unwrap(); + let content = cap.get(1).map_or("", |c| c.as_str()); + cleaned.push_str(&name[last_end..m.start()]); + last_end = m.end(); + + if !content.is_empty() { + if RE_CHINESE.is_match(content) { + aliases.push(("nickname".to_string(), content.to_string())); + } else { + aliases.push(("english".to_string(), content.to_string())); + } + } + } + cleaned.push_str(&name[last_end..]); + let name = cleaned.trim().to_string(); + + // 3. 清理末尾标点 + let name = RE_TRAILING_PUNCT.replace(&name, "").trim().to_string(); + + // 4. 从清洗后名字提取英文名别名 + if let Some(caps) = RE_ENGLISH_SUFFIX.captures(&name) { + let chinese_part = caps[1].to_string(); + let english_part = caps[2].to_string(); + if !english_part.is_empty() { + aliases.push(("chinese".to_string(), chinese_part)); + aliases.push(("english".to_string(), english_part)); + } + } + + if name.is_empty() { + (raw_name.to_string(), aliases) + } else { + (name, aliases) + } +} + +/// 获取归并键(小写化+移除空格和标点) +pub fn get_person_key(name: &str) -> String { + name.to_lowercase() + .chars() + .filter(|c| !c.is_whitespace() && !c.is_ascii_punctuation()) + .collect() +} + +// ── 标签提取 ── + +/// 从标题中提取标签(排除人名部分) +/// 返回 Vec<(tag, pos)> +/// 对应 Python tag_index.extract_tags_from_title +#[allow(dead_code)] +pub fn extract_tags_from_title_legacy(title: &str, person_name: Option<&str>) -> Vec<(String, String)> { + let mut title = title.to_string(); + + // 清理末尾的 "N photos" 等 + title = RE_PHOTOS_SUFFIX.replace(&title, "").to_string(); + + // 移除人名 + if let Some(name) = person_name { + title = title.replace(name, " "); + for part in name.split_whitespace() { + title = title.replace(part, " "); + } + } + + // 移除 [工作室] 前缀 + title = RE_BRACKET_PREFIX.replace(&title, "").to_string(); + + // 按空白分割,过滤停用词和短词 + let mut tags = Vec::new(); + let mut seen = HashSet::new(); + + for word in title.split_whitespace() { + let word = word.trim().to_lowercase(); + if word.len() < 2 { + continue; + } + if STOP_WORDS_SET.contains(word.as_str()) { + continue; + } + if word.chars().all(|c| c.is_ascii_digit() || c == '.') { + continue; + } + if seen.contains(&word) { + continue; + } + seen.insert(word.clone()); + tags.push((word, "x".to_string())); + } + + tags +} + +/// 标签归一化 +pub fn normalize_tag(tag: &str) -> String { + tag.trim().to_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_person_name() { + assert_eq!(extract_person_name_from_title("杨晨晨Yome 私房写真").unwrap(), "杨晨晨Yome"); + assert_eq!(extract_person_name_from_title("王馨瑶yanni 比基尼").unwrap(), "王馨瑶yanni"); + assert_eq!(extract_person_name_from_title("[XiuRen] 杨晨晨 性感").unwrap(), "杨晨晨"); + assert_eq!(extract_person_name_from_title("美女模特 杨晨晨 私房").unwrap(), "杨晨晨"); + } + + #[test] + fn test_extract_cn() { + assert_eq!(extract_cn("杨晨晨Yome").unwrap(), "杨晨晨"); + assert_eq!(extract_cn("王馨瑶yanni").unwrap(), "王馨瑶"); + assert!(extract_cn("Yome").is_none()); + } + + #[test] + fn test_clean_person_name() { + let (cleaned, aliases) = clean_person_name("美女模特 杨晨晨Yome"); + assert_eq!(cleaned, "美女模特 杨晨晨Yome"); + assert!(!aliases.is_empty()); + } +} \ No newline at end of file diff --git a/src/models/album.rs b/src/models/album.rs new file mode 100644 index 0000000..19d99e2 --- /dev/null +++ b/src/models/album.rs @@ -0,0 +1,62 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Album { + pub id: Uuid, + pub title: String, + pub description: Option, + pub cover_image_id: Option, + pub image_count: i64, + pub created_at: String, + pub updated_at: String, + #[serde(default)] + pub person_name_cleaned: Option, + #[serde(default)] + pub tags: Option, // JSON: [tag, ...] +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AlbumInfo { + pub id: Uuid, + pub title: String, + pub document: String, + pub description: Option, + pub cover_image_id: Option, + pub image_count: i64, + pub created_at: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct AlbumWithImages { + pub album: Album, + pub images: Vec, + pub image_count: i64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateAlbum { + pub title: String, + pub document: String, + pub description: Option, +} + +#[derive(Debug, Deserialize)] +pub struct AlbumIndex { + pub title: String, + pub images: Vec, +} + +/// 相册完整元数据(含扩展字段) +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AlbumMeta { + pub id: String, + pub title: Option, + pub person_name_original: Option, + pub person_name_cleaned: Option, + pub tags: Option, + pub all_words: Option, + pub raw_title: Option, + pub processed_at: Option, + pub image_count: Option, +} \ No newline at end of file diff --git a/src/models/favorite.rs b/src/models/favorite.rs new file mode 100644 index 0000000..8f8f459 --- /dev/null +++ b/src/models/favorite.rs @@ -0,0 +1,30 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Favorite { + pub id: Uuid, + pub user_id: String, + pub image_id: Uuid, + pub album_id: Uuid, + pub created_at: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateFavorite { + pub image_id: Uuid, + pub album_id: Uuid, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct FavoriteWithImage { + pub favorite: Favorite, + pub image: super::image::Image, + pub album_title: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct FavoriteStatus { + pub is_favorited: bool, + pub favorite_id: Option, +} \ No newline at end of file diff --git a/src/models/image.rs b/src/models/image.rs new file mode 100644 index 0000000..1c4d7e6 --- /dev/null +++ b/src/models/image.rs @@ -0,0 +1,30 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Image { + pub id: Uuid, + pub album_id: Uuid, + pub filename: String, + pub original_url: Option, + pub title: Option, + pub description: Option, + pub file_size: i64, + pub width: i32, + pub height: i32, + pub mime_type: String, + pub created_at: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CreateImage { + pub album_id: Uuid, + pub filename: String, + pub original_url: Option, + pub title: Option, + pub description: Option, + pub file_size: i64, + pub width: i32, + pub height: i32, + pub mime_type: String, +} \ No newline at end of file diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..d6ec375 --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1,13 @@ +pub mod album; +pub mod image; +pub mod person; +pub mod tag; +pub mod favorite; +pub mod recommend; + +pub use album::*; +pub use image::*; +pub use person::*; +pub use tag::*; +pub use favorite::*; +pub use recommend::*; \ No newline at end of file diff --git a/src/models/person.rs b/src/models/person.rs new file mode 100644 index 0000000..40822b2 --- /dev/null +++ b/src/models/person.rs @@ -0,0 +1,53 @@ +use serde::{Deserialize, Serialize}; +use super::album::AlbumInfo; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Person { + pub id: i64, + pub name: String, + pub display_name: Option, + pub total_albums: i64, + pub total_images: i64, + pub original_names: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PersonAlias { + pub id: i64, + pub person_id: i64, + pub alias_type: String, + pub alias_name: String, + pub created_at: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PersonWithAlbums { + pub person: Person, + pub albums: Vec, + pub album_count: usize, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PersonWithCounts { + pub id: i64, + pub name: String, + pub display_name: Option, + pub total_albums: i64, + pub total_images: i64, + pub aliases: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PersonListResponse { + pub persons: Vec, + pub total: usize, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SearchPersonResponse { + pub person: Option, + pub albums: Vec, + pub total_albums: usize, +} \ No newline at end of file diff --git a/src/models/recommend.rs b/src/models/recommend.rs new file mode 100644 index 0000000..c3b645b --- /dev/null +++ b/src/models/recommend.rs @@ -0,0 +1,46 @@ +use serde::{Deserialize, Serialize}; + +/// 推荐请求 +#[derive(Debug, Deserialize)] +pub struct RecommendQuery { + pub q: Option, + pub limit: Option, + pub context: Option, // 上下文标签(用于共现推荐) +} + +/// 推荐条目(通用) +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct RecommendItem { + pub value: String, + pub label: String, + pub score: f64, + pub count: Option, + pub source: String, // "tag" | "person" | "co-occurrence" | "popular" +} + +/// 推荐响应 +#[derive(Debug, Serialize, Deserialize)] +pub struct RecommendResponse { + pub items: Vec, + pub total: usize, + pub category: String, // "tags" | "persons" | "related" +} + +/// 标签推荐(带上下文) +#[derive(Debug, Serialize, Deserialize)] +pub struct TagRecommendation { + pub tag: String, + pub related_tags: Vec, + pub similar_persons: Vec, +} + +/// 热门人物 +#[derive(Debug, Serialize, Deserialize)] +pub struct PopularPerson { + pub name: String, + pub display_name: Option, + pub total_albums: i64, + pub total_images: i64, + pub top_tags: Vec, + pub aliases: Vec, +} \ No newline at end of file diff --git a/src/models/tag.rs b/src/models/tag.rs new file mode 100644 index 0000000..1c103a1 --- /dev/null +++ b/src/models/tag.rs @@ -0,0 +1,35 @@ +use serde::{Deserialize, Serialize}; + +/// 标签统计 +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct TagStat { + pub tag: String, + pub count: i64, + pub album_count: i64, +} + +/// 标签列表响应 +#[derive(Debug, Serialize, Deserialize)] +pub struct TagListResponse { + pub tags: Vec, + pub total: usize, +} + +/// 元数据统计 +#[derive(Debug, Serialize, Deserialize)] +pub struct MetadataStats { + pub total_persons: i64, + pub total_aliases: i64, + pub total_albums_with_person: i64, + pub total_albums_without_person: i64, + pub total_tags: i64, +} + +/// 标签共现关系 +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct TagCoOccurrence { + pub tag: String, + pub co_tag: String, + pub count: i64, + pub score: f64, +} \ No newline at end of file diff --git a/src/routes/album.rs b/src/routes/album.rs new file mode 100644 index 0000000..10424ee --- /dev/null +++ b/src/routes/album.rs @@ -0,0 +1,65 @@ +use crate::cache::AlbumsCache; +use crate::db::Database; +use crate::models::*; +use actix_web::{HttpResponse, Responder, web}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; +use uuid::Uuid; + +pub async fn get_albums(cache: web::Data>>) -> impl Responder { + let c = cache.read().await; + HttpResponse::Ok().json(&c.albums) +} + +pub async fn search_albums( + cache: web::Data>>, + query: web::Query>, +) -> impl Responder { + let q = query.get("q").map(|s| s.as_str()).unwrap_or(""); + if q.len() > 100 { return HttpResponse::BadRequest().json(serde_json::json!({"error": "搜索查询过长"})); } + if q.chars().any(|c| c.is_control() || c == '\0') { return HttpResponse::BadRequest().json(serde_json::json!({"error": "无效字符"})); } + + let page: usize = query.get("page").and_then(|s| s.parse().ok()).unwrap_or(1); + let limit: usize = query.get("limit").and_then(|s| s.parse().ok()).unwrap_or(50).min(200); + let offset = (page - 1) * limit; + + let c = cache.read().await; + let results = if q.is_empty() { + let total = c.albums.len(); + let start = offset.min(total); + let end = (offset + limit).min(total); + c.albums[start..end].to_vec() + } else { + let mut results = c.search(q); + let total = results.len(); + let start = offset.min(total); + let end = (offset + limit).min(total); + if start < end { results.drain(start..end).collect() } else { Vec::new() } + }; + + HttpResponse::Ok().json(results) +} + +pub async fn get_random_album(cache: web::Data>>) -> impl Responder { + let c = cache.read().await; + if c.albums.is_empty() { return HttpResponse::NotFound().json(serde_json::json!({"error": "没有相册"})); } + let idx = rand::random::() % c.albums.len(); + HttpResponse::Ok().json(&c.albums[idx]) +} + +pub async fn get_album_detail(db: web::Data>, path: web::Path) -> impl Responder { + let id = match Uuid::parse_str(&path.into_inner()) { Ok(id) => id, Err(_) => return HttpResponse::BadRequest().json(serde_json::json!({"error": "无效ID"})) }; + match db.get_album_with_images(id).await { + Ok(Some(album)) => HttpResponse::Ok().json(album), + Ok(None) => HttpResponse::NotFound().json(serde_json::json!({"error": "相册不存在"})), + Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("{}", e)})), + } +} + +pub async fn create_album(db: web::Data>, body: web::Json) -> impl Responder { + match db.create_album(body.into_inner()).await { + Ok(id) => HttpResponse::Created().json(serde_json::json!({"id": id})), + Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("{}", e)})), + } +} \ No newline at end of file diff --git a/src/routes/favorite.rs b/src/routes/favorite.rs new file mode 100644 index 0000000..442a045 --- /dev/null +++ b/src/routes/favorite.rs @@ -0,0 +1,45 @@ +use crate::db::Database; +use crate::models::*; +use actix_web::{HttpResponse, Responder, web}; +use std::sync::Arc; +use uuid::Uuid; + +static USER_ID: &str = "default_user"; + +pub async fn get_favorites(db: web::Data>) -> impl Responder { + match db.get_user_favorites(USER_ID).await { + Ok(favs) => HttpResponse::Ok().json(favs), + Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("{}", e)})), + } +} + +pub async fn check_favorite_status(db: web::Data>, path: web::Path) -> impl Responder { + let id = match Uuid::parse_str(&path.into_inner()) { Ok(id) => id, Err(_) => return HttpResponse::BadRequest().json(serde_json::json!({"error": "无效ID"})) }; + match db.get_favorite_status(USER_ID, id).await { + Ok(s) => HttpResponse::Ok().json(s), + Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("{}", e)})), + } +} + +pub async fn add_favorite(db: web::Data>, body: web::Json) -> impl Responder { + match db.add_favorite(USER_ID, body.into_inner()).await { + Ok(id) => HttpResponse::Created().json(serde_json::json!({"id": id})), + Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("{}", e)})), + } +} + +pub async fn remove_favorite(db: web::Data>, path: web::Path) -> impl Responder { + let id = match Uuid::parse_str(&path.into_inner()) { Ok(id) => id, Err(_) => return HttpResponse::BadRequest().json(serde_json::json!({"error": "无效ID"})) }; + match db.remove_favorite(id).await { + Ok(_) => HttpResponse::Ok().json(serde_json::json!({"message": "已取消"})), + Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("{}", e)})), + } +} + +pub async fn remove_favorite_by_image(db: web::Data>, path: web::Path) -> impl Responder { + let id = match Uuid::parse_str(&path.into_inner()) { Ok(id) => id, Err(_) => return HttpResponse::BadRequest().json(serde_json::json!({"error": "无效ID"})) }; + match db.remove_favorite_by_image(USER_ID, id).await { + Ok(_) => HttpResponse::Ok().json(serde_json::json!({"message": "已取消"})), + Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("{}", e)})), + } +} \ No newline at end of file diff --git a/src/routes/image.rs b/src/routes/image.rs new file mode 100644 index 0000000..7cd4847 --- /dev/null +++ b/src/routes/image.rs @@ -0,0 +1,29 @@ +use crate::db::Database; +use crate::image_processor::ImageProcessor; +use actix_web::{HttpResponse, Responder, web}; +use std::fs; +use std::sync::Arc; +use uuid::Uuid; + +pub async fn get_image( + db: web::Data>, + ip: web::Data>, + path: web::Path, +) -> impl Responder { + let id = match Uuid::parse_str(&path.into_inner()) { Ok(id) => id, Err(_) => return HttpResponse::BadRequest().json(serde_json::json!({"error": "无效ID"})) }; + match db.get_image(id).await { + Ok(Some(img)) => { + let fp = ip.config.images_dir.join(&img.filename); + if fp.exists() { + match fs::read(&fp) { + Ok(data) => HttpResponse::Ok().content_type(img.mime_type) + .insert_header(("Cache-Control", "public, max-age=31536000")) + .insert_header(("ETag", format!("\"{}\"", id))).body(data), + Err(_) => HttpResponse::InternalServerError().json(serde_json::json!({"error": "读取失败"})), + } + } else { HttpResponse::NotFound().json(serde_json::json!({"error": "文件不存在"})) } + } + Ok(None) => HttpResponse::NotFound().json(serde_json::json!({"error": "图片不存在"})), + Err(_) => HttpResponse::InternalServerError().json(serde_json::json!({"error": "查询失败"})), + } +} \ No newline at end of file diff --git a/src/routes/migration.rs b/src/routes/migration.rs new file mode 100644 index 0000000..695a68a --- /dev/null +++ b/src/routes/migration.rs @@ -0,0 +1,48 @@ +use crate::db::Database; +use crate::db::MigrationStats; +use actix_web::{HttpResponse, Responder, web}; +use std::collections::HashMap; +use std::sync::Arc; + +/// GET /api/migration/stats — 查看迁移状态 +pub async fn migration_stats(db: web::Data>) -> impl Responder { + let persons = db.get_all_persons_with_counts().await.len(); + let aliases = db.get_all_aliases().await.len(); + let albums = db.get_all_albums().await.unwrap_or_default().len(); + + HttpResponse::Ok().json(serde_json::json!({ + "persons": persons, + "aliases": aliases, + "albums": albums, + "message": if persons == 0 { "数据库为空,建议执行迁移" } else { "数据已就绪" } + })) +} + +/// POST /api/migration/run — 执行从老数据库迁移 +pub async fn migration_run(db: web::Data>, query: web::Query>) -> impl Responder { + let old_db_path = match query.get("path") { + Some(p) if !p.is_empty() => p.clone(), + _ => return HttpResponse::BadRequest().json(serde_json::json!({ + "error": "请提供老数据库路径: ?path=persons.db" + })), + }; + + match db.migrate_from_old_db(&old_db_path).await { + Ok(stats) => HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "summary": db.migration_summary(&stats), + "stats": { + "persons_imported": stats.persons_imported, + "persons_skipped": stats.persons_skipped, + "aliases_imported": stats.aliases_imported, + "albums_updated": stats.albums_updated, + "albums_missing": stats.albums_missing, + "tags_imported": stats.tags_imported, + "person_albums_created": stats.person_albums_created, + } + })), + Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ + "error": format!("迁移失败: {}", e) + })), + } +} \ No newline at end of file diff --git a/src/routes/mod.rs b/src/routes/mod.rs new file mode 100644 index 0000000..007867c --- /dev/null +++ b/src/routes/mod.rs @@ -0,0 +1,21 @@ +//! API 路由层 — 按领域拆分 + +pub mod album; +pub mod image; +pub mod favorite; +pub mod person; +pub mod tag; +pub mod recommend; +pub mod queue; +pub mod migration; +pub mod pipeline; + +pub use album::*; +pub use image::*; +pub use favorite::*; +pub use person::*; +pub use tag::*; +pub use recommend::*; +pub use queue::*; +pub use migration::*; +pub use pipeline::*; \ No newline at end of file diff --git a/src/routes/person.rs b/src/routes/person.rs new file mode 100644 index 0000000..f4f96a8 --- /dev/null +++ b/src/routes/person.rs @@ -0,0 +1,36 @@ +use crate::db::Database; +use crate::models::*; +use actix_web::{HttpResponse, Responder, web}; +use std::collections::HashMap; +use std::sync::Arc; + +pub async fn get_people(db: web::Data>) -> impl Responder { + let persons = db.get_all_persons_with_aliases().await; + HttpResponse::Ok().json(PersonListResponse { total: persons.len(), persons }) +} + +pub async fn search_people(db: web::Data>, query: web::Query>) -> impl Responder { + let q = query.get("q").map(|s| s.as_str()).unwrap_or(""); + if q.is_empty() { + let persons = db.get_all_persons_with_aliases().await; + return HttpResponse::Ok().json(PersonListResponse { total: persons.len(), persons }); + } + let persons = db.search_persons(q).await; + HttpResponse::Ok().json(PersonListResponse { total: persons.len(), persons }) +} + +pub async fn get_person_detail(db: web::Data>, path: web::Path) -> impl Responder { + let name = path.into_inner(); + match db.get_person_by_name(&name).await { + Some(p) => { + let albums = db.get_person_albums(p.id).await; + HttpResponse::Ok().json(PersonWithAlbums { album_count: albums.len(), person: p, albums }) + } + None => HttpResponse::NotFound().json(serde_json::json!({"error": format!("人员 '{}' 不存在", name)})), + } +} + +pub async fn get_person_aliases(db: web::Data>, path: web::Path) -> impl Responder { + let aliases = db.get_person_aliases(path.into_inner()).await; + HttpResponse::Ok().json(aliases) +} \ No newline at end of file diff --git a/src/routes/pipeline.rs b/src/routes/pipeline.rs new file mode 100644 index 0000000..2020f15 --- /dev/null +++ b/src/routes/pipeline.rs @@ -0,0 +1,15 @@ +use crate::db::Database; +use crate::engine::pipeline; +use actix_web::{HttpResponse, Responder, web}; +use std::sync::Arc; + +/// POST /api/pipeline/run — 处理所有相册元数据(提取人名+标签) +pub async fn pipeline_run(db: web::Data>) -> impl Responder { + let (persons, tags) = pipeline::process_all_albums(db.get_ref()).await; + HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "persons_updated": persons, + "tags_updated": tags, + "message": format!("处理完成:{} 个人名,{} 个标签", persons, tags) + })) +} \ No newline at end of file diff --git a/src/routes/queue.rs b/src/routes/queue.rs new file mode 100644 index 0000000..527b29f --- /dev/null +++ b/src/routes/queue.rs @@ -0,0 +1,8 @@ +use crate::queue::TaskQueue; +use actix_web::{HttpResponse, Responder, web}; +use std::sync::Arc; + +pub async fn get_queue_stats(q: web::Data>) -> impl Responder { + q.record_http_request().await; + HttpResponse::Ok().json(q.get_stats().await) +} \ No newline at end of file diff --git a/src/routes/recommend.rs b/src/routes/recommend.rs new file mode 100644 index 0000000..d494362 --- /dev/null +++ b/src/routes/recommend.rs @@ -0,0 +1,36 @@ +use crate::db::Database; +use crate::engine::RecommendEngine; +use crate::models::*; +use actix_web::{HttpResponse, Responder, web}; +use std::collections::HashMap; +use std::sync::Arc; + +/// 标签推荐(自动补全) +pub async fn recommend_tags(db: web::Data>, query: web::Query>) -> impl Responder { + let q = query.get("q").map(|s| s.as_str()).unwrap_or(""); + let limit: usize = query.get("limit").and_then(|s| s.parse().ok()).unwrap_or(10); + HttpResponse::Ok().json(RecommendEngine::recommend_tags(&db, q, limit).await) +} + +/// 人物推荐(自动补全) +pub async fn recommend_persons(db: web::Data>, query: web::Query>) -> impl Responder { + let q = query.get("q").map(|s| s.as_str()).unwrap_or(""); + let limit: usize = query.get("limit").and_then(|s| s.parse().ok()).unwrap_or(10); + HttpResponse::Ok().json(RecommendEngine::recommend_persons(&db, q, limit).await) +} + +/// 关联推荐(标签共现 + 共现人物) +pub async fn recommend_related(db: web::Data>, query: web::Query>) -> impl Responder { + let context = match query.get("context") { + Some(c) if !c.is_empty() => c, + _ => return HttpResponse::BadRequest().json(serde_json::json!({"error": "请提供context参数"})), + }; + let limit: usize = query.get("limit").and_then(|s| s.parse().ok()).unwrap_or(15); + HttpResponse::Ok().json(RecommendEngine::recommend_related(&db, context, limit).await) +} + +/// 热门人物 +pub async fn popular_persons(db: web::Data>, query: web::Query>) -> impl Responder { + let limit: usize = query.get("limit").and_then(|s| s.parse().ok()).unwrap_or(20).min(50); + HttpResponse::Ok().json(RecommendEngine::popular_persons(&db, limit).await) +} \ No newline at end of file diff --git a/src/routes/tag.rs b/src/routes/tag.rs new file mode 100644 index 0000000..af9975d --- /dev/null +++ b/src/routes/tag.rs @@ -0,0 +1,24 @@ +use crate::db::Database; +use crate::models::*; +use actix_web::{HttpResponse, Responder, web}; +use std::collections::HashMap; +use std::sync::Arc; + +pub async fn get_metadata_stats(db: web::Data>) -> impl Responder { + HttpResponse::Ok().json(db.get_metadata_stats().await) +} + +pub async fn search_by_tag(db: web::Data>, query: web::Query>) -> impl Responder { + let tag = match query.get("tag") { + Some(t) if !t.is_empty() => t, + _ => return HttpResponse::BadRequest().json(serde_json::json!({"error": "请提供tag参数"})), + }; + let albums = db.search_albums_by_tag(tag).await; + HttpResponse::Ok().json(albums) +} + +pub async fn get_popular_tags(db: web::Data>, query: web::Query>) -> impl Responder { + let limit: i64 = query.get("limit").and_then(|s| s.parse().ok()).unwrap_or(50); + let tags = db.get_popular_tags(limit).await; + HttpResponse::Ok().json(TagListResponse { total: tags.len(), tags }) +} \ No newline at end of file diff --git a/src/services/import.rs b/src/services/import.rs new file mode 100644 index 0000000..5483407 --- /dev/null +++ b/src/services/import.rs @@ -0,0 +1,405 @@ +use std::fs; +use std::path::Path; +use std::sync::Arc; +use ::image::GenericImageView; +use tokio::task; +use uuid::Uuid; +use crate::config::Config; +use crate::db::Database; +use crate::models::*; + +/// 批量插入图片并设置封面的辅助函数 +async fn batch_insert_images_and_set_cover( + db: &Database, + images_to_insert: Vec, + album_id: Uuid, + _album_path: &Path, + _folder_name: &str, +) -> Result> { + if images_to_insert.is_empty() { + return Ok(0); + } + + println!(" 批量插入 {} 张图片...", images_to_insert.len()); + match db.add_images(images_to_insert).await { + Ok(ids) => { + println!(" 批量插入成功,共插入 {} 张图片", ids.len()); + + if let Some(first_image_id) = ids.first() { + println!(" 设置第一张图片为封面图片 (ID: {})...", first_image_id); + if let Err(e) = db.update_album_cover(album_id, *first_image_id).await { + eprintln!(" 更新相册封面失败: {}", e); + } + if let Ok(Some(first_image)) = db.get_image(*first_image_id).await { + println!(" 封面图片已设置: {}", first_image.filename); + } + } + + Ok(ids.len()) + } + Err(e) => { + eprintln!(" 批量插入失败: {}", e); + Ok(0) + } + } +} + +pub async fn import_existing_data(config: &Config, db: Arc, _task_queue: Option>) -> Result<(), Box> { + println!("开始扫描图片目录并导入新相册..."); + + let albums_dir = Path::new(&config.storage.images_dir); + + if !albums_dir.exists() { + println!("albums目录不存在,跳过导入"); + return Ok(()); + } + + let existing_albums = db.get_all_albums().await?; + let existing_titles: Vec = existing_albums.iter() + .map(|album| album.document.to_lowercase()) + .collect(); + + println!("数据库中现有相册数量: {}", existing_albums.len()); + + let mut reimported_count = 0; + let mut deleted_count = 0; + + for album in &existing_albums { + let album_folder_path = albums_dir.join(&album.document); + + if !album_folder_path.exists() { + println!("🗑️ 相册文件夹不存在,删除相册: {}", album.document); + if let Err(e) = db.delete_album(album.id).await { + eprintln!("❌ 删除相册失败: {}", e); + } else { + deleted_count += 1; + } + continue; + } + + let image_count = db.get_album_image_count(album.id).await.unwrap_or(0); + if image_count == 0 { + println!("🔄 重新导入相册: {} (图片数量: 0)", album.document); + match reimport_single_album(&db, &album.document, &album_folder_path, album.id).await { + Ok(count) => { + if count > 0 { + reimported_count += 1; + println!("✅ 相册 {} 重新导入成功,导入 {} 张图片", album.document, count); + } else { + println!("⚠️ 相册 {} 重新导入但没有找到图片", album.document); + } + } + Err(e) => { + eprintln!("❌ 相册 {} 重新导入失败: {}", album.document, e); + } + } + } + } + + if reimported_count > 0 || deleted_count > 0 { + println!("🎯 重新导入完成: 重新导入 {} 个相册,删除 {} 个相册", reimported_count, deleted_count); + } + + let entries: Vec<_> = fs::read_dir(albums_dir)?.collect::, _>>()?; + let mut album_folders = Vec::new(); + + for entry in entries { + let path = entry.path(); + if path.is_dir() { + if let Some(folder_name) = path.file_name().and_then(|s| s.to_str()) { + if existing_titles.contains(&folder_name.to_lowercase()) { + continue; + } + album_folders.push((folder_name.to_string(), path)); + } + } + } + + println!("发现 {} 个新相册需要导入", album_folders.len()); + + if album_folders.is_empty() { + println!("没有新相册需要导入"); + return Ok(()); + } + + println!("📁 开始导入新相册..."); + + let mut total_imported = 0; + let mut successful_albums = 0; + + for (folder_name, path) in album_folders { + println!("📁 开始导入相册: {}", folder_name); + + match import_single_album(&db, &folder_name, &path).await { + Ok(count) => { + if count > 0 { + successful_albums += 1; + total_imported += count; + println!("✅ 相册 {} 导入完成,共导入 {} 张图片", folder_name, count); + } else { + println!("⚠️ 相册 {} 没有图片需要导入", folder_name); + } + } + Err(e) => { + eprintln!("❌ 相册 {} 导入失败: {}", folder_name, e); + } + } + } + + println!("🎉 导入完成!成功导入 {} 个相册,共 {} 张图片", successful_albums, total_imported); + + Ok(()) +} + +async fn import_single_album(db: &Database, folder_name: &str, path: &Path) -> Result> { + println!("📁 开始导入相册: {}", folder_name); + + let index_file_path = path.join("index.json"); + let album_title = if index_file_path.exists() { + match fs::read_to_string(&index_file_path) { + Ok(content) => { + match serde_json::from_str::(&content) { + Ok(index) => { + println!(" 从index.json读取相册标题: {}", index.title); + index.title + } + Err(e) => { + println!(" 解析index.json失败: {}, 使用文件夹名作为标题", e); + folder_name.to_string() + } + } + } + Err(e) => { + println!(" 读取index.json失败: {}, 使用文件夹名作为标题", e); + folder_name.to_string() + } + } + } else { + println!(" index.json不存在,使用文件夹名作为标题"); + folder_name.to_string() + }; + + let new_album = CreateAlbum { + title: album_title.clone(), + document: folder_name.to_string(), + description: Some(format!("从文件夹 {} 自动导入的相册", folder_name)), + }; + + let album_id = db.create_album(new_album).await?; + println!(" 创建相册: {} (ID: {})", album_title, album_id); + + let image_urls = if index_file_path.exists() { + match fs::read_to_string(&index_file_path) { + Ok(content) => { + match serde_json::from_str::(&content) { + Ok(index) => { + println!(" 从index.json读取到 {} 个图片URL", index.images.len()); + Some(index.images) + } + Err(_) => None + } + } + Err(_) => None + } + } else { + None + }; + + let image_files = scan_image_files(path, folder_name)?; + + if image_files.is_empty() { + println!(" 相册 {} 中没有找到图片文件", folder_name); + return Ok(0); + } + + println!(" 发现 {} 张图片,开始处理...", image_files.len()); + + let mut image_tasks = Vec::new(); + + for (index, (filename, image_path)) in image_files.iter().enumerate() { + let image_url = image_urls.as_ref().and_then(|urls| { + if urls.len() > index { + Some(urls[index].clone()) + } else { + None + } + }); + + let filename_clone = filename.clone(); + let image_path_clone = image_path.clone(); + let album_id_clone = album_id; + let folder_name_clone = folder_name.to_string(); + + let task = task::spawn_blocking(move || { + let (file_size, width, height) = match get_image_info(&image_path_clone) { + Ok(info) => info, + Err(e) => { + eprintln!("获取图片信息失败: {} - {}", image_path_clone.display(), e); + (0, 0, 0) + } + }; + + CreateImage { + album_id: album_id_clone, + filename: format!("{}/{}", folder_name_clone, filename_clone), + original_url: image_url, + title: Some(format!("图片 {}", index + 1)), + description: Some(format!("从文件夹 {} 导入的图片", folder_name_clone)), + file_size, + width, + height, + mime_type: match image_path_clone.extension().and_then(|s| s.to_str()).unwrap_or("jpg").to_lowercase().as_str() { + "jpg" | "jpeg" => "image/jpeg".to_string(), + "png" => "image/png".to_string(), + "gif" => "image/gif".to_string(), + "webp" => "image/webp".to_string(), + "bmp" => "image/bmp".to_string(), + _ => "image/jpeg".to_string(), + }, + } + }); + + image_tasks.push(task); + } + + let mut images_to_insert = Vec::new(); + for task in image_tasks { + match task.await { + Ok(image) => images_to_insert.push(image), + Err(e) => eprintln!("图片处理任务失败: {}", e), + } + } + + batch_insert_images_and_set_cover(&db, images_to_insert, album_id, path, folder_name).await +} + +pub(crate) fn scan_image_files(path: &Path, _folder_name: &str) -> Result, Box> { + let mut image_files = Vec::new(); + + for entry in fs::read_dir(path)? { + let entry = entry?; + let image_path = entry.path(); + + if image_path.is_file() { + if let Some(extension) = image_path.extension().and_then(|s| s.to_str()) { + let valid_extensions = ["jpg", "jpeg", "png", "gif", "webp", "bmp"]; + + if valid_extensions.contains(&extension.to_lowercase().as_str()) { + let filename = image_path.file_name() + .and_then(|s| s.to_str()) + .unwrap_or("unknown.jpg") + .to_string(); + + image_files.push((filename, image_path)); + } + } + } + } + + image_files.sort_by(|a, b| { + let num_a = a.0.chars() + .take_while(|c| c.is_ascii_digit()) + .collect::() + .parse::() + .unwrap_or(0); + + let num_b = b.0.chars() + .take_while(|c| c.is_ascii_digit()) + .collect::() + .parse::() + .unwrap_or(0); + + num_a.cmp(&num_b) + }); + + Ok(image_files) +} + +fn get_image_info(image_path: &Path) -> Result<(i64, i32, i32), Box> { + let metadata = fs::metadata(image_path)?; + let file_size = metadata.len() as i64; + + let img = ::image::open(image_path)?; + let (width, height) = img.dimensions(); + + Ok((file_size, width as i32, height as i32)) +} + +async fn reimport_single_album( + db: &Database, + folder_name: &str, + path: &Path, + album_id: Uuid, +) -> Result> { + println!("📁 重新导入相册: {} (ID: {})", folder_name, album_id); + + let index_file_path = path.join("index.json"); + let album_title = if index_file_path.exists() { + match fs::read_to_string(&index_file_path) { + Ok(content) => { + match serde_json::from_str::(&content) { + Ok(index) => index.title, + Err(_) => folder_name.to_string(), + } + } + Err(_) => folder_name.to_string(), + } + } else { + folder_name.to_string() + }; + + println!(" 相册标题: {}", album_title); + + let mut images_to_insert = Vec::new(); + let image_extensions = ["jpg", "jpeg", "png", "gif", "webp", "bmp"]; + + let entries: Vec<_> = fs::read_dir(path)?.collect::, _>>()?; + + for entry in entries { + let file_path = entry.path(); + if file_path.is_file() { + if let Some(extension) = file_path.extension().and_then(|ext| ext.to_str()) { + if image_extensions.contains(&extension.to_lowercase().as_str()) { + if let Some(filename) = file_path.file_name().and_then(|s| s.to_str()) { + if filename == "index.json" { + continue; + } + + let (file_size, width, height) = match get_image_info(&file_path) { + Ok(info) => info, + Err(e) => { + eprintln!(" 获取图片信息失败 {}: {}", filename, e); + continue; + } + }; + + let image = CreateImage { + album_id, + filename: format!("{}/{}", folder_name, filename), + original_url: Some(format!("/images/{}/{}", folder_name, filename)), + title: Some(filename.to_string()), + description: Some(String::new()), + file_size, + width, + height, + mime_type: match extension.to_lowercase().as_str() { + "jpg" | "jpeg" => "image/jpeg".to_string(), + "png" => "image/png".to_string(), + "gif" => "image/gif".to_string(), + "webp" => "image/webp".to_string(), + "bmp" => "image/bmp".to_string(), + _ => "application/octet-stream".to_string(), + }, + }; + + images_to_insert.push(image); + } + } + } + } + } + + println!(" 发现 {} 张图片需要导入", images_to_insert.len()); + + batch_insert_images_and_set_cover(&db, images_to_insert, album_id, path, folder_name).await +} \ No newline at end of file diff --git a/src/services/mod.rs b/src/services/mod.rs new file mode 100644 index 0000000..e1d73b4 --- /dev/null +++ b/src/services/mod.rs @@ -0,0 +1,5 @@ +//! 服务层 — 导入、处理等业务逻辑 + +pub mod import; + +pub use import::*; \ No newline at end of file