From 5afcbd68ad32ea8d4f12ad3f681b73618e61adfb Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 28 Apr 2026 18:58:31 +0200 Subject: [PATCH] feat: add metadata-agregator gRPC client integration - Add tonic/prost for gRPC client generation - Add proto definitions from metadata-agregator service - Add MetadataClient and MetadataService for gRPC communication - Add REST controller exposing metadata endpoints (search, get, sync) - Update config with metadata.endpoint setting - Update flake.nix with protobuf for proto compilation --- Cargo.lock | 482 +++++++++++++++++++++++++++++-- Cargo.toml | 5 + build.rs | 7 + config.example.yaml | 3 + flake.nix | 2 + proto/metadata/v1/metadata.proto | 186 ++++++++++++ src/api/metadata_controller.rs | 342 ++++++++++++++++++++++ src/api/mod.rs | 2 + src/config/mod.rs | 6 + src/lib.rs | 4 + src/main.rs | 19 +- src/metadata/client.rs | 112 +++++++ src/metadata/mod.rs | 7 + src/services/metadata_service.rs | 92 ++++++ src/services/mod.rs | 2 + 15 files changed, 1250 insertions(+), 21 deletions(-) create mode 100644 build.rs create mode 100644 proto/metadata/v1/metadata.proto create mode 100644 src/api/metadata_controller.rs create mode 100644 src/metadata/client.rs create mode 100644 src/metadata/mod.rs create mode 100644 src/services/metadata_service.rs diff --git a/Cargo.lock b/Cargo.lock index 1ee075e..a8d60ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,28 @@ 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" @@ -34,13 +56,46 @@ 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", + "axum-core 0.5.6", "bytes", "form_urlencoded", "futures-util", @@ -50,7 +105,7 @@ dependencies = [ "hyper", "hyper-util", "itoa", - "matchit", + "matchit 0.8.4", "memchr", "mime", "percent-encoding", @@ -61,12 +116,32 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tower", + "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" @@ -190,18 +265,52 @@ dependencies = [ "litrs", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[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 = "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 = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -232,6 +341,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[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" @@ -290,6 +405,31 @@ dependencies = [ "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" @@ -366,6 +506,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -393,6 +534,19 @@ dependencies = [ "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" @@ -410,7 +564,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -525,6 +679,16 @@ dependencies = [ "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" @@ -553,6 +717,15 @@ dependencies = [ "serde", ] +[[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" @@ -589,6 +762,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[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" @@ -622,6 +801,12 @@ 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" @@ -661,13 +846,20 @@ dependencies = [ "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", + "axum 0.8.9", "base64", + "prost", "reqwest", "roxmltree", "serde", @@ -675,6 +867,8 @@ dependencies = [ "serde_yaml", "thiserror", "tokio", + "tonic", + "tonic-build", "tower-http", "tracing", "tracing-subscriber", @@ -709,6 +903,36 @@ 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" @@ -758,6 +982,58 @@ 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" @@ -787,7 +1063,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2", + "socket2 0.6.3", "thiserror", "tokio", "tracing", @@ -803,7 +1079,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -824,7 +1100,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.3", "tracing", "windows-sys 0.52.0", ] @@ -850,14 +1126,35 @@ 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", - "rand_core", + "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]] @@ -867,7 +1164,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "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]] @@ -879,6 +1185,18 @@ dependencies = [ "getrandom 0.3.4", ] +[[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" @@ -928,7 +1246,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", - "tower", + "tower 0.5.3", "tower-http", "tower-service", "url", @@ -964,6 +1282,19 @@ 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" @@ -1089,7 +1420,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap", + "indexmap 2.14.0", "itoa", "ryu", "serde", @@ -1123,6 +1454,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[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" @@ -1176,6 +1517,19 @@ dependencies = [ "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" @@ -1271,7 +1625,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", - "socket2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -1297,6 +1651,94 @@ dependencies = [ "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" @@ -1326,7 +1768,7 @@ dependencies = [ "http-body", "iri-string", "pin-project-lite", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", @@ -1589,7 +2031,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] @@ -1602,7 +2044,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "semver", ] @@ -1757,7 +2199,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.14.0", "prettyplease", "syn", "wasm-metadata", @@ -1788,7 +2230,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -1807,7 +2249,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", diff --git a/Cargo.toml b/Cargo.toml index bcd2661..7ae4bbf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,11 @@ thiserror = "2" url = "2" roxmltree = "0.20" base64 = "0.22" +tonic = "0.12" +prost = "0.13" + +[build-dependencies] +tonic-build = "0.12" [profile.release] opt-level = 3 diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..6622ef3 --- /dev/null +++ b/build.rs @@ -0,0 +1,7 @@ +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/config.example.yaml b/config.example.yaml index d3f1211..4c23007 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,6 +1,9 @@ database: url: "postgresql://music:music@localhost:5433/music_aggregator" +metadata: + endpoint: "http://localhost:50051" + indexers: - name: "Jackett" url: "http://localhost:9117" diff --git a/flake.nix b/flake.nix index 52af556..9b92ab6 100644 --- a/flake.nix +++ b/flake.nix @@ -42,6 +42,7 @@ version = "0.1.0"; src = ./.; cargoLock.lockFile = ./Cargo.lock; + nativeBuildInputs = [ pkgs.protobuf ]; }; in { @@ -63,6 +64,7 @@ pre-commit gitleaks plantuml + protobuf rustc cargo diff --git a/proto/metadata/v1/metadata.proto b/proto/metadata/v1/metadata.proto new file mode 100644 index 0000000..d0f2b77 --- /dev/null +++ b/proto/metadata/v1/metadata.proto @@ -0,0 +1,186 @@ +syntax = "proto3"; + +package metadata.v1; + +option go_package = "github.com/metadata-agregator/pkg/gen/metadata/v1;metadatav1"; + +enum Provider { + PROVIDER_UNSPECIFIED = 0; + PROVIDER_MUSICBRAINZ = 1; +} + +// MetadataService provides music metadata aggregation. +service MetadataService { + // GetArtist retrieves an artist by ID or external source ID. + rpc GetArtist(GetArtistRequest) returns (Artist); + + // SearchArtists searches for artists by name. + rpc SearchArtists(SearchArtistsRequest) returns (SearchArtistsResponse); + + // GetAlbum retrieves an album by ID. + rpc GetAlbum(GetAlbumRequest) returns (Album); + + // GetArtistAlbums retrieves all albums by an artist. + rpc GetArtistAlbums(GetArtistAlbumsRequest) returns (GetArtistAlbumsResponse); + + // GetTrack retrieves a track by ID. + rpc GetTrack(GetTrackRequest) returns (Track); + + // GetAlbumTracks retrieves all tracks on an album. + rpc GetAlbumTracks(GetAlbumTracksRequest) returns (GetAlbumTracksResponse); + + // SyncArtist triggers ingestion of an artist from external sources. + rpc SyncArtist(SyncArtistRequest) returns (SyncArtistResponse); +} + +// Requests + +message GetArtistRequest { + oneof identifier { + string id = 1; // Internal UUID + ExternalID external = 2; // External source ID (e.g., musicbrainz MBID) + } + Provider provider = 3; // UNSPECIFIED = query all providers +} + +message SearchArtistsRequest { + string query = 1; + int32 limit = 2; + int32 offset = 3; + Provider provider = 4; +} + +message GetAlbumRequest { + oneof identifier { + string id = 1; + ExternalID external = 2; + } + Provider provider = 3; +} + +message GetArtistAlbumsRequest { + string artist_id = 1; + int32 limit = 2; + int32 offset = 3; + Provider provider = 4; +} + +message GetTrackRequest { + oneof identifier { + string id = 1; + ExternalID external = 2; + string isrc = 3; + } + Provider provider = 4; +} + +message GetAlbumTracksRequest { + string album_id = 1; + Provider provider = 2; +} + +message SyncArtistRequest { + oneof target { + string name = 1; + ExternalID external = 2; + } + Provider provider = 3; +} + +// Responses + +message SearchArtistsResponse { + repeated Artist artists = 1; + int32 total = 2; +} + +message GetArtistAlbumsResponse { + repeated Album albums = 1; + int32 total = 2; +} + +message GetAlbumTracksResponse { + repeated Track tracks = 1; +} + +message SyncArtistResponse { + Artist artist = 1; + int32 albums_synced = 2; + int32 tracks_synced = 3; +} + +// Core Entities + +message Artist { + string id = 1; + string name = 2; + string sort_name = 3; + string artist_type = 4; // person, group, orchestra, etc. + string country = 5; + string formed_date = 6; + string disbanded_date = 7; + string description = 8; + string image_url = 9; + repeated Genre genres = 10; + repeated ExternalID external_ids = 11; +} + +message Album { + string id = 1; + string title = 2; + string album_type = 3; // album, ep, single, compilation + string release_date = 4; + string upc = 5; + int32 total_tracks = 6; + int32 total_discs = 7; + string cover_url = 8; + repeated ArtistCredit artists = 9; + Label label = 10; + repeated Genre genres = 11; + repeated ExternalID external_ids = 12; +} + +message Track { + string id = 1; + string title = 2; + int32 duration_ms = 3; + string isrc = 4; + bool explicit = 5; + int32 disc_number = 6; + int32 track_number = 7; + repeated ArtistCredit artists = 8; + Work work = 9; + repeated ExternalID external_ids = 10; +} + +message Work { + string id = 1; + string title = 2; + string work_type = 3; + string language = 4; + repeated ArtistCredit composers = 5; +} + +message Label { + string id = 1; + string name = 2; + string country = 3; +} + +message Genre { + string id = 1; + string name = 2; +} + +message ArtistCredit { + Artist artist = 1; + string role = 2; // primary, featured, remixer, producer + int32 position = 3; + string join_phrase = 4; // " & ", " feat. ", etc. +} + +message ExternalID { + string source = 1; // musicbrainz, spotify, discogs, etc. + string source_id = 2; + string url = 3; +} diff --git a/src/api/metadata_controller.rs b/src/api/metadata_controller.rs new file mode 100644 index 0000000..9959037 --- /dev/null +++ b/src/api/metadata_controller.rs @@ -0,0 +1,342 @@ +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 index d3d354c..2e13715 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,4 +1,5 @@ mod indexer_controller; +mod metadata_controller; mod torrent_controller; use axum::{ @@ -23,6 +24,7 @@ pub fn routes(state: AppState) -> Router { .route("/stats", get(get_stats)) .nest("/indexers", indexer_controller::routes()) .nest("/torrents", torrent_controller::routes()) + .nest("/metadata", metadata_controller::routes()) .with_state(state) } diff --git a/src/config/mod.rs b/src/config/mod.rs index 11fe174..5e30969 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -15,10 +15,16 @@ pub enum ConfigError { #[derive(Debug, Clone, Deserialize)] pub struct Config { pub database: DatabaseConfig, + pub metadata: MetadataConfig, pub indexers: Vec, pub torrent: TorrentConfig, } +#[derive(Debug, Clone, Deserialize)] +pub struct MetadataConfig { + pub endpoint: String, +} + #[derive(Debug, Clone, Deserialize)] pub struct DatabaseConfig { pub url: String, diff --git a/src/lib.rs b/src/lib.rs index 8b34ebd..c589fd4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod api; pub mod config; pub mod indexer; +pub mod metadata; pub mod models; pub mod services; pub mod torrent; @@ -12,17 +13,20 @@ pub struct AppServices { pub aggregator: services::Aggregator, pub indexer_service: services::IndexerService, pub torrent_service: services::TorrentService, + pub metadata_service: services::MetadataService, } impl AppServices { pub fn new( indexer_service: services::IndexerService, torrent_service: services::TorrentService, + metadata_service: services::MetadataService, ) -> Self { Self { aggregator: services::Aggregator::new(), indexer_service, torrent_service, + metadata_service, } } } diff --git a/src/main.rs b/src/main.rs index 623b938..016a820 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,7 @@ use tokio::sync::RwLock; use axum::Router; use music_agregator::{ api, config, - services::{IndexerService, TorrentService}, + services::{IndexerService, MetadataService, TorrentService}, AppServices, AppState, }; use tower_http::cors::{Any, CorsLayer}; @@ -60,9 +60,26 @@ async fn main() { 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 state: AppState = Arc::new(RwLock::new(AppServices::new( indexer_service, torrent_service, + metadata_service, ))); let cors = CorsLayer::new() diff --git a/src/metadata/client.rs b/src/metadata/client.rs new file mode 100644 index 0000000..39e1561 --- /dev/null +++ b/src/metadata/client.rs @@ -0,0 +1,112 @@ +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 new file mode 100644 index 0000000..f775695 --- /dev/null +++ b/src/metadata/mod.rs @@ -0,0 +1,7 @@ +mod client; + +pub use client::{MetadataClient, MetadataClientError}; + +pub mod proto { + tonic::include_proto!("metadata.v1"); +} diff --git a/src/services/metadata_service.rs b/src/services/metadata_service.rs new file mode 100644 index 0000000..02cb6e6 --- /dev/null +++ b/src/services/metadata_service.rs @@ -0,0 +1,92 @@ +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 index a4bce9f..58bb6c7 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,7 +1,9 @@ mod indexer_service; +mod metadata_service; mod torrent_service; pub use indexer_service::{IndexerInfo, IndexerService}; +pub use metadata_service::MetadataService; pub use torrent_service::TorrentService; use uuid::Uuid;