From 41fb033d30ad9b443f0580df291bb7da05fc4342 Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 29 Apr 2026 10:45:05 +0200 Subject: [PATCH] refactor: rewrite project from Rust to Go - Replace Axum with Chi router - Replace sqlx with pgx for PostgreSQL - Replace tonic/prost with grpc-go - Replace tracing with zerolog - Update flake.nix for Go build with protoc generation - Preserve all existing endpoints and functionality Stack: Chi, pgx, grpc-go, zerolog, yaml.v3 --- .gitignore | 4 +- .pre-commit-config.yaml | 2 +- Cargo.lock | 3445 ------------------------------ Cargo.toml | 37 - build.rs | 7 - cmd/server/main.go | 123 ++ flake.nix | 41 +- go.mod | 30 + go.sum | 70 + internal/api/handlers.go | 280 +++ internal/api/router.go | 54 + internal/config/config.go | 92 + internal/database/db.go | 252 +++ internal/indexer/search.go | 54 + internal/indexer/torznab.go | 289 +++ internal/metadata/client.go | 56 + internal/services/download.go | 298 +++ internal/services/indexer.go | 84 + internal/services/torrent.go | 98 + internal/torrent/client.go | 49 + internal/torrent/qbittorrent.go | 349 +++ internal/torrent/stub.go | 90 + proto/metadata/v1/metadata.proto | 2 +- src/api/indexer_controller.rs | 92 - src/api/library_controller.rs | 124 -- src/api/metadata_controller.rs | 342 --- src/api/mod.rs | 157 -- src/api/sync_controller.rs | 49 - src/api/torrent_controller.rs | 210 -- src/config/mod.rs | 103 - src/indexer/mod.rs | 43 - src/indexer/search.rs | 79 - src/indexer/torznab.rs | 221 -- src/lib.rs | 64 - src/main.rs | 131 -- src/metadata/client.rs | 112 - src/metadata/mod.rs | 7 - src/models/mod.rs | 31 - src/services/db_service.rs | 211 -- src/services/download_service.rs | 254 --- src/services/indexer_service.rs | 105 - src/services/metadata_service.rs | 92 - src/services/mod.rs | 57 - src/services/torrent_service.rs | 99 - src/torrent/client.rs | 81 - src/torrent/mod.rs | 7 - src/torrent/qbittorrent.rs | 253 --- src/torrent/stub.rs | 228 -- 48 files changed, 2306 insertions(+), 6652 deletions(-) delete mode 100644 Cargo.lock delete mode 100644 Cargo.toml delete mode 100644 build.rs create mode 100644 cmd/server/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/handlers.go create mode 100644 internal/api/router.go create mode 100644 internal/config/config.go create mode 100644 internal/database/db.go create mode 100644 internal/indexer/search.go create mode 100644 internal/indexer/torznab.go create mode 100644 internal/metadata/client.go create mode 100644 internal/services/download.go create mode 100644 internal/services/indexer.go create mode 100644 internal/services/torrent.go create mode 100644 internal/torrent/client.go create mode 100644 internal/torrent/qbittorrent.go create mode 100644 internal/torrent/stub.go delete mode 100644 src/api/indexer_controller.rs delete mode 100644 src/api/library_controller.rs delete mode 100644 src/api/metadata_controller.rs delete mode 100644 src/api/mod.rs delete mode 100644 src/api/sync_controller.rs delete mode 100644 src/api/torrent_controller.rs delete mode 100644 src/config/mod.rs delete mode 100644 src/indexer/mod.rs delete mode 100644 src/indexer/search.rs delete mode 100644 src/indexer/torznab.rs delete mode 100644 src/lib.rs delete mode 100644 src/main.rs delete mode 100644 src/metadata/client.rs delete mode 100644 src/metadata/mod.rs delete mode 100644 src/models/mod.rs delete mode 100644 src/services/db_service.rs delete mode 100644 src/services/download_service.rs delete mode 100644 src/services/indexer_service.rs delete mode 100644 src/services/metadata_service.rs delete mode 100644 src/services/mod.rs delete mode 100644 src/services/torrent_service.rs delete mode 100644 src/torrent/client.rs delete mode 100644 src/torrent/mod.rs delete mode 100644 src/torrent/qbittorrent.rs delete mode 100644 src/torrent/stub.rs diff --git a/.gitignore b/.gitignore index d77122b..6f22aa9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ -/target /result .direnv/ config.yaml +/server +/vendor +pkg/metadatapb/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 57544a0..fb5f9a2 120000 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1 +1 @@ -/nix/store/ykac3kn52hv5lqhffvg55zghgrvlgd0r-pre-commit-config.json \ No newline at end of file +/nix/store/mchzk3cbvp456fd3nbajm120nrry3pls-pre-commit-config.json \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index e62c6a7..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,3445 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[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", -] - -[[package]] -name = "atoi" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" -dependencies = [ - "num-traits", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "axum" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" -dependencies = [ - "async-trait", - "axum-core 0.4.5", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "itoa", - "matchit 0.7.3", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "sync_wrapper", - "tower 0.5.3", - "tower-layer", - "tower-service", -] - -[[package]] -name = "axum" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" -dependencies = [ - "axum-core 0.5.6", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit 0.8.4", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower 0.5.3", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" -dependencies = [ - "async-trait", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "sync_wrapper", - "tower-layer", - "tower-service", -] - -[[package]] -name = "axum-core" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "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 = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" -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 = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" - -[[package]] -name = "cc" -version = "1.2.61" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "chrono" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "clap" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - -[[package]] -name = "cookie" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" -dependencies = [ - "percent-encoding", - "time", - "version_check", -] - -[[package]] -name = "cookie_store" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" -dependencies = [ - "cookie", - "document-features", - "idna", - "log", - "publicsuffix", - "serde", - "serde_derive", - "serde_json", - "time", - "url", -] - -[[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 = "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 = "crossbeam-queue" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", - "subtle", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - -[[package]] -name = "dotenvy" -version = "0.15.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -dependencies = [ - "serde", -] - -[[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 = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - -[[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "fixedbitset" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" - -[[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.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[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.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -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.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -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", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "wasip2", - "wasip3", -] - -[[package]] -name = "h2" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap 2.14.0", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" - -[[package]] -name = "hashlink" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" -dependencies = [ - "hashbrown 0.15.5", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[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", -] - -[[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 = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots", -] - -[[package]] -name = "hyper-timeout" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" -dependencies = [ - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2 0.6.3", - "tokio", - "tower-service", - "tracing", -] - -[[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 = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[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 = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.0", - "serde", - "serde_core", -] - -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "js-sys" -version = "0.3.97" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" -dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin", -] - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[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 = "libredox" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" -dependencies = [ - "bitflags", - "libc", - "plain", - "redox_syscall 0.7.4", -] - -[[package]] -name = "libsqlite3-sys" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" -dependencies = [ - "pkg-config", - "vcpkg", -] - -[[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 = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "matchit" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - -[[package]] -name = "md-5" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" -dependencies = [ - "cfg-if", - "digest", -] - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[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 = "mio" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "multimap" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" - -[[package]] -name = "music-agregator" -version = "0.1.0" -dependencies = [ - "async-trait", - "axum 0.8.9", - "base64", - "chrono", - "clap", - "prost", - "reqwest", - "roxmltree", - "serde", - "serde_json", - "serde_yaml", - "sqlx", - "thiserror", - "tokio", - "tonic", - "tonic-build", - "tower-http", - "tracing", - "tracing-subscriber", - "url", - "uuid", -] - -[[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.6", - "smallvec", - "zeroize", -] - -[[package]] -name = "num-conv" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" - -[[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.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "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 = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[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 = "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 = "petgraph" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" -dependencies = [ - "fixedbitset", - "indexmap 2.14.0", -] - -[[package]] -name = "pin-project" -version = "1.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[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 = "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 = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - -[[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 = "prost" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" -dependencies = [ - "bytes", - "prost-derive", -] - -[[package]] -name = "prost-build" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" -dependencies = [ - "heck", - "itertools", - "log", - "multimap", - "once_cell", - "petgraph", - "prettyplease", - "prost", - "prost-types", - "regex", - "syn", - "tempfile", -] - -[[package]] -name = "prost-derive" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" -dependencies = [ - "anyhow", - "itertools", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "prost-types" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" -dependencies = [ - "prost", -] - -[[package]] -name = "psl-types" -version = "2.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" - -[[package]] -name = "publicsuffix" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" -dependencies = [ - "idna", - "psl-types", -] - -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2 0.6.3", - "thiserror", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" -dependencies = [ - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.4", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2 0.6.3", - "tracing", - "windows-sys 0.52.0", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -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.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "redox_syscall" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" -dependencies = [ - "bitflags", -] - -[[package]] -name = "regex" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" - -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64", - "bytes", - "cookie", - "cookie_store", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "mime_guess", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower 0.5.3", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots", -] - -[[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 = "roxmltree" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" - -[[package]] -name = "rsa" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" -dependencies = [ - "const-oid", - "digest", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core 0.6.4", - "signature", - "spki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustc-hash" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" -dependencies = [ - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.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 = "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", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - -[[package]] -name = "serde_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 = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap 2.14.0", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest", - "rand_core 0.6.4", -] - -[[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.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -dependencies = [ - "serde", -] - -[[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.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" -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 = "sqlx" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" -dependencies = [ - "sqlx-core", - "sqlx-macros", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", -] - -[[package]] -name = "sqlx-core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" -dependencies = [ - "base64", - "bytes", - "chrono", - "crc", - "crossbeam-queue", - "either", - "event-listener", - "futures-core", - "futures-intrusive", - "futures-io", - "futures-util", - "hashbrown 0.15.5", - "hashlink", - "indexmap 2.14.0", - "log", - "memchr", - "once_cell", - "percent-encoding", - "serde", - "serde_json", - "sha2", - "smallvec", - "thiserror", - "tokio", - "tokio-stream", - "tracing", - "url", - "uuid", -] - -[[package]] -name = "sqlx-macros" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" -dependencies = [ - "proc-macro2", - "quote", - "sqlx-core", - "sqlx-macros-core", - "syn", -] - -[[package]] -name = "sqlx-macros-core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" -dependencies = [ - "dotenvy", - "either", - "heck", - "hex", - "once_cell", - "proc-macro2", - "quote", - "serde", - "serde_json", - "sha2", - "sqlx-core", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", - "syn", - "tokio", - "url", -] - -[[package]] -name = "sqlx-mysql" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" -dependencies = [ - "atoi", - "base64", - "bitflags", - "byteorder", - "bytes", - "chrono", - "crc", - "digest", - "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.6", - "rsa", - "serde", - "sha1", - "sha2", - "smallvec", - "sqlx-core", - "stringprep", - "thiserror", - "tracing", - "uuid", - "whoami", -] - -[[package]] -name = "sqlx-postgres" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" -dependencies = [ - "atoi", - "base64", - "bitflags", - "byteorder", - "chrono", - "crc", - "dotenvy", - "etcetera", - "futures-channel", - "futures-core", - "futures-util", - "hex", - "hkdf", - "hmac", - "home", - "itoa", - "log", - "md-5", - "memchr", - "once_cell", - "rand 0.8.6", - "serde", - "serde_json", - "sha2", - "smallvec", - "sqlx-core", - "stringprep", - "thiserror", - "tracing", - "uuid", - "whoami", -] - -[[package]] -name = "sqlx-sqlite" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" -dependencies = [ - "atoi", - "chrono", - "flume", - "futures-channel", - "futures-core", - "futures-executor", - "futures-intrusive", - "futures-util", - "libsqlite3-sys", - "log", - "percent-encoding", - "serde", - "serde_urlencoded", - "sqlx-core", - "thiserror", - "tracing", - "url", - "uuid", -] - -[[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 = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.2", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -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.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -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.52.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "socket2 0.6.3", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-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 = "tonic" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" -dependencies = [ - "async-stream", - "async-trait", - "axum 0.7.9", - "base64", - "bytes", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-timeout", - "hyper-util", - "percent-encoding", - "pin-project", - "prost", - "socket2 0.5.10", - "tokio", - "tokio-stream", - "tower 0.4.13", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tonic-build" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11" -dependencies = [ - "prettyplease", - "proc-macro2", - "prost-build", - "prost-types", - "quote", - "syn", -] - -[[package]] -name = "tower" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" -dependencies = [ - "futures-core", - "futures-util", - "indexmap 1.9.3", - "pin-project", - "pin-project-lite", - "rand 0.8.6", - "slab", - "tokio", - "tokio-util", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-http" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" -dependencies = [ - "bitflags", - "bytes", - "futures-util", - "http", - "http-body", - "iri-string", - "pin-project-lite", - "tower 0.5.3", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "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", -] - -[[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 = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "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.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" - -[[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-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - -[[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 = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "uuid" -version = "1.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" -dependencies = [ - "getrandom 0.4.2", - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "version_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.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", -] - -[[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.120" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.70" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.120" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.120" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.120" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver", -] - -[[package]] -name = "web-sys" -version = "0.3.97" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-roots" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" -dependencies = [ - "rustls-pki-types", -] - -[[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", -] - -[[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", -] - -[[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 = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap 2.14.0", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "yoke" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" -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", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerofrom" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" -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", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - -[[package]] -name = "zerotrie" -version = "0.2.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", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index 3bc4636..0000000 --- a/Cargo.toml +++ /dev/null @@ -1,37 +0,0 @@ -[package] -name = "music-agregator" -version = "0.1.0" -edition = "2021" - -[[bin]] -name = "music-agregator" -path = "src/main.rs" - -[dependencies] -axum = "0.8" -tokio = { version = "1", features = ["rt-multi-thread", "macros"] } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -serde_yaml = "0.9" -tower-http = { version = "0.6", features = ["cors", "trace"] } -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -uuid = { version = "1", features = ["v4", "serde"] } -reqwest = { version = "0.12", default-features = false, features = ["json", "cookies", "rustls-tls", "multipart"] } -async-trait = "0.1" -thiserror = "2" -url = "2" -roxmltree = "0.20" -base64 = "0.22" -chrono = { version = "0.4", features = ["serde"] } -clap = { version = "4", features = ["derive"] } -sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono", "json"] } -tonic = "0.12" -prost = "0.13" - -[build-dependencies] -tonic-build = "0.12" - -[profile.release] -opt-level = 3 -lto = true diff --git a/build.rs b/build.rs deleted file mode 100644 index 6622ef3..0000000 --- a/build.rs +++ /dev/null @@ -1,7 +0,0 @@ -fn main() -> Result<(), Box> { - tonic_build::configure() - .build_server(false) - .build_client(true) - .compile_protos(&["proto/metadata/v1/metadata.proto"], &["proto"])?; - Ok(()) -} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..ded7bb4 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,123 @@ +package main + +import ( + "context" + "flag" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/fujin/music-agregator/internal/api" + "github.com/fujin/music-agregator/internal/config" + "github.com/fujin/music-agregator/internal/database" + "github.com/fujin/music-agregator/internal/metadata" + "github.com/fujin/music-agregator/internal/services" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +func main() { + configPath := flag.String("c", "config.yaml", "path to config file") + port := flag.Int("p", 0, "port to listen on (overrides config)") + flag.Parse() + + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}) + + cfg, err := config.Load(*configPath) + if err != nil { + log.Fatal().Err(err).Msg("failed to load config") + } + + if *port != 0 { + cfg.App.Port = *port + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + indexerService, err := services.NewIndexerService(cfg.Indexers) + if err != nil { + log.Fatal().Err(err).Msg("failed to create indexer service") + } + log.Info().Int("count", len(cfg.Indexers)).Msg("initialized indexer service") + + torrentService, err := services.NewTorrentService(cfg.Torrent) + if err != nil { + log.Fatal().Err(err).Msg("failed to create torrent service") + } + + if torrentService.IsConfigured() { + if err := torrentService.Connect(ctx); err != nil { + log.Warn().Err(err).Msg("failed to connect to torrent client") + } else { + log.Info().Str("type", string(cfg.Torrent.ClientType)).Msg("connected to torrent client") + } + } else { + log.Warn().Msg("no torrent client configured") + } + + metadataClient, err := metadata.NewClient(cfg.Metadata.Endpoint) + if err != nil { + log.Fatal().Err(err).Msg("failed to create metadata client") + } + log.Info().Str("endpoint", cfg.Metadata.Endpoint).Msg("initialized metadata client") + + var db *database.DB + if cfg.Database.URL != "" { + db, err = database.New(ctx, cfg.Database.URL) + if err != nil { + log.Warn().Err(err).Msg("failed to connect to database (continuing without db)") + } else { + log.Info().Msg("connected to database") + } + } + + handlers := &api.Handlers{ + IndexerService: indexerService, + TorrentService: torrentService, + MetadataClient: metadataClient, + DB: db, + } + + router := api.NewRouter(handlers) + + server := &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.App.Port), + Handler: router, + } + + go func() { + log.Info().Int("port", cfg.App.Port).Msg("starting server") + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatal().Err(err).Msg("server error") + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Info().Msg("shutting down server...") + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer shutdownCancel() + + if err := server.Shutdown(shutdownCtx); err != nil { + log.Error().Err(err).Msg("server forced to shutdown") + } + + if db != nil { + db.Close() + } + + if torrentService.IsConfigured() { + torrentService.Disconnect(context.Background()) + } + + metadataClient.Close() + + log.Info().Msg("server stopped") +} diff --git a/flake.nix b/flake.nix index 9b92ab6..624b9f3 100644 --- a/flake.nix +++ b/flake.nix @@ -33,16 +33,39 @@ src = ./.; hooks = { nixfmt.enable = true; - rustfmt.enable = true; + gofmt.enable = true; }; }; - music-agregator = pkgs.rustPlatform.buildRustPackage { + music-agregator = pkgs.buildGoModule { pname = "music-agregator"; version = "0.1.0"; src = ./.; - cargoLock.lockFile = ./Cargo.lock; - nativeBuildInputs = [ pkgs.protobuf ]; + vendorHash = "sha256-gad5/pLGWyU45QiEvZJ8xEKNy4K2p5OykKE0nykzh8w="; + + nativeBuildInputs = [ + pkgs.protobuf + pkgs.protoc-gen-go + pkgs.protoc-gen-go-grpc + ]; + + preBuild = '' + export HOME=$(mktemp -d) + mkdir -p pkg/metadatapb/metadata/v1 + ${pkgs.protobuf}/bin/protoc \ + --plugin=protoc-gen-go=${pkgs.protoc-gen-go}/bin/protoc-gen-go \ + --plugin=protoc-gen-go-grpc=${pkgs.protoc-gen-go-grpc}/bin/protoc-gen-go-grpc \ + --proto_path=proto \ + --go_out=pkg/metadatapb --go_opt=paths=source_relative \ + --go-grpc_out=pkg/metadatapb --go-grpc_opt=paths=source_relative \ + proto/metadata/v1/metadata.proto + ''; + + subPackages = [ "cmd/server" ]; + + postInstall = '' + mv $out/bin/server $out/bin/music-agregator + ''; }; in { @@ -65,11 +88,13 @@ gitleaks plantuml protobuf + protoc-gen-go + protoc-gen-go-grpc - rustc - cargo - rustfmt - clippy + go + gopls + gotools + go-tools ]; }; }; diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9524cb2 --- /dev/null +++ b/go.mod @@ -0,0 +1,30 @@ +module github.com/fujin/music-agregator + +go 1.23 + +require ( + github.com/go-chi/chi/v5 v5.1.0 + github.com/go-chi/cors v1.2.1 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.6.0 + github.com/rs/zerolog v1.33.0 + google.golang.org/grpc v1.65.0 + google.golang.org/protobuf v1.34.2 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + golang.org/x/crypto v0.25.0 // indirect + golang.org/x/net v0.27.0 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/text v0.16.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240708141625-4ad9e859172b // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9abac0a --- /dev/null +++ b/go.sum @@ -0,0 +1,70 @@ +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= +github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= +golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= +golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240708141625-4ad9e859172b h1:04+jVzTs2XBnOZcPsLnmrTGqltqJbZQ1Ey26hjYdQQ0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240708141625-4ad9e859172b/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= +google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc= +google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/handlers.go b/internal/api/handlers.go new file mode 100644 index 0000000..081db58 --- /dev/null +++ b/internal/api/handlers.go @@ -0,0 +1,280 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/fujin/music-agregator/internal/database" + "github.com/fujin/music-agregator/internal/indexer" + "github.com/fujin/music-agregator/internal/metadata" + "github.com/fujin/music-agregator/internal/services" + "github.com/go-chi/chi/v5" +) + +type Handlers struct { + IndexerService *services.IndexerService + TorrentService *services.TorrentService + MetadataClient *metadata.Client + DB *database.DB +} + +func (h *Handlers) Health(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (h *Handlers) ListIndexers(w http.ResponseWriter, r *http.Request) { + indexers := h.IndexerService.GetIndexers(r.Context()) + writeJSON(w, http.StatusOK, indexers) +} + +type searchRequest struct { + Artist string `json:"artist"` + Album *string `json:"album,omitempty"` + Year *uint32 `json:"year,omitempty"` + Limit int `json:"limit,omitempty"` + Offset int `json:"offset,omitempty"` +} + +func (h *Handlers) SearchIndexers(w http.ResponseWriter, r *http.Request) { + var req searchRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if req.Limit == 0 { + req.Limit = 20 + } + + criteria := &indexer.MusicSearchCriteria{ + Artist: req.Artist, + Album: req.Album, + Year: req.Year, + Limit: req.Limit, + Offset: req.Offset, + } + + results, err := h.IndexerService.Search(r.Context(), criteria, nil) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, results) +} + +func (h *Handlers) ListTorrents(w http.ResponseWriter, r *http.Request) { + torrents, err := h.TorrentService.ListTorrents(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, torrents) +} + +func (h *Handlers) GetTorrent(w http.ResponseWriter, r *http.Request) { + hash := chi.URLParam(r, "hash") + torrent, err := h.TorrentService.GetTorrent(r.Context(), hash) + if err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + writeJSON(w, http.StatusOK, torrent) +} + +type addTorrentRequest struct { + URL string `json:"url"` + SavePath *string `json:"save_path,omitempty"` +} + +func (h *Handlers) AddTorrent(w http.ResponseWriter, r *http.Request) { + var req addTorrentRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if err := h.TorrentService.AddTorrentURL(r.Context(), req.URL, req.SavePath); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "added"}) +} + +type removeTorrentRequest struct { + DeleteFiles bool `json:"delete_files"` +} + +func (h *Handlers) RemoveTorrent(w http.ResponseWriter, r *http.Request) { + hash := chi.URLParam(r, "hash") + + var req removeTorrentRequest + json.NewDecoder(r.Body).Decode(&req) + + if err := h.TorrentService.RemoveTorrent(r.Context(), hash, req.DeleteFiles); err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "removed"}) +} + +func (h *Handlers) PauseTorrent(w http.ResponseWriter, r *http.Request) { + hash := chi.URLParam(r, "hash") + if err := h.TorrentService.PauseTorrent(r.Context(), hash); err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "paused"}) +} + +func (h *Handlers) ResumeTorrent(w http.ResponseWriter, r *http.Request) { + hash := chi.URLParam(r, "hash") + if err := h.TorrentService.ResumeTorrent(r.Context(), hash); err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "resumed"}) +} + +func (h *Handlers) SearchArtists(w http.ResponseWriter, r *http.Request) { + var req struct { + Query string `json:"query"` + Limit int32 `json:"limit,omitempty"` + Offset int32 `json:"offset,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if req.Limit == 0 { + req.Limit = 10 + } + + result, err := h.MetadataClient.SearchArtists(r.Context(), req.Query, req.Limit, req.Offset) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, result) +} + +func (h *Handlers) GetArtistAlbums(w http.ResponseWriter, r *http.Request) { + artistID := chi.URLParam(r, "id") + + result, err := h.MetadataClient.GetArtistAlbums(r.Context(), artistID, 500, 0) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, result) +} + +func (h *Handlers) Sync(w http.ResponseWriter, r *http.Request) { + var req struct { + Artist string `json:"artist"` + Album *string `json:"album,omitempty"` + Download *bool `json:"download,omitempty"` + Store *bool `json:"store,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + download := true + if req.Download != nil { + download = *req.Download + } + store := true + if req.Store != nil { + store = *req.Store + } + + options := services.SyncOptions{ + Artist: req.Artist, + Album: req.Album, + Download: download, + Store: store, + } + + result, err := services.Sync(r.Context(), options, h.MetadataClient, h.IndexerService, h.TorrentService, h.DB) + if err != nil { + if _, ok := err.(*services.NotFoundError); ok { + writeError(w, http.StatusNotFound, err.Error()) + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, result) +} + +func (h *Handlers) ListLibraryArtists(w http.ResponseWriter, r *http.Request) { + if h.DB == nil { + writeError(w, http.StatusServiceUnavailable, "database not connected") + return + } + + artists, err := h.DB.ListArtists(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, artists) +} + +func (h *Handlers) ListLibraryAlbums(w http.ResponseWriter, r *http.Request) { + if h.DB == nil { + writeError(w, http.StatusServiceUnavailable, "database not connected") + return + } + + albums, err := h.DB.ListAllAlbums(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, albums) +} + +func (h *Handlers) LibraryStats(w http.ResponseWriter, r *http.Request) { + if h.DB == nil { + writeError(w, http.StatusServiceUnavailable, "database not connected") + return + } + + artistCount, err := h.DB.CountArtists(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + albumCount, err := h.DB.CountAlbums(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]int64{ + "artists": artistCount, + "albums": albumCount, + }) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} diff --git a/internal/api/router.go b/internal/api/router.go new file mode 100644 index 0000000..6e1aecb --- /dev/null +++ b/internal/api/router.go @@ -0,0 +1,54 @@ +package api + +import ( + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" +) + +func NewRouter(h *Handlers) *chi.Mux { + r := chi.NewRouter() + + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"}, + AllowCredentials: true, + MaxAge: 300, + })) + + r.Get("/health", h.Health) + + r.Route("/api", func(r chi.Router) { + r.Route("/indexers", func(r chi.Router) { + r.Get("/", h.ListIndexers) + r.Post("/search", h.SearchIndexers) + }) + + r.Route("/torrents", func(r chi.Router) { + r.Get("/", h.ListTorrents) + r.Post("/", h.AddTorrent) + r.Get("/{hash}", h.GetTorrent) + r.Delete("/{hash}", h.RemoveTorrent) + r.Post("/{hash}/pause", h.PauseTorrent) + r.Post("/{hash}/resume", h.ResumeTorrent) + }) + + r.Route("/metadata", func(r chi.Router) { + r.Post("/artists/search", h.SearchArtists) + r.Get("/artists/{id}/albums", h.GetArtistAlbums) + }) + + r.Post("/sync", h.Sync) + + r.Route("/library", func(r chi.Router) { + r.Get("/artists", h.ListLibraryArtists) + r.Get("/albums", h.ListLibraryAlbums) + r.Get("/stats", h.LibraryStats) + }) + }) + + return r +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8cdf15e --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,92 @@ +package config + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +type Config struct { + App AppConfig `yaml:"app"` + Database DatabaseConfig `yaml:"database"` + Metadata MetadataConfig `yaml:"metadata"` + Indexers []IndexerConfig `yaml:"indexers"` + Torrent TorrentConfig `yaml:"torrent"` +} + +type AppConfig struct { + Port int `yaml:"port"` +} + +type DatabaseConfig struct { + URL string `yaml:"url"` +} + +type MetadataConfig struct { + Endpoint string `yaml:"endpoint"` +} + +type IndexerType string + +const ( + IndexerTypeJackett IndexerType = "jackett" + IndexerTypeProwlarr IndexerType = "prowlarr" + IndexerTypeTorznab IndexerType = "torznab" +) + +type IndexerConfig struct { + Name string `yaml:"name"` + IndexerType IndexerType `yaml:"indexer_type"` + URL string `yaml:"url"` + APIKey string `yaml:"api_key"` +} + +type TorrentClientType string + +const ( + TorrentClientQBittorrent TorrentClientType = "qbittorrent" + TorrentClientStub TorrentClientType = "stub" + TorrentClientNone TorrentClientType = "none" +) + +type TorrentConfig struct { + ClientType TorrentClientType `yaml:"client_type"` + URL string `yaml:"url,omitempty"` + Username string `yaml:"username,omitempty"` + Password string `yaml:"password,omitempty"` + LogPath string `yaml:"log_path,omitempty"` + SavePath string `yaml:"save_path,omitempty"` +} + +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse config: %w", err) + } + + if cfg.App.Port == 0 { + cfg.App.Port = 3000 + } + + for i := range cfg.Indexers { + if cfg.Indexers[i].IndexerType == "" { + cfg.Indexers[i].IndexerType = IndexerTypeJackett + } + } + + if cfg.Torrent.ClientType == "" { + cfg.Torrent.ClientType = TorrentClientNone + } + + if cfg.Torrent.SavePath == "" { + cfg.Torrent.SavePath = "/tmp/downloads" + } + + return &cfg, nil +} diff --git a/internal/database/db.go b/internal/database/db.go new file mode 100644 index 0000000..6b84798 --- /dev/null +++ b/internal/database/db.go @@ -0,0 +1,252 @@ +package database + +import ( + "context" + "encoding/json" + "regexp" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +type DB struct { + pool *pgxpool.Pool +} + +func New(ctx context.Context, databaseURL string) (*DB, error) { + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + return nil, err + } + + if err := pool.Ping(ctx); err != nil { + return nil, err + } + + return &DB{pool: pool}, nil +} + +func (db *DB) Close() { + db.pool.Close() +} + +type Artist struct { + ID string + Name string + SortName string + ArtistType string + Description string + Genres []Genre + ExternalIDs []ExternalID +} + +type Album struct { + ID string + Title string + AlbumType string + ReleaseDate string + Genres []Genre +} + +type Genre struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type ExternalID struct { + Source string `json:"source"` + SourceID string `json:"source_id"` + URL string `json:"url"` +} + +type ArtistMetadataRow struct { + ID uuid.UUID `json:"id"` + ForeignArtistID *string `json:"foreign_artist_id"` + Name string `json:"name"` + SortName *string `json:"sort_name"` + ArtistType *string `json:"artist_type"` + Genres json.RawMessage `json:"genres"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type AlbumRow struct { + ID uuid.UUID `json:"id"` + ArtistMetadataID uuid.UUID `json:"artist_metadata_id"` + ForeignAlbumID *string `json:"foreign_album_id"` + Title string `json:"title"` + AlbumType *string `json:"album_type"` + ReleaseDate *time.Time `json:"release_date"` + Monitored bool `json:"monitored"` + AddedAt time.Time `json:"added_at"` +} + +type AlbumWithArtistRow struct { + ID uuid.UUID `json:"id"` + ForeignAlbumID *string `json:"foreign_album_id"` + Title string `json:"title"` + AlbumType *string `json:"album_type"` + ReleaseDate *time.Time `json:"release_date"` + Monitored bool `json:"monitored"` + AddedAt time.Time `json:"added_at"` + ArtistID uuid.UUID `json:"artist_id"` + ArtistName string `json:"artist_name"` +} + +func (db *DB) UpsertArtistMetadata(ctx context.Context, artist *Artist) (uuid.UUID, error) { + id, err := uuid.Parse(artist.ID) + if err != nil { + id = uuid.New() + } + + genres, _ := json.Marshal(artist.Genres) + links, _ := json.Marshal(artist.ExternalIDs) + + var resultID uuid.UUID + err = db.pool.QueryRow(ctx, ` + INSERT INTO artist_metadata ( + id, foreign_artist_id, name, sort_name, disambiguation, + artist_type, status, overview, genres, links, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW()) + ON CONFLICT (foreign_artist_id) DO UPDATE SET + name = EXCLUDED.name, + sort_name = EXCLUDED.sort_name, + artist_type = EXCLUDED.artist_type, + overview = EXCLUDED.overview, + genres = EXCLUDED.genres, + links = EXCLUDED.links, + updated_at = NOW() + RETURNING id + `, id, artist.ID, artist.Name, artist.SortName, artist.Description, + artist.ArtistType, "active", artist.Description, genres, links).Scan(&resultID) + + return resultID, err +} + +var cleanTitleRegex = regexp.MustCompile(`[^a-z0-9]`) + +func (db *DB) UpsertAlbum(ctx context.Context, album *Album, artistMetadataID uuid.UUID) (uuid.UUID, error) { + id, err := uuid.Parse(album.ID) + if err != nil { + id = uuid.New() + } + + genres, _ := json.Marshal(album.Genres) + images, _ := json.Marshal([]any{}) + + var releaseDate *time.Time + if album.ReleaseDate != "" { + if t, err := time.Parse("2006-01-02", album.ReleaseDate); err == nil { + releaseDate = &t + } + } + + cleanTitle := cleanTitleRegex.ReplaceAllString(strings.ToLower(album.Title), "") + + var resultID uuid.UUID + err = db.pool.QueryRow(ctx, ` + INSERT INTO albums ( + id, artist_metadata_id, foreign_album_id, title, clean_title, + overview, album_type, release_date, images, genres + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (foreign_album_id) DO UPDATE SET + title = EXCLUDED.title, + album_type = EXCLUDED.album_type, + release_date = EXCLUDED.release_date, + genres = EXCLUDED.genres + RETURNING id + `, id, artistMetadataID, album.ID, album.Title, cleanTitle, + "", album.AlbumType, releaseDate, images, genres).Scan(&resultID) + + return resultID, err +} + +func (db *DB) ListArtists(ctx context.Context) ([]ArtistMetadataRow, error) { + rows, err := db.pool.Query(ctx, ` + SELECT id, foreign_artist_id, name, sort_name, artist_type, genres, created_at, updated_at + FROM artist_metadata + ORDER BY name + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var artists []ArtistMetadataRow + for rows.Next() { + var a ArtistMetadataRow + err := rows.Scan(&a.ID, &a.ForeignArtistID, &a.Name, &a.SortName, &a.ArtistType, &a.Genres, &a.CreatedAt, &a.UpdatedAt) + if err != nil { + return nil, err + } + artists = append(artists, a) + } + + return artists, nil +} + +func (db *DB) ListAlbumsByArtist(ctx context.Context, artistMetadataID uuid.UUID) ([]AlbumRow, error) { + rows, err := db.pool.Query(ctx, ` + SELECT id, artist_metadata_id, foreign_album_id, title, album_type, release_date, monitored, added_at + FROM albums + WHERE artist_metadata_id = $1 + ORDER BY release_date DESC NULLS LAST + `, artistMetadataID) + if err != nil { + return nil, err + } + defer rows.Close() + + var albums []AlbumRow + for rows.Next() { + var a AlbumRow + err := rows.Scan(&a.ID, &a.ArtistMetadataID, &a.ForeignAlbumID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.Monitored, &a.AddedAt) + if err != nil { + return nil, err + } + albums = append(albums, a) + } + + return albums, nil +} + +func (db *DB) ListAllAlbums(ctx context.Context) ([]AlbumWithArtistRow, error) { + rows, err := db.pool.Query(ctx, ` + SELECT + a.id, a.foreign_album_id, a.title, a.album_type, a.release_date, a.monitored, a.added_at, + am.id as artist_id, am.name as artist_name + FROM albums a + JOIN artist_metadata am ON a.artist_metadata_id = am.id + ORDER BY a.added_at DESC + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var albums []AlbumWithArtistRow + for rows.Next() { + var a AlbumWithArtistRow + err := rows.Scan(&a.ID, &a.ForeignAlbumID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.Monitored, &a.AddedAt, &a.ArtistID, &a.ArtistName) + if err != nil { + return nil, err + } + albums = append(albums, a) + } + + return albums, nil +} + +func (db *DB) CountArtists(ctx context.Context) (int64, error) { + var count int64 + err := db.pool.QueryRow(ctx, "SELECT COUNT(*) FROM artist_metadata").Scan(&count) + return count, err +} + +func (db *DB) CountAlbums(ctx context.Context) (int64, error) { + var count int64 + err := db.pool.QueryRow(ctx, "SELECT COUNT(*) FROM albums").Scan(&count) + return count, err +} diff --git a/internal/indexer/search.go b/internal/indexer/search.go new file mode 100644 index 0000000..8ecbcff --- /dev/null +++ b/internal/indexer/search.go @@ -0,0 +1,54 @@ +package indexer + +import ( + "regexp" + "strings" +) + +type MusicSearchCriteria struct { + Artist string + Album *string + Year *uint32 + Limit int + Offset int +} + +func (c *MusicSearchCriteria) CleanArtist() string { + return cleanSearchTerm(c.Artist) +} + +func (c *MusicSearchCriteria) CleanAlbum() *string { + if c.Album == nil { + return nil + } + cleaned := cleanSearchTerm(*c.Album) + return &cleaned +} + +var cleanRegex = regexp.MustCompile(`[^\w\s]`) + +func cleanSearchTerm(s string) string { + s = cleanRegex.ReplaceAllString(s, " ") + fields := strings.Fields(s) + return strings.Join(fields, " ") +} + +type SearchResult struct { + GUID string `json:"guid"` + Title string `json:"title"` + DownloadURL string `json:"download_url"` + InfoURL *string `json:"info_url,omitempty"` + Size uint64 `json:"size"` + PublishDate *string `json:"publish_date,omitempty"` + Artist *string `json:"artist,omitempty"` + Album *string `json:"album,omitempty"` + Year *uint32 `json:"year,omitempty"` + Label *string `json:"label,omitempty"` + Seeders *int `json:"seeders,omitempty"` + Leechers *int `json:"leechers,omitempty"` + Grabs *int `json:"grabs,omitempty"` + Infohash *string `json:"infohash,omitempty"` + MagnetURL *string `json:"magnet_url,omitempty"` + Indexer string `json:"indexer"` + Categories []uint32 `json:"categories"` +} diff --git a/internal/indexer/torznab.go b/internal/indexer/torznab.go new file mode 100644 index 0000000..6f2dfdd --- /dev/null +++ b/internal/indexer/torznab.go @@ -0,0 +1,289 @@ +package indexer + +import ( + "context" + "encoding/xml" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" +) + +var ( + ErrAuthFailed = errors.New("authentication failed") + ErrSearchFailed = errors.New("search failed") + ErrRateLimited = errors.New("rate limited") + ErrUnavailable = errors.New("indexer unavailable") + ErrParseError = errors.New("parse error") +) + +type TorznabIndexer struct { + name string + baseURL *url.URL + apiKey string + categories []uint32 + client *http.Client +} + +func NewTorznabIndexer(name, baseURL, apiKey string) (*TorznabIndexer, error) { + u, err := url.Parse(baseURL) + if err != nil { + return nil, fmt.Errorf("invalid URL: %w", err) + } + + return &TorznabIndexer{ + name: name, + baseURL: u, + apiKey: apiKey, + categories: []uint32{3000, 3010, 3040}, + client: &http.Client{}, + }, nil +} + +func (i *TorznabIndexer) WithCategories(cats []uint32) *TorznabIndexer { + i.categories = cats + return i +} + +func (i *TorznabIndexer) Name() string { + return i.name +} + +func (i *TorznabIndexer) buildSearchURL(criteria *MusicSearchCriteria) string { + u := *i.baseURL + q := u.Query() + + q.Set("t", "music") + q.Set("apikey", i.apiKey) + q.Set("extended", "1") + + var cats []string + for _, c := range i.categories { + cats = append(cats, strconv.FormatUint(uint64(c), 10)) + } + q.Set("cat", strings.Join(cats, ",")) + + var qParts []string + qParts = append(qParts, criteria.CleanArtist()) + if album := criteria.CleanAlbum(); album != nil { + qParts = append(qParts, *album) + } + if criteria.Year != nil { + qParts = append(qParts, strconv.FormatUint(uint64(*criteria.Year), 10)) + } + q.Set("q", strings.Join(qParts, " ")) + + q.Set("limit", strconv.Itoa(criteria.Limit)) + q.Set("offset", strconv.Itoa(criteria.Offset)) + + u.RawQuery = q.Encode() + return u.String() +} + +func (i *TorznabIndexer) Search(ctx context.Context, criteria *MusicSearchCriteria) ([]SearchResult, error) { + searchURL := i.buildSearchURL(criteria) + + req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) + if err != nil { + return nil, err + } + + resp, err := i.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusTooManyRequests { + retryAfter := 60 + if ra := resp.Header.Get("Retry-After"); ra != "" { + if v, err := strconv.Atoi(ra); err == nil { + retryAfter = v + } + } + return nil, fmt.Errorf("%w: retry after %d seconds", ErrRateLimited, retryAfter) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("%w: HTTP %d", ErrUnavailable, resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + return i.parseResponse(body) +} + +func (i *TorznabIndexer) TestConnection(ctx context.Context) error { + u := *i.baseURL + q := u.Query() + q.Set("t", "caps") + q.Set("apikey", i.apiKey) + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil) + if err != nil { + return err + } + + resp, err := i.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("%w: HTTP %d", ErrUnavailable, resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + xmlStr := string(body) + if strings.Contains(xmlStr, " 0 { + if y, err := strconv.ParseUint(parts[0], 10, 32); err == nil { + y32 := uint32(y) + year = &y32 + } + } + } + + dlResult := downloadAlbum(ctx, artist.Name, album.Title, year, indexerService, torrentService) + downloadStatus = &dlResult.status + torrentHash = dlResult.torrentHash + indexerName = dlResult.indexer + dlError = dlResult.err + + switch dlResult.status { + case DownloadStatusAdded: + albumsDownloaded++ + case DownloadStatusNoResults: + albumsNoResults++ + case DownloadStatusFailed, DownloadStatusSkipped: + albumsFailed++ + } + } + + results = append(results, AlbumSyncResult{ + AlbumID: album.Id, + AlbumTitle: album.Title, + Stored: stored, + DownloadStatus: downloadStatus, + TorrentHash: torrentHash, + Indexer: indexerName, + Error: dlError, + }) + } + + return &SyncResult{ + ArtistID: artist.Id, + ArtistName: artist.Name, + TotalAlbums: len(albumsToProcess), + AlbumsStored: albumsStored, + AlbumsDownloaded: albumsDownloaded, + AlbumsNoResults: albumsNoResults, + AlbumsFailed: albumsFailed, + Results: results, + }, nil +} + +func downloadAlbum( + ctx context.Context, + artistName, albumTitle string, + year *uint32, + indexerService *IndexerService, + torrentService *TorrentService, +) downloadResult { + albumStr := albumTitle + criteria := &indexer.MusicSearchCriteria{ + Artist: artistName, + Album: &albumStr, + Year: year, + Limit: 20, + Offset: 0, + } + + searchResults, err := indexerService.Search(ctx, criteria, nil) + if err != nil { + errStr := "indexer search failed: " + err.Error() + return downloadResult{ + status: DownloadStatusFailed, + err: &errStr, + } + } + + if len(searchResults) == 0 { + return downloadResult{status: DownloadStatusNoResults} + } + + best := selectBestResult(searchResults) + + if err := torrentService.AddTorrentURL(ctx, best.DownloadURL, nil); err != nil { + errStr := "failed to add torrent: " + err.Error() + return downloadResult{ + status: DownloadStatusFailed, + indexer: &best.Indexer, + err: &errStr, + } + } + + return downloadResult{ + status: DownloadStatusAdded, + torrentHash: best.Infohash, + indexer: &best.Indexer, + } +} + +func selectBestResult(results []indexer.SearchResult) *indexer.SearchResult { + var best *indexer.SearchResult + var bestScore int64 = -1 + + for i := range results { + r := &results[i] + seeders := 0 + if r.Seeders != nil { + seeders = *r.Seeders + } + score := int64(seeders) + if strings.Contains(strings.ToLower(r.Title), "flac") { + score += 1000 + } + + if score > bestScore { + bestScore = score + best = r + } + } + + return best +} + +func parseUUID(s string) ([16]byte, error) { + var id [16]byte + s = strings.ReplaceAll(s, "-", "") + if len(s) != 32 { + return id, &NotFoundError{Message: "invalid uuid"} + } + for i := 0; i < 16; i++ { + b, err := strconv.ParseUint(s[i*2:i*2+2], 16, 8) + if err != nil { + return id, err + } + id[i] = byte(b) + } + return id, nil +} + +type NotFoundError struct { + Message string +} + +func (e *NotFoundError) Error() string { + return e.Message +} diff --git a/internal/services/indexer.go b/internal/services/indexer.go new file mode 100644 index 0000000..819dfab --- /dev/null +++ b/internal/services/indexer.go @@ -0,0 +1,84 @@ +package services + +import ( + "context" + "fmt" + "strings" + + "github.com/fujin/music-agregator/internal/config" + "github.com/fujin/music-agregator/internal/indexer" +) + +type IndexerService struct { + indexers []*indexer.TorznabIndexer +} + +type IndexerInfo struct { + Name string `json:"name"` + URL string `json:"url"` + Healthy bool `json:"healthy"` +} + +func NewIndexerService(configs []config.IndexerConfig) (*IndexerService, error) { + var indexers []*indexer.TorznabIndexer + + for _, cfg := range configs { + url := buildTorznabURL(cfg) + idx, err := indexer.NewTorznabIndexer(cfg.Name, url, cfg.APIKey) + if err != nil { + return nil, fmt.Errorf("failed to create indexer %s: %w", cfg.Name, err) + } + indexers = append(indexers, idx) + } + + return &IndexerService{indexers: indexers}, nil +} + +func buildTorznabURL(cfg config.IndexerConfig) string { + url := strings.TrimRight(cfg.URL, "/") + + switch cfg.IndexerType { + case config.IndexerTypeJackett: + if !strings.Contains(url, "/api/") { + url = fmt.Sprintf("%s/api/v2.0/indexers/all/results/torznab", url) + } + case config.IndexerTypeProwlarr: + if !strings.Contains(url, "/api/") { + url = fmt.Sprintf("%s/api/v1/indexer/all/newznab", url) + } + } + + return url +} + +func (s *IndexerService) Search(ctx context.Context, criteria *indexer.MusicSearchCriteria, indexerName *string) ([]indexer.SearchResult, error) { + var results []indexer.SearchResult + + for _, idx := range s.indexers { + if indexerName != nil && idx.Name() != *indexerName { + continue + } + + r, err := idx.Search(ctx, criteria) + if err != nil { + continue + } + results = append(results, r...) + } + + return results, nil +} + +func (s *IndexerService) GetIndexers(ctx context.Context) []IndexerInfo { + var infos []IndexerInfo + + for _, idx := range s.indexers { + healthy := idx.TestConnection(ctx) == nil + infos = append(infos, IndexerInfo{ + Name: idx.Name(), + Healthy: healthy, + }) + } + + return infos +} diff --git a/internal/services/torrent.go b/internal/services/torrent.go new file mode 100644 index 0000000..a479ec8 --- /dev/null +++ b/internal/services/torrent.go @@ -0,0 +1,98 @@ +package services + +import ( + "context" + + "github.com/fujin/music-agregator/internal/config" + "github.com/fujin/music-agregator/internal/torrent" +) + +type TorrentService struct { + client torrent.Client +} + +func NewTorrentService(cfg config.TorrentConfig) (*TorrentService, error) { + var client torrent.Client + + switch cfg.ClientType { + case config.TorrentClientQBittorrent: + c, err := torrent.NewQBittorrentClient(cfg.URL, cfg.Username, cfg.Password) + if err != nil { + return nil, err + } + client = c + case config.TorrentClientStub: + client = torrent.NewStubClient(cfg.LogPath, cfg.SavePath) + default: + return &TorrentService{client: nil}, nil + } + + return &TorrentService{client: client}, nil +} + +func (s *TorrentService) Connect(ctx context.Context) error { + if s.client == nil { + return nil + } + return s.client.Connect(ctx) +} + +func (s *TorrentService) Disconnect(ctx context.Context) error { + if s.client == nil { + return nil + } + return s.client.Disconnect(ctx) +} + +func (s *TorrentService) ListTorrents(ctx context.Context) ([]torrent.TorrentInfo, error) { + if s.client == nil { + return []torrent.TorrentInfo{}, nil + } + return s.client.ListTorrents(ctx) +} + +func (s *TorrentService) GetTorrent(ctx context.Context, hash string) (*torrent.TorrentInfo, error) { + if s.client == nil { + return nil, torrent.ErrTorrentNotFound + } + return s.client.GetTorrent(ctx, hash) +} + +func (s *TorrentService) AddTorrentURL(ctx context.Context, url string, savePath *string) error { + if s.client == nil { + return nil + } + return s.client.AddTorrentURL(ctx, url, savePath) +} + +func (s *TorrentService) AddTorrentFile(ctx context.Context, data []byte, savePath *string) error { + if s.client == nil { + return nil + } + return s.client.AddTorrentFile(ctx, data, savePath) +} + +func (s *TorrentService) RemoveTorrent(ctx context.Context, hash string, deleteFiles bool) error { + if s.client == nil { + return nil + } + return s.client.RemoveTorrent(ctx, hash, deleteFiles) +} + +func (s *TorrentService) PauseTorrent(ctx context.Context, hash string) error { + if s.client == nil { + return nil + } + return s.client.PauseTorrent(ctx, hash) +} + +func (s *TorrentService) ResumeTorrent(ctx context.Context, hash string) error { + if s.client == nil { + return nil + } + return s.client.ResumeTorrent(ctx, hash) +} + +func (s *TorrentService) IsConfigured() bool { + return s.client != nil +} diff --git a/internal/torrent/client.go b/internal/torrent/client.go new file mode 100644 index 0000000..3516a08 --- /dev/null +++ b/internal/torrent/client.go @@ -0,0 +1,49 @@ +package torrent + +import ( + "context" + "errors" +) + +var ( + ErrNotConnected = errors.New("not connected") + ErrAuthFailed = errors.New("authentication failed") + ErrTorrentNotFound = errors.New("torrent not found") + ErrInvalidRequest = errors.New("invalid request") + ErrConnectionFailed = errors.New("connection failed") +) + +type TorrentState string + +const ( + StateDownloading TorrentState = "downloading" + StateSeeding TorrentState = "seeding" + StatePaused TorrentState = "paused" + StateQueued TorrentState = "queued" + StateChecking TorrentState = "checking" + StateError TorrentState = "error" + StateUnknown TorrentState = "unknown" +) + +type TorrentInfo struct { + Hash string `json:"hash"` + Name string `json:"name"` + Size uint64 `json:"size"` + Progress float64 `json:"progress"` + DownloadSpeed uint64 `json:"download_speed"` + UploadSpeed uint64 `json:"upload_speed"` + State TorrentState `json:"state"` + SavePath string `json:"save_path"` +} + +type Client interface { + Connect(ctx context.Context) error + Disconnect(ctx context.Context) error + ListTorrents(ctx context.Context) ([]TorrentInfo, error) + GetTorrent(ctx context.Context, hash string) (*TorrentInfo, error) + AddTorrentURL(ctx context.Context, url string, savePath *string) error + AddTorrentFile(ctx context.Context, data []byte, savePath *string) error + RemoveTorrent(ctx context.Context, hash string, deleteFiles bool) error + PauseTorrent(ctx context.Context, hash string) error + ResumeTorrent(ctx context.Context, hash string) error +} diff --git a/internal/torrent/qbittorrent.go b/internal/torrent/qbittorrent.go new file mode 100644 index 0000000..bca47bb --- /dev/null +++ b/internal/torrent/qbittorrent.go @@ -0,0 +1,349 @@ +package torrent + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/cookiejar" + "net/url" + "strings" + "sync" +) + +type QBittorrentClient struct { + baseURL string + username string + password string + client *http.Client + connected bool + mu sync.RWMutex +} + +type qbTorrent struct { + Hash string `json:"hash"` + Name string `json:"name"` + Size int64 `json:"size"` + Progress float64 `json:"progress"` + DLSpeed int64 `json:"dlspeed"` + UPSpeed int64 `json:"upspeed"` + State string `json:"state"` + SavePath string `json:"save_path"` +} + +func NewQBittorrentClient(baseURL, username, password string) (*QBittorrentClient, error) { + jar, err := cookiejar.New(nil) + if err != nil { + return nil, err + } + + return &QBittorrentClient{ + baseURL: strings.TrimRight(baseURL, "/"), + username: username, + password: password, + client: &http.Client{Jar: jar}, + }, nil +} + +func (c *QBittorrentClient) apiURL(path string) string { + return fmt.Sprintf("%s/api/v2%s", c.baseURL, path) +} + +func (c *QBittorrentClient) mapState(state string) TorrentState { + switch state { + case "downloading", "forcedDL", "metaDL", "allocating": + return StateDownloading + case "uploading", "forcedUP", "stalledUP": + return StateSeeding + case "pausedDL", "pausedUP": + return StatePaused + case "queuedDL", "queuedUP": + return StateQueued + case "checkingDL", "checkingUP", "checkingResumeData": + return StateChecking + case "error", "missingFiles": + return StateError + default: + return StateUnknown + } +} + +func (c *QBittorrentClient) mapTorrent(t qbTorrent) TorrentInfo { + size := uint64(0) + if t.Size > 0 { + size = uint64(t.Size) + } + dlSpeed := uint64(0) + if t.DLSpeed > 0 { + dlSpeed = uint64(t.DLSpeed) + } + upSpeed := uint64(0) + if t.UPSpeed > 0 { + upSpeed = uint64(t.UPSpeed) + } + + return TorrentInfo{ + Hash: t.Hash, + Name: t.Name, + Size: size, + Progress: t.Progress, + DownloadSpeed: dlSpeed, + UploadSpeed: upSpeed, + State: c.mapState(t.State), + SavePath: t.SavePath, + } +} + +func (c *QBittorrentClient) ensureConnected() error { + c.mu.RLock() + defer c.mu.RUnlock() + if !c.connected { + return ErrNotConnected + } + return nil +} + +func (c *QBittorrentClient) Connect(ctx context.Context) error { + data := url.Values{} + data.Set("username", c.username) + data.Set("password", c.password) + + req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/auth/login"), strings.NewReader(data.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.client.Do(req) + if err != nil { + return fmt.Errorf("%w: %v", ErrConnectionFailed, err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if string(body) == "Ok." { + c.mu.Lock() + c.connected = true + c.mu.Unlock() + return nil + } + + return ErrAuthFailed +} + +func (c *QBittorrentClient) Disconnect(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/auth/logout"), nil) + if err != nil { + return err + } + + c.client.Do(req) + c.mu.Lock() + c.connected = false + c.mu.Unlock() + return nil +} + +func (c *QBittorrentClient) ListTorrents(ctx context.Context) ([]TorrentInfo, error) { + if err := c.ensureConnected(); err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, "GET", c.apiURL("/torrents/info"), nil) + if err != nil { + return nil, err + } + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var torrents []qbTorrent + if err := json.NewDecoder(resp.Body).Decode(&torrents); err != nil { + return nil, err + } + + result := make([]TorrentInfo, len(torrents)) + for i, t := range torrents { + result[i] = c.mapTorrent(t) + } + return result, nil +} + +func (c *QBittorrentClient) GetTorrent(ctx context.Context, hash string) (*TorrentInfo, error) { + if err := c.ensureConnected(); err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, "GET", c.apiURL("/torrents/info")+"?hashes="+hash, nil) + if err != nil { + return nil, err + } + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var torrents []qbTorrent + if err := json.NewDecoder(resp.Body).Decode(&torrents); err != nil { + return nil, err + } + + if len(torrents) == 0 { + return nil, ErrTorrentNotFound + } + + info := c.mapTorrent(torrents[0]) + return &info, nil +} + +func (c *QBittorrentClient) AddTorrentURL(ctx context.Context, torrentURL string, savePath *string) error { + if err := c.ensureConnected(); err != nil { + return err + } + + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + w.WriteField("urls", torrentURL) + if savePath != nil { + w.WriteField("savepath", *savePath) + } + w.Close() + + req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/torrents/add"), &buf) + if err != nil { + return err + } + req.Header.Set("Content-Type", w.FormDataContentType()) + + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if !statusOK(resp.StatusCode) { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("%w: %s", ErrInvalidRequest, string(body)) + } + + return nil +} + +func (c *QBittorrentClient) AddTorrentFile(ctx context.Context, data []byte, savePath *string) error { + if err := c.ensureConnected(); err != nil { + return err + } + + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + + part, err := w.CreateFormFile("torrents", "torrent.torrent") + if err != nil { + return err + } + part.Write(data) + + if savePath != nil { + w.WriteField("savepath", *savePath) + } + w.Close() + + req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/torrents/add"), &buf) + if err != nil { + return err + } + req.Header.Set("Content-Type", w.FormDataContentType()) + + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if !statusOK(resp.StatusCode) { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("%w: %s", ErrInvalidRequest, string(body)) + } + + return nil +} + +func (c *QBittorrentClient) RemoveTorrent(ctx context.Context, hash string, deleteFiles bool) error { + if err := c.ensureConnected(); err != nil { + return err + } + + data := url.Values{} + data.Set("hashes", hash) + if deleteFiles { + data.Set("deleteFiles", "true") + } else { + data.Set("deleteFiles", "false") + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/torrents/delete"), strings.NewReader(data.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if !statusOK(resp.StatusCode) { + return ErrTorrentNotFound + } + + return nil +} + +func (c *QBittorrentClient) PauseTorrent(ctx context.Context, hash string) error { + if err := c.ensureConnected(); err != nil { + return err + } + + data := url.Values{} + data.Set("hashes", hash) + + req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/torrents/pause"), strings.NewReader(data.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + c.client.Do(req) + return nil +} + +func (c *QBittorrentClient) ResumeTorrent(ctx context.Context, hash string) error { + if err := c.ensureConnected(); err != nil { + return err + } + + data := url.Values{} + data.Set("hashes", hash) + + req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/torrents/resume"), strings.NewReader(data.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + c.client.Do(req) + return nil +} + +func statusOK(code int) bool { + return code >= 200 && code < 300 +} diff --git a/internal/torrent/stub.go b/internal/torrent/stub.go new file mode 100644 index 0000000..8ad935f --- /dev/null +++ b/internal/torrent/stub.go @@ -0,0 +1,90 @@ +package torrent + +import ( + "context" + "fmt" + "os" + "sync" + "time" +) + +type StubClient struct { + logPath string + savePath string + mu sync.Mutex +} + +func NewStubClient(logPath, savePath string) *StubClient { + return &StubClient{ + logPath: logPath, + savePath: savePath, + } +} + +func (c *StubClient) log(format string, args ...any) { + c.mu.Lock() + defer c.mu.Unlock() + + f, err := os.OpenFile(c.logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return + } + defer f.Close() + + timestamp := time.Now().Format(time.RFC3339) + msg := fmt.Sprintf(format, args...) + fmt.Fprintf(f, "[%s] %s\n", timestamp, msg) +} + +func (c *StubClient) Connect(ctx context.Context) error { + c.log("CONNECT") + return nil +} + +func (c *StubClient) Disconnect(ctx context.Context) error { + c.log("DISCONNECT") + return nil +} + +func (c *StubClient) ListTorrents(ctx context.Context) ([]TorrentInfo, error) { + c.log("LIST_TORRENTS") + return []TorrentInfo{}, nil +} + +func (c *StubClient) GetTorrent(ctx context.Context, hash string) (*TorrentInfo, error) { + c.log("GET_TORRENT hash=%s", hash) + return nil, ErrTorrentNotFound +} + +func (c *StubClient) AddTorrentURL(ctx context.Context, url string, savePath *string) error { + path := c.savePath + if savePath != nil { + path = *savePath + } + c.log("ADD_TORRENT_URL url=%s save_path=%s", url, path) + return nil +} + +func (c *StubClient) AddTorrentFile(ctx context.Context, data []byte, savePath *string) error { + path := c.savePath + if savePath != nil { + path = *savePath + } + c.log("ADD_TORRENT_FILE size=%d save_path=%s", len(data), path) + return nil +} + +func (c *StubClient) RemoveTorrent(ctx context.Context, hash string, deleteFiles bool) error { + c.log("REMOVE_TORRENT hash=%s delete_files=%t", hash, deleteFiles) + return nil +} + +func (c *StubClient) PauseTorrent(ctx context.Context, hash string) error { + c.log("PAUSE_TORRENT hash=%s", hash) + return nil +} + +func (c *StubClient) ResumeTorrent(ctx context.Context, hash string) error { + c.log("RESUME_TORRENT hash=%s", hash) + return nil +} diff --git a/proto/metadata/v1/metadata.proto b/proto/metadata/v1/metadata.proto index d0f2b77..51abbb1 100644 --- a/proto/metadata/v1/metadata.proto +++ b/proto/metadata/v1/metadata.proto @@ -2,7 +2,7 @@ syntax = "proto3"; package metadata.v1; -option go_package = "github.com/metadata-agregator/pkg/gen/metadata/v1;metadatav1"; +option go_package = "github.com/fujin/music-agregator/pkg/metadatapb/metadata/v1;metadatav1"; enum Provider { PROVIDER_UNSPECIFIED = 0; diff --git a/src/api/indexer_controller.rs b/src/api/indexer_controller.rs deleted file mode 100644 index 03289f0..0000000 --- a/src/api/indexer_controller.rs +++ /dev/null @@ -1,92 +0,0 @@ -use axum::{ - extract::{Path, State}, - http::StatusCode, - routing::{get, post}, - Json, Router, -}; -use serde::{Deserialize, Serialize}; - -use crate::indexer::{MusicSearchCriteria, SearchResult}; -use crate::services::IndexerInfo; -use crate::AppState; - -pub fn routes() -> Router { - Router::new() - .route("/", get(list_indexers)) - .route("/search", post(search)) - .route("/{name}/test", get(test_indexer)) -} - -async fn list_indexers(State(state): State) -> Json> { - let state = state.read().await; - Json(state.indexer_service.list_indexers()) -} - -#[derive(Debug, Deserialize)] -pub struct SearchRequest { - pub artist: String, - pub album: Option, - pub year: Option, - pub limit: Option, - pub indexer: Option, -} - -#[derive(Debug, Serialize)] -pub struct SearchResponse { - pub results: Vec, - pub total: usize, -} - -async fn search( - State(state): State, - Json(req): Json, -) -> Result, (StatusCode, String)> { - let mut criteria = MusicSearchCriteria::new(&req.artist); - - if let Some(album) = &req.album { - criteria = criteria.with_album(album); - } - if let Some(year) = req.year { - criteria = criteria.with_year(year); - } - if let Some(limit) = req.limit { - criteria = criteria.with_limit(limit); - } - - let state = state.read().await; - let results = state - .indexer_service - .search(&criteria, req.indexer.as_deref()) - .await - .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?; - - let total = results.len(); - Ok(Json(SearchResponse { results, total })) -} - -#[derive(Debug, Serialize)] -pub struct TestResponse { - pub success: bool, - pub message: String, -} - -async fn test_indexer( - State(state): State, - Path(name): Path, -) -> Result, (StatusCode, Json)> { - let state = state.read().await; - - match state.indexer_service.test_indexer(&name).await { - Ok(()) => Ok(Json(TestResponse { - success: true, - message: "Connection successful".to_string(), - })), - Err(e) => Err(( - StatusCode::BAD_GATEWAY, - Json(TestResponse { - success: false, - message: e.to_string(), - }), - )), - } -} diff --git a/src/api/library_controller.rs b/src/api/library_controller.rs deleted file mode 100644 index 8570aed..0000000 --- a/src/api/library_controller.rs +++ /dev/null @@ -1,124 +0,0 @@ -use axum::{ - extract::{Path, State}, - http::StatusCode, - routing::get, - Json, Router, -}; -use serde::Serialize; -use uuid::Uuid; - -use crate::services::{AlbumRow, AlbumWithArtistRow, ArtistMetadataRow}; -use crate::AppState; - -pub fn routes() -> Router { - Router::new() - .route("/artists", get(list_artists)) - .route("/artists/{id}/albums", get(list_artist_albums)) - .route("/albums", get(list_albums)) - .route("/stats", get(library_stats)) -} - -#[derive(Serialize)] -struct ArtistsResponse { - artists: Vec, - total: usize, -} - -async fn list_artists( - State(state): State, -) -> Result, (StatusCode, String)> { - let state = state.read().await; - - let db = state.db_service.as_ref().ok_or(( - StatusCode::SERVICE_UNAVAILABLE, - "database not connected".to_string(), - ))?; - - let artists = db - .list_artists() - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - - let total = artists.len(); - Ok(Json(ArtistsResponse { artists, total })) -} - -#[derive(Serialize)] -struct ArtistAlbumsResponse { - albums: Vec, - total: usize, -} - -async fn list_artist_albums( - State(state): State, - Path(id): Path, -) -> Result, (StatusCode, String)> { - let state = state.read().await; - - let db = state.db_service.as_ref().ok_or(( - StatusCode::SERVICE_UNAVAILABLE, - "database not connected".to_string(), - ))?; - - let albums = db - .list_albums_by_artist(id) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - - let total = albums.len(); - Ok(Json(ArtistAlbumsResponse { albums, total })) -} - -#[derive(Serialize)] -struct AlbumsResponse { - albums: Vec, - total: usize, -} - -async fn list_albums( - State(state): State, -) -> Result, (StatusCode, String)> { - let state = state.read().await; - - let db = state.db_service.as_ref().ok_or(( - StatusCode::SERVICE_UNAVAILABLE, - "database not connected".to_string(), - ))?; - - let albums = db - .list_all_albums() - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - - let total = albums.len(); - Ok(Json(AlbumsResponse { albums, total })) -} - -#[derive(Serialize)] -struct LibraryStats { - artists: i64, - albums: i64, -} - -async fn library_stats( - State(state): State, -) -> Result, (StatusCode, String)> { - let state = state.read().await; - - let db = state.db_service.as_ref().ok_or(( - StatusCode::SERVICE_UNAVAILABLE, - "database not connected".to_string(), - ))?; - - let artists = db - .count_artists() - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - - let albums = db - .count_albums() - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - - Ok(Json(LibraryStats { artists, albums })) -} diff --git a/src/api/metadata_controller.rs b/src/api/metadata_controller.rs deleted file mode 100644 index e9de5ed..0000000 --- a/src/api/metadata_controller.rs +++ /dev/null @@ -1,342 +0,0 @@ -use axum::{ - extract::{Path, Query, State}, - http::StatusCode, - routing::{get, post}, - Json, Router, -}; -use serde::{Deserialize, Serialize}; - -use crate::metadata::proto::{Album, Artist, ArtistCredit, ExternalId, Genre, Label, Track, Work}; -use crate::AppState; - -pub fn routes() -> Router { - Router::new() - .route("/artists/search", get(search_artists)) - .route("/artists/{id}", get(get_artist)) - .route("/artists/{id}/albums", get(get_artist_albums)) - .route("/artists/sync", post(sync_artist)) - .route("/albums/{id}", get(get_album)) - .route("/albums/{id}/tracks", get(get_album_tracks)) - .route("/status", get(connection_status)) -} - -#[derive(Debug, Deserialize)] -pub struct SearchQuery { - pub q: String, - pub limit: Option, - pub offset: Option, -} - -#[derive(Debug, Serialize)] -pub struct ArtistResponse { - pub id: String, - pub name: String, - pub sort_name: String, - pub artist_type: String, - pub country: String, - pub formed_date: String, - pub disbanded_date: String, - pub description: String, - pub image_url: String, - pub genres: Vec, - pub external_ids: Vec, -} - -#[derive(Debug, Serialize)] -pub struct GenreResponse { - pub id: String, - pub name: String, -} - -#[derive(Debug, Serialize)] -pub struct ExternalIdResponse { - pub source: String, - pub source_id: String, - pub url: String, -} - -#[derive(Debug, Serialize)] -pub struct AlbumResponse { - pub id: String, - pub title: String, - pub album_type: String, - pub release_date: String, - pub upc: String, - pub total_tracks: i32, - pub total_discs: i32, - pub cover_url: String, - pub artists: Vec, - pub label: Option, - pub genres: Vec, - pub external_ids: Vec, -} - -#[derive(Debug, Serialize)] -pub struct ArtistCreditResponse { - pub artist: Option, - pub role: String, - pub position: i32, - pub join_phrase: String, -} - -#[derive(Debug, Serialize)] -pub struct LabelResponse { - pub id: String, - pub name: String, - pub country: String, -} - -#[derive(Debug, Serialize)] -pub struct TrackResponse { - pub id: String, - pub title: String, - pub duration_ms: i32, - pub isrc: String, - pub explicit: bool, - pub disc_number: i32, - pub track_number: i32, - pub artists: Vec, - pub work: Option, - pub external_ids: Vec, -} - -#[derive(Debug, Serialize)] -pub struct WorkResponse { - pub id: String, - pub title: String, - pub work_type: String, - pub language: String, -} - -#[derive(Debug, Serialize)] -pub struct SearchArtistsResponse { - pub artists: Vec, - pub total: i32, -} - -#[derive(Debug, Serialize)] -pub struct ArtistAlbumsResponse { - pub albums: Vec, - pub total: i32, -} - -#[derive(Debug, Serialize)] -pub struct AlbumTracksResponse { - pub tracks: Vec, -} - -#[derive(Debug, Deserialize)] -pub struct SyncRequest { - pub name: String, -} - -#[derive(Debug, Serialize)] -pub struct SyncResponse { - pub artist: Option, - pub albums_synced: i32, - pub tracks_synced: i32, -} - -fn map_genre(g: &Genre) -> GenreResponse { - GenreResponse { - id: g.id.clone(), - name: g.name.clone(), - } -} - -fn map_external_id(e: &ExternalId) -> ExternalIdResponse { - ExternalIdResponse { - source: e.source.clone(), - source_id: e.source_id.clone(), - url: e.url.clone(), - } -} - -fn map_artist(a: &Artist) -> ArtistResponse { - ArtistResponse { - id: a.id.clone(), - name: a.name.clone(), - sort_name: a.sort_name.clone(), - artist_type: a.artist_type.clone(), - country: a.country.clone(), - formed_date: a.formed_date.clone(), - disbanded_date: a.disbanded_date.clone(), - description: a.description.clone(), - image_url: a.image_url.clone(), - genres: a.genres.iter().map(map_genre).collect(), - external_ids: a.external_ids.iter().map(map_external_id).collect(), - } -} - -fn map_label(l: &Label) -> LabelResponse { - LabelResponse { - id: l.id.clone(), - name: l.name.clone(), - country: l.country.clone(), - } -} - -fn map_artist_credit(c: &ArtistCredit) -> ArtistCreditResponse { - ArtistCreditResponse { - artist: c.artist.as_ref().map(map_artist), - role: c.role.clone(), - position: c.position, - join_phrase: c.join_phrase.clone(), - } -} - -fn map_album(a: &Album) -> AlbumResponse { - AlbumResponse { - id: a.id.clone(), - title: a.title.clone(), - album_type: a.album_type.clone(), - release_date: a.release_date.clone(), - upc: a.upc.clone(), - total_tracks: a.total_tracks, - total_discs: a.total_discs, - cover_url: a.cover_url.clone(), - artists: a.artists.iter().map(map_artist_credit).collect(), - label: a.label.as_ref().map(map_label), - genres: a.genres.iter().map(map_genre).collect(), - external_ids: a.external_ids.iter().map(map_external_id).collect(), - } -} - -fn map_work(w: &Work) -> WorkResponse { - WorkResponse { - id: w.id.clone(), - title: w.title.clone(), - work_type: w.work_type.clone(), - language: w.language.clone(), - } -} - -fn map_track(t: &Track) -> TrackResponse { - TrackResponse { - id: t.id.clone(), - title: t.title.clone(), - duration_ms: t.duration_ms, - isrc: t.isrc.clone(), - explicit: t.explicit, - disc_number: t.disc_number, - track_number: t.track_number, - artists: t.artists.iter().map(map_artist_credit).collect(), - work: t.work.as_ref().map(map_work), - external_ids: t.external_ids.iter().map(map_external_id).collect(), - } -} - -async fn search_artists( - State(state): State, - Query(query): Query, -) -> Result, (StatusCode, String)> { - let state = state.read().await; - let response = state - .metadata_service - .search_artists(&query.q, query.limit, query.offset) - .await - .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?; - - Ok(Json(SearchArtistsResponse { - artists: response.artists.iter().map(map_artist).collect(), - total: response.total, - })) -} - -async fn get_artist( - State(state): State, - Path(id): Path, -) -> Result, (StatusCode, String)> { - let state = state.read().await; - let artist = state - .metadata_service - .get_artist(&id) - .await - .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?; - - Ok(Json(map_artist(&artist))) -} - -async fn get_artist_albums( - State(state): State, - Path(id): Path, - Query(query): Query, -) -> Result, (StatusCode, String)> { - let state = state.read().await; - let response = state - .metadata_service - .get_artist_albums(&id, query.limit, query.offset) - .await - .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?; - - Ok(Json(ArtistAlbumsResponse { - albums: response.albums.iter().map(map_album).collect(), - total: response.total, - })) -} - -#[derive(Debug, Deserialize)] -pub struct PaginationQuery { - pub limit: Option, - pub offset: Option, -} - -async fn get_album( - State(state): State, - Path(id): Path, -) -> Result, (StatusCode, String)> { - let state = state.read().await; - let album = state - .metadata_service - .get_album(&id) - .await - .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?; - - Ok(Json(map_album(&album))) -} - -async fn get_album_tracks( - State(state): State, - Path(id): Path, -) -> Result, (StatusCode, String)> { - let state = state.read().await; - let response = state - .metadata_service - .get_album_tracks(&id) - .await - .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?; - - Ok(Json(AlbumTracksResponse { - tracks: response.tracks.iter().map(map_track).collect(), - })) -} - -async fn sync_artist( - State(state): State, - Json(req): Json, -) -> Result, (StatusCode, String)> { - let state = state.read().await; - let response = state - .metadata_service - .sync_artist(&req.name) - .await - .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?; - - Ok(Json(SyncResponse { - artist: response.artist.as_ref().map(map_artist), - albums_synced: response.albums_synced, - tracks_synced: response.tracks_synced, - })) -} - -#[derive(Debug, Serialize)] -pub struct StatusResponse { - pub connected: bool, -} - -async fn connection_status(State(state): State) -> Json { - let state = state.read().await; - Json(StatusResponse { - connected: state.metadata_service.is_connected(), - }) -} diff --git a/src/api/mod.rs b/src/api/mod.rs deleted file mode 100644 index a11d063..0000000 --- a/src/api/mod.rs +++ /dev/null @@ -1,157 +0,0 @@ -mod indexer_controller; -mod library_controller; -mod metadata_controller; -mod sync_controller; -mod torrent_controller; - -use axum::{ - extract::{Path, Query, State}, - http::StatusCode, - routing::{delete, get, post}, - Json, Router, -}; -use serde::Deserialize; -use uuid::Uuid; - -use crate::models::{CreateTrack, Track}; -use crate::AppState; - -pub fn routes(state: AppState) -> Router { - Router::new() - .route("/health", get(health)) - .route("/reload", post(reload)) - .route("/tracks", get(list_tracks)) - .route("/tracks", post(create_track)) - .route("/tracks/{id}", get(get_track)) - .route("/tracks/{id}", delete(delete_track)) - .route("/tracks/search", get(search_tracks)) - .route("/stats", get(get_stats)) - .nest("/indexers", indexer_controller::routes()) - .nest("/torrents", torrent_controller::routes()) - .nest("/metadata", metadata_controller::routes()) - .nest("/sync", sync_controller::routes()) - .nest("/library", library_controller::routes()) - .with_state(state) -} - -#[derive(serde::Serialize)] -struct Health { - status: &'static str, - services: ServiceStatus, -} - -#[derive(serde::Serialize)] -struct ServiceStatus { - torrent: bool, - metadata: bool, - indexers: Vec, -} - -async fn health(State(state): State) -> Json { - let state = state.read().await; - let indexers = state - .indexer_service - .list_indexers() - .into_iter() - .map(|i| i.name) - .collect(); - Json(Health { - status: "ok", - services: ServiceStatus { - torrent: state.torrent_service.is_connected().await, - metadata: state.metadata_service.is_connected(), - indexers, - }, - }) -} - -#[derive(serde::Serialize)] -struct ReloadResponse { - success: bool, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, -} - -async fn reload(State(state): State) -> Json { - let mut state = state.write().await; - match state.reload().await { - Ok(()) => Json(ReloadResponse { - success: true, - error: None, - }), - Err(e) => Json(ReloadResponse { - success: false, - error: Some(e), - }), - } -} - -async fn list_tracks(State(state): State) -> Json> { - let state = state.read().await; - Json(state.aggregator.get_all().to_vec()) -} - -async fn create_track( - State(state): State, - Json(input): Json, -) -> (StatusCode, Json) { - let mut state = state.write().await; - let track = state.aggregator.add_track(input.into()); - (StatusCode::CREATED, Json(track)) -} - -async fn get_track( - State(state): State, - Path(id): Path, -) -> Result, StatusCode> { - let state = state.read().await; - state - .aggregator - .get_by_id(id) - .cloned() - .map(Json) - .ok_or(StatusCode::NOT_FOUND) -} - -async fn delete_track(State(state): State, Path(id): Path) -> StatusCode { - let mut state = state.write().await; - if state.aggregator.delete(id) { - StatusCode::NO_CONTENT - } else { - StatusCode::NOT_FOUND - } -} - -#[derive(Deserialize)] -struct SearchQuery { - artist: String, -} - -async fn search_tracks( - State(state): State, - Query(query): Query, -) -> Json> { - let state = state.read().await; - Json( - state - .aggregator - .search_by_artist(&query.artist) - .into_iter() - .cloned() - .collect(), - ) -} - -#[derive(serde::Serialize)] -struct Stats { - track_count: usize, - total_duration_secs: u32, -} - -async fn get_stats(State(state): State) -> Json { - let state = state.read().await; - Json(Stats { - track_count: state.aggregator.get_all().len(), - total_duration_secs: state.aggregator.total_duration(), - }) -} diff --git a/src/api/sync_controller.rs b/src/api/sync_controller.rs deleted file mode 100644 index 6b7c904..0000000 --- a/src/api/sync_controller.rs +++ /dev/null @@ -1,49 +0,0 @@ -use axum::{extract::State, http::StatusCode, routing::post, Json, Router}; -use serde::Deserialize; - -use crate::services::{DownloadService, SyncOptions, SyncResult}; -use crate::AppState; - -pub fn routes() -> Router { - Router::new().route("/", post(sync)) -} - -#[derive(Debug, Deserialize)] -pub struct SyncRequest { - pub artist: String, - pub album: Option, - #[serde(default = "default_true")] - pub download: bool, - #[serde(default = "default_true")] - pub store: bool, -} - -fn default_true() -> bool { - true -} - -async fn sync( - State(state): State, - Json(req): Json, -) -> Result, (StatusCode, String)> { - let state = state.read().await; - - let options = SyncOptions { - artist: req.artist, - album: req.album, - download: req.download, - store: req.store, - }; - - let result = DownloadService::sync( - options, - &state.metadata_service, - &state.indexer_service, - &state.torrent_service, - state.db_service.as_ref(), - ) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; - - Ok(Json(result)) -} diff --git a/src/api/torrent_controller.rs b/src/api/torrent_controller.rs deleted file mode 100644 index 3d6c880..0000000 --- a/src/api/torrent_controller.rs +++ /dev/null @@ -1,210 +0,0 @@ -use axum::{ - extract::{Path, Query, State}, - http::StatusCode, - routing::{delete, get, post}, - Json, Router, -}; -use serde::{Deserialize, Serialize}; - -use crate::torrent::TorrentInfo; -use crate::AppState; - -pub fn routes() -> Router { - Router::new() - .route("/", get(list_torrents)) - .route("/{hash}", get(get_torrent)) - .route("/{hash}", delete(remove_torrent)) - .route("/{hash}/pause", post(pause_torrent)) - .route("/{hash}/resume", post(resume_torrent)) - .route("/add/url", post(add_torrent_url)) - .route("/add/file", post(add_torrent_file)) - .route("/status", get(connection_status)) -} - -#[derive(Debug, Serialize)] -pub struct TorrentListResponse { - pub torrents: Vec, - pub total: usize, -} - -async fn list_torrents( - State(state): State, -) -> Result, (StatusCode, String)> { - let state = state.read().await; - let torrents = state - .torrent_service - .list_torrents() - .await - .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?; - - let total = torrents.len(); - Ok(Json(TorrentListResponse { torrents, total })) -} - -async fn get_torrent( - State(state): State, - Path(hash): Path, -) -> Result, (StatusCode, String)> { - let state = state.read().await; - state - .torrent_service - .get_torrent(&hash) - .await - .map(Json) - .map_err(|e| { - let status = if e.to_string().contains("not found") { - StatusCode::NOT_FOUND - } else { - StatusCode::BAD_GATEWAY - }; - (status, e.to_string()) - }) -} - -#[derive(Debug, Deserialize)] -pub struct RemoveQuery { - #[serde(default)] - pub delete_files: bool, -} - -async fn remove_torrent( - State(state): State, - Path(hash): Path, - Query(query): Query, -) -> Result { - let state = state.read().await; - state - .torrent_service - .remove_torrent(&hash, query.delete_files) - .await - .map(|_| StatusCode::NO_CONTENT) - .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string())) -} - -async fn pause_torrent( - State(state): State, - Path(hash): Path, -) -> Result { - let state = state.read().await; - state - .torrent_service - .pause_torrent(&hash) - .await - .map(|_| StatusCode::OK) - .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string())) -} - -async fn resume_torrent( - State(state): State, - Path(hash): Path, -) -> Result { - let state = state.read().await; - state - .torrent_service - .resume_torrent(&hash) - .await - .map(|_| StatusCode::OK) - .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string())) -} - -#[derive(Debug, Deserialize)] -pub struct AddUrlRequest { - pub url: String, - pub save_path: Option, -} - -#[derive(Debug, Serialize)] -pub struct AddResponse { - pub success: bool, - pub message: String, -} - -async fn add_torrent_url( - State(state): State, - Json(req): Json, -) -> Result<(StatusCode, Json), (StatusCode, Json)> { - let state = state.read().await; - state - .torrent_service - .add_torrent_url(&req.url, req.save_path.as_deref()) - .await - .map(|_| { - ( - StatusCode::CREATED, - Json(AddResponse { - success: true, - message: "Torrent added successfully".to_string(), - }), - ) - }) - .map_err(|e| { - ( - StatusCode::BAD_REQUEST, - Json(AddResponse { - success: false, - message: e.to_string(), - }), - ) - }) -} - -#[derive(Debug, Deserialize)] -pub struct AddFileRequest { - pub torrent_base64: String, - pub save_path: Option, -} - -async fn add_torrent_file( - State(state): State, - Json(req): Json, -) -> Result<(StatusCode, Json), (StatusCode, Json)> { - use base64::Engine; - - let data = base64::engine::general_purpose::STANDARD - .decode(&req.torrent_base64) - .map_err(|e| { - ( - StatusCode::BAD_REQUEST, - Json(AddResponse { - success: false, - message: format!("Invalid base64: {}", e), - }), - ) - })?; - - let state = state.read().await; - state - .torrent_service - .add_torrent_file(&data, req.save_path.as_deref()) - .await - .map(|_| { - ( - StatusCode::CREATED, - Json(AddResponse { - success: true, - message: "Torrent added successfully".to_string(), - }), - ) - }) - .map_err(|e| { - ( - StatusCode::BAD_REQUEST, - Json(AddResponse { - success: false, - message: e.to_string(), - }), - ) - }) -} - -#[derive(Debug, Serialize)] -pub struct StatusResponse { - pub connected: bool, -} - -async fn connection_status(State(state): State) -> Json { - let state = state.read().await; - Json(StatusResponse { - connected: state.torrent_service.is_connected().await, - }) -} diff --git a/src/config/mod.rs b/src/config/mod.rs deleted file mode 100644 index 68ee5e5..0000000 --- a/src/config/mod.rs +++ /dev/null @@ -1,103 +0,0 @@ -use serde::Deserialize; -use std::fs; -use std::path::Path; -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum ConfigError { - #[error("failed to read config file: {0}")] - ReadError(#[from] std::io::Error), - - #[error("failed to parse config: {0}")] - ParseError(#[from] serde_yaml::Error), -} - -#[derive(Debug, Clone, Deserialize)] -pub struct Config { - #[serde(default)] - pub app: AppConfig, - pub database: DatabaseConfig, - pub metadata: MetadataConfig, - pub indexers: Vec, - pub torrent: TorrentConfig, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct AppConfig { - #[serde(default = "default_port")] - pub port: u16, -} - -impl Default for AppConfig { - fn default() -> Self { - Self { - port: default_port(), - } - } -} - -fn default_port() -> u16 { - 3000 -} - -#[derive(Debug, Clone, Deserialize)] -pub struct MetadataConfig { - pub endpoint: String, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct DatabaseConfig { - pub url: String, -} - -#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum IndexerType { - #[default] - Jackett, - Prowlarr, - Torznab, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct IndexerConfig { - pub name: String, - #[serde(default)] - pub indexer_type: IndexerType, - pub url: String, - pub api_key: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(tag = "client_type", rename_all = "lowercase")] -pub enum TorrentConfig { - QBittorrent { - url: String, - username: String, - password: String, - }, - Stub { - log_path: String, - #[serde(default = "default_stub_save_path")] - save_path: String, - }, - None, -} - -impl Default for TorrentConfig { - fn default() -> Self { - Self::None - } -} - -fn default_stub_save_path() -> String { - "/tmp/downloads".to_string() -} - -impl Config { - pub fn load>(path: P) -> Result { - let content = fs::read_to_string(path)?; - let config: Config = serde_yaml::from_str(&content)?; - Ok(config) - } -} diff --git a/src/indexer/mod.rs b/src/indexer/mod.rs deleted file mode 100644 index 0b970c6..0000000 --- a/src/indexer/mod.rs +++ /dev/null @@ -1,43 +0,0 @@ -mod search; -mod torznab; - -pub use search::{MusicSearchCriteria, SearchResult}; -pub use torznab::TorznabIndexer; - -use async_trait::async_trait; -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum IndexerError { - #[error("authentication failed: invalid API key")] - AuthenticationFailed, - - #[error("rate limited: retry after {0} seconds")] - RateLimited(u64), - - #[error("indexer unavailable: {0}")] - Unavailable(String), - - #[error("search failed: {0}")] - SearchFailed(String), - - #[error("parse error: {0}")] - ParseError(String), - - #[error("http error: {0}")] - Http(#[from] reqwest::Error), -} - -#[async_trait] -pub trait Indexer: Send + Sync { - fn name(&self) -> &str; - - fn supports_music_search(&self) -> bool; - - async fn search( - &self, - criteria: &MusicSearchCriteria, - ) -> Result, IndexerError>; - - async fn test_connection(&self) -> Result<(), IndexerError>; -} diff --git a/src/indexer/search.rs b/src/indexer/search.rs deleted file mode 100644 index c72c62e..0000000 --- a/src/indexer/search.rs +++ /dev/null @@ -1,79 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone)] -pub struct MusicSearchCriteria { - pub artist: String, - pub album: Option, - pub year: Option, - pub limit: u32, - pub offset: u32, -} - -impl MusicSearchCriteria { - pub fn new(artist: impl Into) -> Self { - Self { - artist: artist.into(), - album: None, - year: None, - limit: 100, - offset: 0, - } - } - - pub fn with_album(mut self, album: impl Into) -> Self { - self.album = Some(album.into()); - self - } - - pub fn with_year(mut self, year: u32) -> Self { - self.year = Some(year); - self - } - - pub fn with_limit(mut self, limit: u32) -> Self { - self.limit = limit; - self - } - - pub fn with_offset(mut self, offset: u32) -> Self { - self.offset = offset; - self - } - - pub fn clean_artist(&self) -> String { - normalize_query(&self.artist) - } - - pub fn clean_album(&self) -> Option { - self.album.as_ref().map(|a| normalize_query(a)) - } -} - -fn normalize_query(s: &str) -> String { - s.trim().replace("\"", "").replace("'", "").to_lowercase() -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SearchResult { - pub guid: String, - pub title: String, - pub download_url: String, - pub info_url: Option, - pub size: u64, - pub publish_date: Option, - - pub artist: Option, - pub album: Option, - pub year: Option, - pub label: Option, - - pub seeders: Option, - pub leechers: Option, - pub grabs: Option, - - pub infohash: Option, - pub magnet_url: Option, - - pub indexer: String, - pub categories: Vec, -} diff --git a/src/indexer/torznab.rs b/src/indexer/torznab.rs deleted file mode 100644 index c650691..0000000 --- a/src/indexer/torznab.rs +++ /dev/null @@ -1,221 +0,0 @@ -use async_trait::async_trait; -use reqwest::Client; -use url::Url; - -use super::search::{MusicSearchCriteria, SearchResult}; -use super::{Indexer, IndexerError}; - -pub struct TorznabIndexer { - name: String, - base_url: Url, - api_key: String, - categories: Vec, - http: Client, -} - -impl TorznabIndexer { - pub fn new( - name: impl Into, - base_url: &str, - api_key: impl Into, - ) -> Result { - let base_url = Url::parse(base_url) - .map_err(|e| IndexerError::SearchFailed(format!("invalid URL: {}", e)))?; - - Ok(Self { - name: name.into(), - base_url, - api_key: api_key.into(), - categories: vec![3000, 3010, 3040], - http: Client::new(), - }) - } - - pub fn with_categories(mut self, categories: Vec) -> Self { - self.categories = categories; - self - } - - fn build_search_url(&self, criteria: &MusicSearchCriteria) -> Result { - let mut url = self.base_url.clone(); - - { - let mut query = url.query_pairs_mut(); - query.append_pair("t", "music"); - query.append_pair("apikey", &self.api_key); - query.append_pair("extended", "1"); - - let cats = self - .categories - .iter() - .map(|c| c.to_string()) - .collect::>() - .join(","); - query.append_pair("cat", &cats); - - let mut q_parts = vec![criteria.clean_artist()]; - if let Some(album) = criteria.clean_album() { - q_parts.push(album); - } - if let Some(year) = criteria.year { - q_parts.push(year.to_string()); - } - query.append_pair("q", &q_parts.join(" ")); - - query.append_pair("limit", &criteria.limit.to_string()); - query.append_pair("offset", &criteria.offset.to_string()); - } - - Ok(url) - } - - fn parse_response(&self, xml: &str) -> Result, IndexerError> { - let mut results = Vec::new(); - - let doc = roxmltree::Document::parse(xml) - .map_err(|e| IndexerError::ParseError(format!("XML parse error: {}", e)))?; - - if let Some(error) = doc.descendants().find(|n| n.has_tag_name("error")) { - let code = error.attribute("code").unwrap_or("0"); - let desc = error.attribute("description").unwrap_or("Unknown error"); - - if code.starts_with("1") { - return Err(IndexerError::AuthenticationFailed); - } - return Err(IndexerError::SearchFailed(desc.to_string())); - } - - for item in doc.descendants().filter(|n| n.has_tag_name("item")) { - let result = self.parse_item(&item)?; - results.push(result); - } - - Ok(results) - } - - fn parse_item(&self, item: &roxmltree::Node) -> Result { - let get_text = |tag: &str| -> Option { - item.children() - .find(|n| n.has_tag_name(tag)) - .and_then(|n| n.text()) - .map(|s| s.to_string()) - }; - - let get_attr = |name: &str| -> Option { - item.children() - .filter(|n| n.has_tag_name("attr")) - .find(|n| n.attribute("name") == Some(name)) - .and_then(|n| n.attribute("value")) - .map(|s| s.to_string()) - }; - - let guid = get_text("guid").unwrap_or_default(); - let title = get_text("title").unwrap_or_default(); - let download_url = get_text("link").unwrap_or_default(); - - let size = get_attr("size") - .or_else(|| { - item.children() - .find(|n| n.has_tag_name("enclosure")) - .and_then(|n| n.attribute("length")) - .map(|s| s.to_string()) - }) - .and_then(|s| s.parse().ok()) - .unwrap_or(0); - - let mut categories = Vec::new(); - for attr in item.children().filter(|n| n.has_tag_name("attr")) { - if attr.attribute("name") == Some("category") { - if let Some(val) = attr.attribute("value") { - if let Ok(cat) = val.parse::() { - categories.push(cat); - } - } - } - } - - Ok(SearchResult { - guid, - title, - download_url, - info_url: get_text("comments"), - size, - publish_date: get_text("pubDate"), - artist: get_attr("artist"), - album: get_attr("album"), - year: get_attr("year").and_then(|s| s.parse().ok()), - label: get_attr("label"), - seeders: get_attr("seeders").and_then(|s| s.parse().ok()), - leechers: get_attr("leechers").and_then(|s| s.parse().ok()), - grabs: get_attr("grabs").and_then(|s| s.parse().ok()), - infohash: get_attr("infohash"), - magnet_url: get_attr("magneturl"), - indexer: self.name.clone(), - categories, - }) - } -} - -#[async_trait] -impl Indexer for TorznabIndexer { - fn name(&self) -> &str { - &self.name - } - - fn supports_music_search(&self) -> bool { - true - } - - async fn search( - &self, - criteria: &MusicSearchCriteria, - ) -> Result, IndexerError> { - let url = self.build_search_url(criteria)?; - - let response = self.http.get(url).send().await?; - - if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS { - let retry_after = response - .headers() - .get("retry-after") - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.parse().ok()) - .unwrap_or(60); - return Err(IndexerError::RateLimited(retry_after)); - } - - if !response.status().is_success() { - return Err(IndexerError::Unavailable(format!( - "HTTP {}", - response.status() - ))); - } - - let xml = response.text().await?; - self.parse_response(&xml) - } - - async fn test_connection(&self) -> Result<(), IndexerError> { - let mut url = self.base_url.clone(); - url.query_pairs_mut() - .append_pair("t", "caps") - .append_pair("apikey", &self.api_key); - - let response = self.http.get(url).send().await?; - - if !response.status().is_success() { - return Err(IndexerError::Unavailable(format!( - "HTTP {}", - response.status() - ))); - } - - let xml = response.text().await?; - - if xml.contains(", - config_path: String, -} - -impl AppServices { - pub fn new( - indexer_service: services::IndexerService, - torrent_service: services::TorrentService, - metadata_service: services::MetadataService, - db_service: Option, - config_path: String, - ) -> Self { - Self { - aggregator: services::Aggregator::new(), - indexer_service, - torrent_service, - metadata_service, - db_service, - config_path, - } - } - - pub async fn reload(&mut self) -> Result<(), String> { - let cfg = config::Config::load(&self.config_path).map_err(|e| e.to_string())?; - - self.indexer_service = - services::IndexerService::from_config(&cfg.indexers).map_err(|e| e.to_string())?; - - match services::TorrentService::from_config(&cfg.torrent).await { - Ok(svc) => self.torrent_service = svc, - Err(e) => { - tracing::warn!("failed to init torrent client on reload: {}", e); - } - } - - let mut metadata = services::MetadataService::new(&cfg.metadata.endpoint); - if metadata.connect().await.is_ok() { - self.metadata_service = metadata; - } else { - tracing::warn!("failed to connect to metadata service on reload"); - } - - tracing::info!("config reloaded from {}", self.config_path); - Ok(()) - } -} - -pub type AppState = Arc>; diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index a59882e..0000000 --- a/src/main.rs +++ /dev/null @@ -1,131 +0,0 @@ -use std::sync::Arc; -use tokio::sync::RwLock; - -use axum::Router; -use clap::Parser; -use music_agregator::{ - api, config, - services::{DbService, IndexerService, MetadataService, TorrentService}, - AppServices, AppState, -}; -use tower_http::cors::{Any, CorsLayer}; -use tower_http::trace::TraceLayer; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; - -#[derive(Parser)] -#[command(name = "music-agregator")] -#[command(about = "Music aggregation service with torrent and metadata integration")] -struct Args { - #[arg(short, long, default_value = "config.yaml")] - config: String, - - #[arg(short, long)] - port: Option, -} - -#[tokio::main] -async fn main() { - tracing_subscriber::registry() - .with(tracing_subscriber::fmt::layer()) - .with(tracing_subscriber::EnvFilter::from_default_env()) - .init(); - - let args = Args::parse(); - - let config = match config::Config::load(&args.config) { - Ok(cfg) => { - tracing::info!("loaded config from {}", args.config); - cfg - } - Err(e) => { - tracing::error!("failed to load config: {}", e); - std::process::exit(1); - } - }; - - let indexer_service = match IndexerService::from_config(&config.indexers) { - Ok(svc) => { - tracing::info!("initialized {} indexer(s)", config.indexers.len()); - svc - } - Err(e) => { - tracing::error!("failed to initialize indexer service: {}", e); - std::process::exit(1); - } - }; - - let torrent_service = match TorrentService::from_config(&config.torrent).await { - Ok(svc) => { - match &config.torrent { - config::TorrentConfig::QBittorrent { url, .. } => { - tracing::info!("connected to qBittorrent at {}", url); - } - config::TorrentConfig::Stub { log_path, .. } => { - tracing::info!("using stub torrent client, logging to {}", log_path); - } - config::TorrentConfig::None => { - tracing::info!("no torrent client configured"); - } - } - svc - } - Err(e) => { - tracing::warn!("failed to init torrent client: {} (continuing without)", e); - TorrentService::new() - } - }; - - let mut metadata_service = MetadataService::new(&config.metadata.endpoint); - match metadata_service.connect().await { - Ok(()) => { - tracing::info!( - "connected to metadata service at {}", - config.metadata.endpoint - ); - } - Err(e) => { - tracing::warn!( - "failed to connect to metadata service: {} (continuing without metadata)", - e - ); - } - } - - let db_service = match DbService::new(&config.database.url).await { - Ok(svc) => { - tracing::info!("connected to database"); - Some(svc) - } - Err(e) => { - tracing::warn!( - "failed to connect to database: {} (continuing without db)", - e - ); - None - } - }; - - let state: AppState = Arc::new(RwLock::new(AppServices::new( - indexer_service, - torrent_service, - metadata_service, - db_service, - args.config.clone(), - ))); - - let cors = CorsLayer::new() - .allow_origin(Any) - .allow_methods(Any) - .allow_headers(Any); - - let app = Router::new() - .nest("/api", api::routes(state)) - .layer(cors) - .layer(TraceLayer::new_for_http()); - - let port = args.port.unwrap_or(config.app.port); - let addr = format!("0.0.0.0:{}", port); - let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); - tracing::info!("listening on {}", listener.local_addr().unwrap()); - axum::serve(listener, app).await.unwrap(); -} diff --git a/src/metadata/client.rs b/src/metadata/client.rs deleted file mode 100644 index 39e1561..0000000 --- a/src/metadata/client.rs +++ /dev/null @@ -1,112 +0,0 @@ -use thiserror::Error; -use tonic::transport::Channel; - -use super::proto::{ - get_album_request, get_artist_request, metadata_service_client::MetadataServiceClient, - sync_artist_request, Album, Artist, GetAlbumRequest, GetAlbumTracksRequest, - GetAlbumTracksResponse, GetArtistAlbumsRequest, GetArtistAlbumsResponse, GetArtistRequest, - Provider, SearchArtistsRequest, SearchArtistsResponse, SyncArtistRequest, SyncArtistResponse, -}; - -#[derive(Debug, Error)] -pub enum MetadataClientError { - #[error("connection failed: {0}")] - ConnectionFailed(String), - - #[error("request failed: {0}")] - RequestFailed(#[from] tonic::Status), - - #[error("transport error: {0}")] - Transport(#[from] tonic::transport::Error), -} - -pub struct MetadataClient { - client: MetadataServiceClient, -} - -impl MetadataClient { - pub async fn connect(endpoint: &str) -> Result { - let client = MetadataServiceClient::connect(endpoint.to_string()).await?; - Ok(Self { client }) - } - - pub async fn search_artists( - &mut self, - query: &str, - limit: Option, - offset: Option, - ) -> Result { - let request = SearchArtistsRequest { - query: query.to_string(), - limit: limit.unwrap_or(20), - offset: offset.unwrap_or(0), - provider: Provider::Unspecified as i32, - }; - - let response = self.client.search_artists(request).await?; - Ok(response.into_inner()) - } - - pub async fn get_artist(&mut self, id: &str) -> Result { - let request = GetArtistRequest { - identifier: Some(get_artist_request::Identifier::Id(id.to_string())), - provider: Provider::Unspecified as i32, - }; - - let response = self.client.get_artist(request).await?; - Ok(response.into_inner()) - } - - pub async fn get_artist_albums( - &mut self, - artist_id: &str, - limit: Option, - offset: Option, - ) -> Result { - let request = GetArtistAlbumsRequest { - artist_id: artist_id.to_string(), - limit: limit.unwrap_or(50), - offset: offset.unwrap_or(0), - provider: Provider::Unspecified as i32, - }; - - let response = self.client.get_artist_albums(request).await?; - Ok(response.into_inner()) - } - - pub async fn get_album(&mut self, id: &str) -> Result { - let request = GetAlbumRequest { - identifier: Some(get_album_request::Identifier::Id(id.to_string())), - provider: Provider::Unspecified as i32, - }; - - let response = self.client.get_album(request).await?; - Ok(response.into_inner()) - } - - pub async fn get_album_tracks( - &mut self, - album_id: &str, - ) -> Result { - let request = GetAlbumTracksRequest { - album_id: album_id.to_string(), - provider: Provider::Unspecified as i32, - }; - - let response = self.client.get_album_tracks(request).await?; - Ok(response.into_inner()) - } - - pub async fn sync_artist( - &mut self, - name: &str, - ) -> Result { - let request = SyncArtistRequest { - target: Some(sync_artist_request::Target::Name(name.to_string())), - provider: Provider::Musicbrainz as i32, - }; - - let response = self.client.sync_artist(request).await?; - Ok(response.into_inner()) - } -} diff --git a/src/metadata/mod.rs b/src/metadata/mod.rs deleted file mode 100644 index f775695..0000000 --- a/src/metadata/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -mod client; - -pub use client::{MetadataClient, MetadataClientError}; - -pub mod proto { - tonic::include_proto!("metadata.v1"); -} diff --git a/src/models/mod.rs b/src/models/mod.rs deleted file mode 100644 index 79875f5..0000000 --- a/src/models/mod.rs +++ /dev/null @@ -1,31 +0,0 @@ -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Track { - pub id: Uuid, - pub title: String, - pub artist: String, - pub album: Option, - pub duration_secs: u32, -} - -#[derive(Debug, Deserialize)] -pub struct CreateTrack { - pub title: String, - pub artist: String, - pub album: Option, - pub duration_secs: u32, -} - -impl From for Track { - fn from(input: CreateTrack) -> Self { - Self { - id: Uuid::new_v4(), - title: input.title, - artist: input.artist, - album: input.album, - duration_secs: input.duration_secs, - } - } -} diff --git a/src/services/db_service.rs b/src/services/db_service.rs deleted file mode 100644 index f9858f5..0000000 --- a/src/services/db_service.rs +++ /dev/null @@ -1,211 +0,0 @@ -use sqlx::{postgres::PgPoolOptions, FromRow, PgPool}; -use uuid::Uuid; - -use crate::metadata::proto::{Album, Artist}; - -#[derive(Clone)] -pub struct DbService { - pool: PgPool, -} - -impl DbService { - pub async fn new(database_url: &str) -> Result { - let pool = PgPoolOptions::new() - .max_connections(5) - .connect(database_url) - .await?; - - Ok(Self { pool }) - } - - pub async fn upsert_artist_metadata(&self, artist: &Artist) -> Result { - let id = Uuid::parse_str(&artist.id).unwrap_or_else(|_| Uuid::new_v4()); - let genres: serde_json::Value = serde_json::json!(artist - .genres - .iter() - .map(|g| serde_json::json!({"id": g.id, "name": g.name})) - .collect::>()); - let links: serde_json::Value = serde_json::json!(artist - .external_ids - .iter() - .map( - |e| serde_json::json!({"source": e.source, "source_id": e.source_id, "url": e.url}) - ) - .collect::>()); - - let row: (Uuid,) = sqlx::query_as( - r#" - INSERT INTO artist_metadata ( - id, foreign_artist_id, name, sort_name, disambiguation, - artist_type, status, overview, genres, links, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW()) - ON CONFLICT (foreign_artist_id) DO UPDATE SET - name = EXCLUDED.name, - sort_name = EXCLUDED.sort_name, - artist_type = EXCLUDED.artist_type, - overview = EXCLUDED.overview, - genres = EXCLUDED.genres, - links = EXCLUDED.links, - updated_at = NOW() - RETURNING id - "#, - ) - .bind(id) - .bind(&artist.id) - .bind(&artist.name) - .bind(&artist.sort_name) - .bind(&artist.description) - .bind(&artist.artist_type) - .bind("active") - .bind(&artist.description) - .bind(&genres) - .bind(&links) - .fetch_one(&self.pool) - .await?; - - Ok(row.0) - } - - pub async fn upsert_album( - &self, - album: &Album, - artist_metadata_id: Uuid, - ) -> Result { - let id = Uuid::parse_str(&album.id).unwrap_or_else(|_| Uuid::new_v4()); - let genres: serde_json::Value = serde_json::json!(album - .genres - .iter() - .map(|g| serde_json::json!({"id": g.id, "name": g.name})) - .collect::>()); - let images: serde_json::Value = serde_json::json!([]); - let release_date = chrono::NaiveDate::parse_from_str(&album.release_date, "%Y-%m-%d").ok(); - let clean_title = album - .title - .to_lowercase() - .replace(|c: char| !c.is_alphanumeric(), ""); - - let row: (Uuid,) = sqlx::query_as( - r#" - INSERT INTO albums ( - id, artist_metadata_id, foreign_album_id, title, clean_title, - overview, album_type, release_date, images, genres - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - ON CONFLICT (foreign_album_id) DO UPDATE SET - title = EXCLUDED.title, - album_type = EXCLUDED.album_type, - release_date = EXCLUDED.release_date, - genres = EXCLUDED.genres - RETURNING id - "#, - ) - .bind(id) - .bind(artist_metadata_id) - .bind(&album.id) - .bind(&album.title) - .bind(&clean_title) - .bind("") - .bind(&album.album_type) - .bind(release_date) - .bind(&images) - .bind(&genres) - .fetch_one(&self.pool) - .await?; - - Ok(row.0) - } - - pub async fn list_artists(&self) -> Result, sqlx::Error> { - sqlx::query_as( - r#" - SELECT id, foreign_artist_id, name, sort_name, artist_type, genres, created_at, updated_at - FROM artist_metadata - ORDER BY name - "#, - ) - .fetch_all(&self.pool) - .await - } - - pub async fn list_albums_by_artist( - &self, - artist_metadata_id: Uuid, - ) -> Result, sqlx::Error> { - sqlx::query_as( - r#" - SELECT id, artist_metadata_id, foreign_album_id, title, album_type, release_date, monitored, added_at - FROM albums - WHERE artist_metadata_id = $1 - ORDER BY release_date DESC NULLS LAST - "#, - ) - .bind(artist_metadata_id) - .fetch_all(&self.pool) - .await - } - - pub async fn list_all_albums(&self) -> Result, sqlx::Error> { - sqlx::query_as( - r#" - SELECT - a.id, a.foreign_album_id, a.title, a.album_type, a.release_date, a.monitored, a.added_at, - am.id as artist_id, am.name as artist_name - FROM albums a - JOIN artist_metadata am ON a.artist_metadata_id = am.id - ORDER BY a.added_at DESC - "#, - ) - .fetch_all(&self.pool) - .await - } - - pub async fn count_artists(&self) -> Result { - let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM artist_metadata") - .fetch_one(&self.pool) - .await?; - Ok(row.0) - } - - pub async fn count_albums(&self) -> Result { - let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM albums") - .fetch_one(&self.pool) - .await?; - Ok(row.0) - } -} - -#[derive(Debug, serde::Serialize, FromRow)] -pub struct ArtistMetadataRow { - pub id: Uuid, - pub foreign_artist_id: Option, - pub name: String, - pub sort_name: Option, - pub artist_type: Option, - pub genres: Option, - pub created_at: chrono::DateTime, - pub updated_at: chrono::DateTime, -} - -#[derive(Debug, serde::Serialize, FromRow)] -pub struct AlbumRow { - pub id: Uuid, - pub artist_metadata_id: Uuid, - pub foreign_album_id: Option, - pub title: String, - pub album_type: Option, - pub release_date: Option, - pub monitored: bool, - pub added_at: chrono::DateTime, -} - -#[derive(Debug, serde::Serialize, FromRow)] -pub struct AlbumWithArtistRow { - pub id: Uuid, - pub foreign_album_id: Option, - pub title: String, - pub album_type: Option, - pub release_date: Option, - pub monitored: bool, - pub added_at: chrono::DateTime, - pub artist_id: Uuid, - pub artist_name: String, -} diff --git a/src/services/download_service.rs b/src/services/download_service.rs deleted file mode 100644 index 9985d17..0000000 --- a/src/services/download_service.rs +++ /dev/null @@ -1,254 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::indexer::SearchResult; - -use super::{DbService, IndexerService, MetadataService, TorrentService}; - -#[derive(Debug, Deserialize)] -pub struct SyncOptions { - pub artist: String, - pub album: Option, - pub download: bool, - pub store: bool, -} - -#[derive(Debug, Serialize)] -pub struct SyncResult { - pub artist_id: String, - pub artist_name: String, - pub total_albums: usize, - pub albums_stored: usize, - pub albums_downloaded: usize, - pub albums_no_results: usize, - pub albums_failed: usize, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub results: Vec, -} - -#[derive(Debug, Serialize)] -pub struct AlbumSyncResult { - pub album_id: String, - pub album_title: String, - pub stored: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub download_status: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub torrent_hash: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub indexer: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum DownloadStatus { - Added, - NoResults, - Failed, - Skipped, -} - -struct DownloadResult { - status: DownloadStatus, - torrent_hash: Option, - indexer: Option, - error: Option, -} - -pub struct DownloadService; - -impl DownloadService { - pub async fn sync( - options: SyncOptions, - metadata: &MetadataService, - indexers: &IndexerService, - torrent: &TorrentService, - db: Option<&DbService>, - ) -> Result { - let search_result = metadata - .search_artists(&options.artist, Some(1), None) - .await - .map_err(|e| format!("metadata search failed: {}", e))?; - - let artist = search_result - .artists - .first() - .ok_or_else(|| format!("artist '{}' not found", options.artist))?; - - let artist_metadata_id = if options.store { - if let Some(db) = db { - match db.upsert_artist_metadata(artist).await { - Ok(id) => { - tracing::info!("stored artist metadata: {} ({})", artist.name, id); - Some(id) - } - Err(e) => { - tracing::warn!("failed to store artist metadata: {}", e); - None - } - } - } else { - None - } - } else { - None - }; - - let albums_response = metadata - .get_artist_albums(&artist.id, Some(500), None) - .await - .map_err(|e| format!("failed to get albums: {}", e))?; - - let albums_to_process: Vec<_> = if let Some(ref album_filter) = options.album { - let filter_lower = album_filter.to_lowercase(); - albums_response - .albums - .iter() - .filter(|a| a.title.to_lowercase().contains(&filter_lower)) - .collect() - } else { - albums_response.albums.iter().collect() - }; - - let mut results = Vec::new(); - let mut albums_stored = 0; - let mut albums_downloaded = 0; - let mut albums_no_results = 0; - let mut albums_failed = 0; - - for album in albums_to_process.iter() { - let stored = if options.store { - if let (Some(db), Some(artist_id)) = (db, artist_metadata_id) { - match db.upsert_album(album, artist_id).await { - Ok(_) => { - albums_stored += 1; - true - } - Err(e) => { - tracing::warn!("failed to store album {}: {}", album.title, e); - false - } - } - } else { - false - } - } else { - false - }; - - let (download_status, torrent_hash, indexer, error) = if options.download { - let year = album - .release_date - .split('-') - .next() - .and_then(|y| y.parse().ok()); - - let dl_result = - Self::download_album(&artist.name, &album.title, year, indexers, torrent).await; - - match dl_result.status { - DownloadStatus::Added => albums_downloaded += 1, - DownloadStatus::NoResults => albums_no_results += 1, - DownloadStatus::Failed | DownloadStatus::Skipped => albums_failed += 1, - } - - ( - Some(dl_result.status), - dl_result.torrent_hash, - dl_result.indexer, - dl_result.error, - ) - } else { - (None, None, None, None) - }; - - results.push(AlbumSyncResult { - album_id: album.id.clone(), - album_title: album.title.clone(), - stored, - download_status, - torrent_hash, - indexer, - error, - }); - } - - Ok(SyncResult { - artist_id: artist.id.clone(), - artist_name: artist.name.clone(), - total_albums: albums_to_process.len(), - albums_stored, - albums_downloaded, - albums_no_results, - albums_failed, - results, - }) - } - - async fn download_album( - artist_name: &str, - album_title: &str, - year: Option, - indexers: &IndexerService, - torrent: &TorrentService, - ) -> DownloadResult { - let criteria = crate::indexer::MusicSearchCriteria { - artist: artist_name.to_string(), - album: Some(album_title.to_string()), - year, - limit: 20, - offset: 0, - }; - - let search_results = match indexers.search(&criteria, None).await { - Ok(r) => r, - Err(e) => { - return DownloadResult { - status: DownloadStatus::Failed, - torrent_hash: None, - indexer: None, - error: Some(format!("indexer search failed: {}", e)), - }; - } - }; - - if search_results.is_empty() { - return DownloadResult { - status: DownloadStatus::NoResults, - torrent_hash: None, - indexer: None, - error: None, - }; - } - - let best = Self::select_best_result(&search_results); - - match torrent.add_torrent_url(&best.download_url, None).await { - Ok(()) => DownloadResult { - status: DownloadStatus::Added, - torrent_hash: best.infohash.clone(), - indexer: Some(best.indexer.clone()), - error: None, - }, - Err(e) => DownloadResult { - status: DownloadStatus::Failed, - torrent_hash: None, - indexer: Some(best.indexer.clone()), - error: Some(format!("failed to add torrent: {}", e)), - }, - } - } - - fn select_best_result(results: &[SearchResult]) -> &SearchResult { - results - .iter() - .max_by_key(|r| { - let seeders = r.seeders.unwrap_or(0); - let is_flac = r.title.to_lowercase().contains("flac"); - let score = seeders as i64 + if is_flac { 1000 } else { 0 }; - score - }) - .unwrap() - } -} diff --git a/src/services/indexer_service.rs b/src/services/indexer_service.rs deleted file mode 100644 index ca0d0b7..0000000 --- a/src/services/indexer_service.rs +++ /dev/null @@ -1,105 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use crate::config::{IndexerConfig, IndexerType}; -use crate::indexer::{Indexer, IndexerError, MusicSearchCriteria, SearchResult, TorznabIndexer}; - -pub struct IndexerService { - indexers: HashMap>, -} - -impl IndexerService { - pub fn new() -> Self { - Self { - indexers: HashMap::new(), - } - } - - pub fn from_config(configs: &[IndexerConfig]) -> Result { - let mut service = Self::new(); - - for config in configs { - let torznab_url = Self::build_torznab_url(&config.url, config.indexer_type); - let indexer = TorznabIndexer::new(&config.name, &torznab_url, &config.api_key)?; - service.add_indexer(Arc::new(indexer)); - } - - Ok(service) - } - - fn build_torznab_url(base_url: &str, indexer_type: IndexerType) -> String { - let base = base_url.trim_end_matches('/'); - match indexer_type { - IndexerType::Jackett => format!("{}/api/v2.0/indexers/all/results/torznab/", base), - IndexerType::Prowlarr => format!("{}/api/v1/indexer/all/torznab", base), - IndexerType::Torznab => base_url.to_string(), - } - } - - pub fn add_indexer(&mut self, indexer: Arc) { - self.indexers.insert(indexer.name().to_string(), indexer); - } - - pub fn get_indexer(&self, name: &str) -> Option> { - self.indexers.get(name).cloned() - } - - pub fn list_indexers(&self) -> Vec { - self.indexers - .values() - .map(|i| IndexerInfo { - name: i.name().to_string(), - supports_music: i.supports_music_search(), - }) - .collect() - } - - pub async fn search( - &self, - criteria: &MusicSearchCriteria, - indexer_name: Option<&str>, - ) -> Result, IndexerError> { - match indexer_name { - Some(name) => { - let indexer = self.indexers.get(name).ok_or_else(|| { - IndexerError::Unavailable(format!("indexer not found: {}", name)) - })?; - indexer.search(criteria).await - } - None => { - let mut all_results = Vec::new(); - for indexer in self.indexers.values() { - if indexer.supports_music_search() { - match indexer.search(criteria).await { - Ok(results) => all_results.extend(results), - Err(e) => { - tracing::warn!("indexer {} failed: {}", indexer.name(), e); - } - } - } - } - Ok(all_results) - } - } - } - - pub async fn test_indexer(&self, name: &str) -> Result<(), IndexerError> { - let indexer = self - .indexers - .get(name) - .ok_or_else(|| IndexerError::Unavailable(format!("indexer not found: {}", name)))?; - indexer.test_connection().await - } -} - -impl Default for IndexerService { - fn default() -> Self { - Self::new() - } -} - -#[derive(Debug, Clone, serde::Serialize)] -pub struct IndexerInfo { - pub name: String, - pub supports_music: bool, -} diff --git a/src/services/metadata_service.rs b/src/services/metadata_service.rs deleted file mode 100644 index 02cb6e6..0000000 --- a/src/services/metadata_service.rs +++ /dev/null @@ -1,92 +0,0 @@ -use std::sync::Arc; -use tokio::sync::Mutex; - -use crate::metadata::{MetadataClient, MetadataClientError}; - -pub struct MetadataService { - client: Option>>, - endpoint: String, -} - -impl MetadataService { - pub fn new(endpoint: &str) -> Self { - Self { - client: None, - endpoint: endpoint.to_string(), - } - } - - pub async fn connect(&mut self) -> Result<(), MetadataClientError> { - let client = MetadataClient::connect(&self.endpoint).await?; - self.client = Some(Arc::new(Mutex::new(client))); - Ok(()) - } - - pub fn is_connected(&self) -> bool { - self.client.is_some() - } - - fn client(&self) -> Result>, MetadataClientError> { - self.client - .clone() - .ok_or_else(|| MetadataClientError::ConnectionFailed("not connected".into())) - } - - pub async fn search_artists( - &self, - query: &str, - limit: Option, - offset: Option, - ) -> Result { - let client = self.client()?; - let mut guard = client.lock().await; - guard.search_artists(query, limit, offset).await - } - - pub async fn get_artist( - &self, - id: &str, - ) -> Result { - let client = self.client()?; - let mut guard = client.lock().await; - guard.get_artist(id).await - } - - pub async fn get_artist_albums( - &self, - artist_id: &str, - limit: Option, - offset: Option, - ) -> Result { - let client = self.client()?; - let mut guard = client.lock().await; - guard.get_artist_albums(artist_id, limit, offset).await - } - - pub async fn get_album( - &self, - id: &str, - ) -> Result { - let client = self.client()?; - let mut guard = client.lock().await; - guard.get_album(id).await - } - - pub async fn get_album_tracks( - &self, - album_id: &str, - ) -> Result { - let client = self.client()?; - let mut guard = client.lock().await; - guard.get_album_tracks(album_id).await - } - - pub async fn sync_artist( - &self, - name: &str, - ) -> Result { - let client = self.client()?; - let mut guard = client.lock().await; - guard.sync_artist(name).await - } -} diff --git a/src/services/mod.rs b/src/services/mod.rs deleted file mode 100644 index e373f81..0000000 --- a/src/services/mod.rs +++ /dev/null @@ -1,57 +0,0 @@ -mod db_service; -mod download_service; -mod indexer_service; -mod metadata_service; -mod torrent_service; - -pub use db_service::{AlbumRow, AlbumWithArtistRow, ArtistMetadataRow, DbService}; -pub use download_service::{DownloadService, DownloadStatus, SyncOptions, SyncResult}; -pub use indexer_service::{IndexerInfo, IndexerService}; -pub use metadata_service::MetadataService; -pub use torrent_service::TorrentService; - -use uuid::Uuid; - -use crate::models::Track; - -#[derive(Default)] -pub struct Aggregator { - tracks: Vec, -} - -impl Aggregator { - pub fn new() -> Self { - Self::default() - } - - pub fn add_track(&mut self, track: Track) -> Track { - self.tracks.push(track.clone()); - track - } - - pub fn get_all(&self) -> &[Track] { - &self.tracks - } - - pub fn get_by_id(&self, id: Uuid) -> Option<&Track> { - self.tracks.iter().find(|t| t.id == id) - } - - pub fn search_by_artist(&self, artist: &str) -> Vec<&Track> { - let artist_lower = artist.to_lowercase(); - self.tracks - .iter() - .filter(|t| t.artist.to_lowercase().contains(&artist_lower)) - .collect() - } - - pub fn delete(&mut self, id: Uuid) -> bool { - let len_before = self.tracks.len(); - self.tracks.retain(|t| t.id != id); - self.tracks.len() != len_before - } - - pub fn total_duration(&self) -> u32 { - self.tracks.iter().map(|t| t.duration_secs).sum() - } -} diff --git a/src/services/torrent_service.rs b/src/services/torrent_service.rs deleted file mode 100644 index 58a2c5f..0000000 --- a/src/services/torrent_service.rs +++ /dev/null @@ -1,99 +0,0 @@ -use std::sync::Arc; - -use crate::config::TorrentConfig; -use crate::torrent::{ - QBittorrentClient, StubTorrentClient, TorrentClient, TorrentClientError, TorrentInfo, -}; - -pub struct TorrentService { - client: Option>, -} - -impl TorrentService { - pub fn new() -> Self { - Self { client: None } - } - - pub async fn from_config(config: &TorrentConfig) -> Result { - match config { - TorrentConfig::QBittorrent { - url, - username, - password, - } => { - let mut client = QBittorrentClient::new(url, username, password)?; - client.connect().await?; - Ok(Self { - client: Some(Arc::new(client)), - }) - } - TorrentConfig::Stub { - log_path, - save_path, - } => { - let mut client = StubTorrentClient::new(log_path, save_path); - client.connect().await?; - Ok(Self { - client: Some(Arc::new(client)), - }) - } - TorrentConfig::None => Ok(Self::new()), - } - } - - fn client(&self) -> Result<&Arc, TorrentClientError> { - self.client - .as_ref() - .ok_or_else(|| TorrentClientError::ConnectionFailed("no client configured".into())) - } - - pub async fn is_connected(&self) -> bool { - self.client.is_some() - } - - pub async fn list_torrents(&self) -> Result, TorrentClientError> { - self.client()?.list_torrents().await - } - - pub async fn get_torrent(&self, hash: &str) -> Result { - self.client()?.get_torrent(hash).await - } - - pub async fn add_torrent_url( - &self, - url: &str, - save_path: Option<&str>, - ) -> Result<(), TorrentClientError> { - self.client()?.add_torrent_url(url, save_path).await - } - - pub async fn add_torrent_file( - &self, - data: &[u8], - save_path: Option<&str>, - ) -> Result<(), TorrentClientError> { - self.client()?.add_torrent_file(data, save_path).await - } - - pub async fn remove_torrent( - &self, - hash: &str, - delete_files: bool, - ) -> Result<(), TorrentClientError> { - self.client()?.remove_torrent(hash, delete_files).await - } - - pub async fn pause_torrent(&self, hash: &str) -> Result<(), TorrentClientError> { - self.client()?.pause_torrent(hash).await - } - - pub async fn resume_torrent(&self, hash: &str) -> Result<(), TorrentClientError> { - self.client()?.resume_torrent(hash).await - } -} - -impl Default for TorrentService { - fn default() -> Self { - Self::new() - } -} diff --git a/src/torrent/client.rs b/src/torrent/client.rs deleted file mode 100644 index 2a8a8b7..0000000 --- a/src/torrent/client.rs +++ /dev/null @@ -1,81 +0,0 @@ -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum TorrentClientError { - #[error("authentication failed")] - AuthenticationFailed, - - #[error("connection failed: {0}")] - ConnectionFailed(String), - - #[error("torrent not found: {0}")] - TorrentNotFound(String), - - #[error("invalid request: {0}")] - InvalidRequest(String), - - #[error("http error: {0}")] - Http(#[from] reqwest::Error), - - #[error("unexpected error: {0}")] - Unexpected(String), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum TorrentState { - Downloading, - Seeding, - Paused, - Queued, - Checking, - Error, - Unknown, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TorrentInfo { - pub hash: String, - pub name: String, - pub size: u64, - pub progress: f64, - pub download_speed: u64, - pub upload_speed: u64, - pub state: TorrentState, - pub save_path: String, -} - -#[async_trait] -pub trait TorrentClient: Send + Sync { - async fn connect(&mut self) -> Result<(), TorrentClientError>; - - async fn disconnect(&mut self) -> Result<(), TorrentClientError>; - - async fn list_torrents(&self) -> Result, TorrentClientError>; - - async fn get_torrent(&self, hash: &str) -> Result; - - async fn add_torrent_url( - &self, - url: &str, - save_path: Option<&str>, - ) -> Result<(), TorrentClientError>; - - async fn add_torrent_file( - &self, - torrent_data: &[u8], - save_path: Option<&str>, - ) -> Result<(), TorrentClientError>; - - async fn remove_torrent( - &self, - hash: &str, - delete_files: bool, - ) -> Result<(), TorrentClientError>; - - async fn pause_torrent(&self, hash: &str) -> Result<(), TorrentClientError>; - - async fn resume_torrent(&self, hash: &str) -> Result<(), TorrentClientError>; -} diff --git a/src/torrent/mod.rs b/src/torrent/mod.rs deleted file mode 100644 index f5c60f1..0000000 --- a/src/torrent/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -mod client; -mod qbittorrent; -mod stub; - -pub use client::{TorrentClient, TorrentClientError, TorrentInfo, TorrentState}; -pub use qbittorrent::QBittorrentClient; -pub use stub::StubTorrentClient; diff --git a/src/torrent/qbittorrent.rs b/src/torrent/qbittorrent.rs deleted file mode 100644 index 8cd99bd..0000000 --- a/src/torrent/qbittorrent.rs +++ /dev/null @@ -1,253 +0,0 @@ -use async_trait::async_trait; -use reqwest::{multipart, Client}; -use serde::Deserialize; -use std::sync::Arc; -use tokio::sync::RwLock; -use url::Url; - -use super::client::{TorrentClient, TorrentClientError, TorrentInfo, TorrentState}; - -pub struct QBittorrentClient { - base_url: Url, - username: String, - password: String, - http: Client, - connected: Arc>, -} - -#[derive(Debug, Deserialize)] -struct QBTorrent { - hash: String, - name: String, - size: i64, - progress: f64, - dlspeed: i64, - upspeed: i64, - state: String, - save_path: String, -} - -impl QBittorrentClient { - pub fn new(base_url: &str, username: &str, password: &str) -> Result { - let base_url = - Url::parse(base_url).map_err(|e| TorrentClientError::InvalidRequest(e.to_string()))?; - - let http = Client::builder().cookie_store(true).build()?; - - Ok(Self { - base_url, - username: username.to_string(), - password: password.to_string(), - http, - connected: Arc::new(RwLock::new(false)), - }) - } - - fn api_url(&self, path: &str) -> String { - format!("{}api/v2{}", self.base_url, path) - } - - fn map_state(state: &str) -> TorrentState { - match state { - "downloading" | "forcedDL" | "metaDL" | "allocating" => TorrentState::Downloading, - "uploading" | "forcedUP" | "stalledUP" => TorrentState::Seeding, - "pausedDL" | "pausedUP" => TorrentState::Paused, - "queuedDL" | "queuedUP" => TorrentState::Queued, - "checkingDL" | "checkingUP" | "checkingResumeData" => TorrentState::Checking, - "error" | "missingFiles" => TorrentState::Error, - _ => TorrentState::Unknown, - } - } - - fn map_torrent(t: QBTorrent) -> TorrentInfo { - TorrentInfo { - hash: t.hash, - name: t.name, - size: t.size.max(0) as u64, - progress: t.progress, - download_speed: t.dlspeed.max(0) as u64, - upload_speed: t.upspeed.max(0) as u64, - state: Self::map_state(&t.state), - save_path: t.save_path, - } - } - - async fn ensure_connected(&self) -> Result<(), TorrentClientError> { - let connected = *self.connected.read().await; - if !connected { - return Err(TorrentClientError::ConnectionFailed("not connected".into())); - } - Ok(()) - } -} - -#[async_trait] -impl TorrentClient for QBittorrentClient { - async fn connect(&mut self) -> Result<(), TorrentClientError> { - let params = [ - ("username", self.username.as_str()), - ("password", self.password.as_str()), - ]; - - let resp = self - .http - .post(self.api_url("/auth/login")) - .form(¶ms) - .send() - .await?; - - let text = resp.text().await?; - - if text == "Ok." { - *self.connected.write().await = true; - Ok(()) - } else { - Err(TorrentClientError::AuthenticationFailed) - } - } - - async fn disconnect(&mut self) -> Result<(), TorrentClientError> { - self.http.post(self.api_url("/auth/logout")).send().await?; - - *self.connected.write().await = false; - Ok(()) - } - - async fn list_torrents(&self) -> Result, TorrentClientError> { - self.ensure_connected().await?; - - let resp = self.http.get(self.api_url("/torrents/info")).send().await?; - - let torrents: Vec = resp.json().await?; - Ok(torrents.into_iter().map(Self::map_torrent).collect()) - } - - async fn get_torrent(&self, hash: &str) -> Result { - self.ensure_connected().await?; - - let resp = self - .http - .get(self.api_url("/torrents/info")) - .query(&[("hashes", hash)]) - .send() - .await?; - - let torrents: Vec = resp.json().await?; - torrents - .into_iter() - .next() - .map(Self::map_torrent) - .ok_or_else(|| TorrentClientError::TorrentNotFound(hash.to_string())) - } - - async fn add_torrent_url( - &self, - url: &str, - save_path: Option<&str>, - ) -> Result<(), TorrentClientError> { - self.ensure_connected().await?; - - let mut form = multipart::Form::new().text("urls", url.to_string()); - - if let Some(path) = save_path { - form = form.text("savepath", path.to_string()); - } - - let resp = self - .http - .post(self.api_url("/torrents/add")) - .multipart(form) - .send() - .await?; - - if resp.status().is_success() { - Ok(()) - } else { - Err(TorrentClientError::InvalidRequest( - resp.text().await.unwrap_or_default(), - )) - } - } - - async fn add_torrent_file( - &self, - torrent_data: &[u8], - save_path: Option<&str>, - ) -> Result<(), TorrentClientError> { - self.ensure_connected().await?; - - let part = multipart::Part::bytes(torrent_data.to_vec()) - .file_name("torrent.torrent") - .mime_str("application/x-bittorrent") - .map_err(|e| TorrentClientError::InvalidRequest(e.to_string()))?; - - let mut form = multipart::Form::new().part("torrents", part); - - if let Some(path) = save_path { - form = form.text("savepath", path.to_string()); - } - - let resp = self - .http - .post(self.api_url("/torrents/add")) - .multipart(form) - .send() - .await?; - - if resp.status().is_success() { - Ok(()) - } else { - Err(TorrentClientError::InvalidRequest( - resp.text().await.unwrap_or_default(), - )) - } - } - - async fn remove_torrent( - &self, - hash: &str, - delete_files: bool, - ) -> Result<(), TorrentClientError> { - self.ensure_connected().await?; - - let resp = self - .http - .post(self.api_url("/torrents/delete")) - .form(&[ - ("hashes", hash), - ("deleteFiles", if delete_files { "true" } else { "false" }), - ]) - .send() - .await?; - - if resp.status().is_success() { - Ok(()) - } else { - Err(TorrentClientError::TorrentNotFound(hash.to_string())) - } - } - - async fn pause_torrent(&self, hash: &str) -> Result<(), TorrentClientError> { - self.ensure_connected().await?; - - self.http - .post(self.api_url("/torrents/pause")) - .form(&[("hashes", hash)]) - .send() - .await?; - - Ok(()) - } - - async fn resume_torrent(&self, hash: &str) -> Result<(), TorrentClientError> { - self.ensure_connected().await?; - - self.http - .post(self.api_url("/torrents/resume")) - .form(&[("hashes", hash)]) - .send() - .await?; - - Ok(()) - } -} diff --git a/src/torrent/stub.rs b/src/torrent/stub.rs deleted file mode 100644 index 7d0fb8c..0000000 --- a/src/torrent/stub.rs +++ /dev/null @@ -1,228 +0,0 @@ -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Arc; -use tokio::sync::RwLock; - -use super::client::{TorrentClient, TorrentClientError, TorrentInfo, TorrentState}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "action")] -pub enum StubRequest { - AddUrl { - url: String, - save_path: Option, - timestamp: String, - }, - AddFile { - size: usize, - save_path: Option, - timestamp: String, - }, - Remove { - hash: String, - delete_files: bool, - timestamp: String, - }, - Pause { - hash: String, - timestamp: String, - }, - Resume { - hash: String, - timestamp: String, - }, -} - -struct StubTorrent { - info: TorrentInfo, -} - -pub struct StubTorrentClient { - torrents: Arc>>, - log_path: PathBuf, - save_path: String, - connected: bool, -} - -impl StubTorrentClient { - pub fn new(log_path: impl Into, save_path: impl Into) -> Self { - Self { - torrents: Arc::new(RwLock::new(HashMap::new())), - log_path: log_path.into(), - save_path: save_path.into(), - connected: false, - } - } - - fn log_request(&self, request: &StubRequest) { - use std::io::Write; - - let json = serde_json::to_string_pretty(request).unwrap_or_default(); - let entry = format!("{}\n---\n", json); - - let result = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&self.log_path) - .and_then(|mut f| f.write_all(entry.as_bytes())); - - if let Err(e) = result { - tracing::warn!("failed to write stub log: {}", e); - } - } - - fn generate_hash(input: &str) -> String { - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - let mut hasher = DefaultHasher::new(); - input.hash(&mut hasher); - format!("{:016x}{:016x}{:08x}", hasher.finish(), hasher.finish(), 0) - } - - fn timestamp() -> String { - chrono::Utc::now().to_rfc3339() - } -} - -#[async_trait] -impl TorrentClient for StubTorrentClient { - async fn connect(&mut self) -> Result<(), TorrentClientError> { - self.connected = true; - tracing::info!("stub torrent client connected"); - Ok(()) - } - - async fn disconnect(&mut self) -> Result<(), TorrentClientError> { - self.connected = false; - Ok(()) - } - - async fn list_torrents(&self) -> Result, TorrentClientError> { - let torrents = self.torrents.read().await; - Ok(torrents.values().map(|t| t.info.clone()).collect()) - } - - async fn get_torrent(&self, hash: &str) -> Result { - let torrents = self.torrents.read().await; - torrents - .get(hash) - .map(|t| t.info.clone()) - .ok_or_else(|| TorrentClientError::TorrentNotFound(hash.to_string())) - } - - async fn add_torrent_url( - &self, - url: &str, - save_path: Option<&str>, - ) -> Result<(), TorrentClientError> { - let request = StubRequest::AddUrl { - url: url.to_string(), - save_path: save_path.map(String::from), - timestamp: Self::timestamp(), - }; - self.log_request(&request); - - let hash = Self::generate_hash(url); - let name = url.rsplit('/').next().unwrap_or("unknown").to_string(); - - let info = TorrentInfo { - hash: hash.clone(), - name, - size: 0, - progress: 0.0, - download_speed: 0, - upload_speed: 0, - state: TorrentState::Downloading, - save_path: save_path.unwrap_or(&self.save_path).to_string(), - }; - - let mut torrents = self.torrents.write().await; - torrents.insert(hash, StubTorrent { info }); - - Ok(()) - } - - async fn add_torrent_file( - &self, - torrent_data: &[u8], - save_path: Option<&str>, - ) -> Result<(), TorrentClientError> { - let request = StubRequest::AddFile { - size: torrent_data.len(), - save_path: save_path.map(String::from), - timestamp: Self::timestamp(), - }; - self.log_request(&request); - - let hash = Self::generate_hash(&format!("file:{}", torrent_data.len())); - - let info = TorrentInfo { - hash: hash.clone(), - name: format!("torrent-{}.torrent", &hash[..8]), - size: torrent_data.len() as u64, - progress: 0.0, - download_speed: 0, - upload_speed: 0, - state: TorrentState::Downloading, - save_path: save_path.unwrap_or(&self.save_path).to_string(), - }; - - let mut torrents = self.torrents.write().await; - torrents.insert(hash, StubTorrent { info }); - - Ok(()) - } - - async fn remove_torrent( - &self, - hash: &str, - delete_files: bool, - ) -> Result<(), TorrentClientError> { - let request = StubRequest::Remove { - hash: hash.to_string(), - delete_files, - timestamp: Self::timestamp(), - }; - self.log_request(&request); - - let mut torrents = self.torrents.write().await; - torrents - .remove(hash) - .map(|_| ()) - .ok_or_else(|| TorrentClientError::TorrentNotFound(hash.to_string())) - } - - async fn pause_torrent(&self, hash: &str) -> Result<(), TorrentClientError> { - let request = StubRequest::Pause { - hash: hash.to_string(), - timestamp: Self::timestamp(), - }; - self.log_request(&request); - - let mut torrents = self.torrents.write().await; - if let Some(t) = torrents.get_mut(hash) { - t.info.state = TorrentState::Paused; - Ok(()) - } else { - Err(TorrentClientError::TorrentNotFound(hash.to_string())) - } - } - - async fn resume_torrent(&self, hash: &str) -> Result<(), TorrentClientError> { - let request = StubRequest::Resume { - hash: hash.to_string(), - timestamp: Self::timestamp(), - }; - self.log_request(&request); - - let mut torrents = self.torrents.write().await; - if let Some(t) = torrents.get_mut(hash) { - t.info.state = TorrentState::Downloading; - Ok(()) - } else { - Err(TorrentClientError::TorrentNotFound(hash.to_string())) - } - } -}