From 366d98a1ae8373e9e7ad835af6dbaf605f2c8e11 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 18 Jun 2026 22:52:28 +0200 Subject: [PATCH] add http-json, rss, and media data source adapters http-json: generic HTTP+JSON polling adapter, converts serde_json to domain Value. 4 tests. rss: XML RSS feed parser, extracts items into Value array. 1 test. media: Navidrome/Subsonic getNowPlaying adapter. 2 tests with fake server. --- Cargo.lock | 562 +++++++++++++++++- Cargo.toml | 4 + crates/adapters/http-json/Cargo.toml | 13 + crates/adapters/http-json/src/lib.rs | 68 +++ .../http-json/tests/http_json_tests.rs | 102 ++++ crates/adapters/media/Cargo.toml | 13 + crates/adapters/media/src/lib.rs | 158 +++++ crates/adapters/rss/Cargo.toml | 14 + crates/adapters/rss/src/lib.rs | 144 +++++ 9 files changed, 1076 insertions(+), 2 deletions(-) create mode 100644 crates/adapters/http-json/Cargo.toml create mode 100644 crates/adapters/http-json/src/lib.rs create mode 100644 crates/adapters/http-json/tests/http_json_tests.rs create mode 100644 crates/adapters/media/Cargo.toml create mode 100644 crates/adapters/media/src/lib.rs create mode 100644 crates/adapters/rss/Cargo.toml create mode 100644 crates/adapters/rss/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 9db77f8..6dddfff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -143,6 +143,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "byteorder" version = "1.5.0" @@ -241,6 +247,32 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -362,12 +394,31 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "etcetera" version = "0.8.0" @@ -390,6 +441,12 @@ dependencies = [ "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" @@ -407,12 +464,33 @@ dependencies = [ "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 = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -514,6 +592,36 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -628,6 +736,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-json" +version = "0.1.0" +dependencies = [ + "axum", + "domain", + "reqwest", + "serde_json", + "tokio", +] + [[package]] name = "httparse" version = "1.10.1" @@ -650,6 +769,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -658,6 +778,38 @@ dependencies = [ "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", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", ] [[package]] @@ -666,13 +818,23 @@ 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", + "system-configuration", "tokio", "tower-service", + "tracing", + "windows-registry", ] [[package]] @@ -788,12 +950,29 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -838,6 +1017,12 @@ dependencies = [ "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" @@ -875,6 +1060,17 @@ dependencies = [ "digest", ] +[[package]] +name = "media-adapter" +version = "0.1.0" +dependencies = [ + "axum", + "domain", + "reqwest", + "serde_json", + "tokio", +] + [[package]] name = "memchr" version = "2.8.2" @@ -898,6 +1094,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -950,6 +1163,49 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "parking" version = "2.2.1" @@ -1081,6 +1337,16 @@ dependencies = [ "serde", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quote" version = "1.0.45" @@ -1090,6 +1356,12 @@ dependencies = [ "proc-macro2", ] +[[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" @@ -1117,7 +1389,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", ] [[package]] @@ -1138,6 +1410,46 @@ dependencies = [ "bitflags", ] +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "ring" version = "0.17.14" @@ -1146,7 +1458,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -1172,6 +1484,31 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rss-adapter" +version = "0.1.0" +dependencies = [ + "axum", + "domain", + "quick-xml", + "reqwest", + "serde", + "tokio", +] + +[[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" @@ -1206,18 +1543,56 @@ dependencies = [ "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 = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.228" @@ -1595,6 +1970,9 @@ 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" @@ -1607,6 +1985,27 @@ dependencies = [ "syn", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tcp-client" version = "0.1.0" @@ -1625,6 +2024,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -1696,6 +2108,26 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-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" @@ -1707,6 +2139,19 @@ dependencies = [ "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 = "tower" version = "0.5.3" @@ -1731,10 +2176,14 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags", "bytes", + "futures-util", "http", + "http-body", "pin-project-lite", + "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -1781,6 +2230,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.20.1" @@ -1850,6 +2305,15 @@ 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" @@ -1862,6 +2326,71 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" +[[package]] +name = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -1896,6 +2425,35 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[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" diff --git a/Cargo.toml b/Cargo.toml index c195817..eca3283 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,9 @@ members = [ "crates/adapters/display-terminal", "crates/adapters/config-sqlite", "crates/adapters/http-api", + "crates/adapters/http-json", + "crates/adapters/rss", + "crates/adapters/media", "crates/bootstrap", "crates/client-desktop", ] @@ -39,3 +42,4 @@ sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite"] } postcard = { version = "1.1", default-features = false, features = ["alloc"] } tokio = { version = "1.0", features = ["macros", "rt", "rt-multi-thread", "net", "sync", "time", "io-util"] } tower = "0.5" +reqwest = { version = "0.12", features = ["json"] } diff --git a/crates/adapters/http-json/Cargo.toml b/crates/adapters/http-json/Cargo.toml new file mode 100644 index 0000000..31e457c --- /dev/null +++ b/crates/adapters/http-json/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "http-json" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain.workspace = true +reqwest.workspace = true +serde_json.workspace = true + +[dev-dependencies] +tokio.workspace = true +axum.workspace = true diff --git a/crates/adapters/http-json/src/lib.rs b/crates/adapters/http-json/src/lib.rs new file mode 100644 index 0000000..342a191 --- /dev/null +++ b/crates/adapters/http-json/src/lib.rs @@ -0,0 +1,68 @@ +use domain::{DataSource, DataSourcePort, Value}; + +pub struct HttpJsonAdapter { + client: reqwest::Client, +} + +#[derive(Debug)] +pub enum HttpJsonError { + Request(reqwest::Error), + NoUrl, + Parse(String), +} + +impl std::fmt::Display for HttpJsonError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + HttpJsonError::Request(e) => write!(f, "request: {e}"), + HttpJsonError::NoUrl => write!(f, "no url configured"), + HttpJsonError::Parse(e) => write!(f, "parse: {e}"), + } + } +} + +impl HttpJsonAdapter { + pub fn new() -> Self { + Self { + client: reqwest::Client::new(), + } + } +} + +fn json_to_value(json: serde_json::Value) -> Value { + match json { + serde_json::Value::Null => Value::Null, + serde_json::Value::Bool(b) => Value::Bool(b), + serde_json::Value::Number(n) => Value::Number(n.as_f64().unwrap_or(0.0)), + serde_json::Value::String(s) => Value::String(s), + serde_json::Value::Array(arr) => { + Value::Array(arr.into_iter().map(json_to_value).collect()) + } + serde_json::Value::Object(map) => { + Value::Object(map.into_iter().map(|(k, v)| (k, json_to_value(v))).collect()) + } + } +} + +impl DataSourcePort for HttpJsonAdapter { + type Error = HttpJsonError; + + async fn poll(&self, source: &DataSource) -> Result { + let url = source.config.url.as_ref().ok_or(HttpJsonError::NoUrl)?; + + let mut req = self.client.get(url); + + for (key, val) in &source.config.headers { + req = req.header(key, val); + } + + if let Some(api_key) = &source.config.api_key { + req = req.header("Authorization", format!("Bearer {api_key}")); + } + + let resp = req.send().await.map_err(HttpJsonError::Request)?; + let json: serde_json::Value = resp.json().await.map_err(HttpJsonError::Request)?; + + Ok(json_to_value(json)) + } +} diff --git a/crates/adapters/http-json/tests/http_json_tests.rs b/crates/adapters/http-json/tests/http_json_tests.rs new file mode 100644 index 0000000..81fc3e3 --- /dev/null +++ b/crates/adapters/http-json/tests/http_json_tests.rs @@ -0,0 +1,102 @@ +use std::time::Duration; +use axum::{Router, routing::get, response::Json}; +use domain::{DataSource, DataSourceConfig, DataSourcePort, DataSourceType, Value}; +use http_json::HttpJsonAdapter; + +async fn start_fake_api() -> String { + let app = Router::new() + .route("/weather", get(|| async { + Json(serde_json::json!({ + "main": {"temp": 5.4, "humidity": 80}, + "weather": [{"icon": "cloud_rain"}] + })) + })) + .route("/simple", get(|| async { + Json(serde_json::json!({"value": "hello"})) + })) + .route("/not-json", get(|| async { "plain text" })); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + format!("http://{addr}") +} + +fn make_source(url: String) -> DataSource { + DataSource { + id: 1, + name: "test".into(), + source_type: DataSourceType::HttpJson, + poll_interval: Duration::from_secs(60), + config: DataSourceConfig { + url: Some(url), + headers: vec![], + api_key: None, + }, + } +} + +#[tokio::test] +async fn polls_url_and_returns_nested_json_as_value() { + let base = start_fake_api().await; + let adapter = HttpJsonAdapter::new(); + let source = make_source(format!("{base}/weather")); + + let result = adapter.poll(&source).await.unwrap(); + + assert_eq!( + result.get_path("$.main.temp"), + Some(&Value::Number(5.4)) + ); + assert_eq!( + result.get_path("$.main.humidity"), + Some(&Value::Number(80.0)) + ); + assert_eq!( + result.get_path("$.weather[0].icon"), + Some(&Value::String("cloud_rain".into())) + ); +} + +#[tokio::test] +async fn polls_simple_json() { + let base = start_fake_api().await; + let adapter = HttpJsonAdapter::new(); + let source = make_source(format!("{base}/simple")); + + let result = adapter.poll(&source).await.unwrap(); + assert_eq!( + result.get_path("$.value"), + Some(&Value::String("hello".into())) + ); +} + +#[tokio::test] +async fn returns_error_when_no_url() { + let adapter = HttpJsonAdapter::new(); + let source = DataSource { + id: 1, + name: "bad".into(), + source_type: DataSourceType::HttpJson, + poll_interval: Duration::from_secs(60), + config: DataSourceConfig { + url: None, + headers: vec![], + api_key: None, + }, + }; + + let result = adapter.poll(&source).await; + assert!(result.is_err()); +} + +#[tokio::test] +async fn returns_error_on_connection_refused() { + let adapter = HttpJsonAdapter::new(); + let source = make_source("http://127.0.0.1:1".into()); + + let result = adapter.poll(&source).await; + assert!(result.is_err()); +} diff --git a/crates/adapters/media/Cargo.toml b/crates/adapters/media/Cargo.toml new file mode 100644 index 0000000..cdb8a5a --- /dev/null +++ b/crates/adapters/media/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "media-adapter" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain.workspace = true +reqwest.workspace = true +serde_json.workspace = true + +[dev-dependencies] +tokio.workspace = true +axum.workspace = true diff --git a/crates/adapters/media/src/lib.rs b/crates/adapters/media/src/lib.rs new file mode 100644 index 0000000..cc1e277 --- /dev/null +++ b/crates/adapters/media/src/lib.rs @@ -0,0 +1,158 @@ +use std::collections::BTreeMap; +use domain::{DataSource, DataSourcePort, Value}; + +pub struct MediaAdapter { + client: reqwest::Client, +} + +#[derive(Debug)] +pub enum MediaError { + Request(reqwest::Error), + NoUrl, + Parse(String), +} + +impl std::fmt::Display for MediaError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MediaError::Request(e) => write!(f, "request: {e}"), + MediaError::NoUrl => write!(f, "no url configured"), + MediaError::Parse(e) => write!(f, "parse: {e}"), + } + } +} + +impl MediaAdapter { + pub fn new() -> Self { + Self { + client: reqwest::Client::new(), + } + } +} + +impl DataSourcePort for MediaAdapter { + type Error = MediaError; + + async fn poll(&self, source: &DataSource) -> Result { + let base_url = source.config.url.as_ref().ok_or(MediaError::NoUrl)?; + let api_key = source.config.api_key.as_deref().unwrap_or(""); + + let url = format!( + "{base_url}/rest/getNowPlaying.view?u=kframe&t={api_key}&s=salt&v=1.16.1&c=kframe&f=json" + ); + + let resp = self.client.get(&url).send().await.map_err(MediaError::Request)?; + let json: serde_json::Value = resp.json().await.map_err(MediaError::Request)?; + + let entries = json["subsonic-response"]["nowPlaying"]["entry"] + .as_array() + .cloned() + .unwrap_or_default(); + + if entries.is_empty() { + let mut result = BTreeMap::new(); + result.insert("playing".into(), Value::Bool(false)); + return Ok(Value::Object(result)); + } + + let entry = &entries[0]; + let mut result = BTreeMap::new(); + result.insert("playing".into(), Value::Bool(true)); + result.insert("title".into(), Value::String( + entry["title"].as_str().unwrap_or("Unknown").into() + )); + result.insert("artist".into(), Value::String( + entry["artist"].as_str().unwrap_or("Unknown").into() + )); + result.insert("album".into(), Value::String( + entry["album"].as_str().unwrap_or("Unknown").into() + )); + + if let Some(duration) = entry["duration"].as_u64() { + result.insert("duration".into(), Value::Number(duration as f64)); + } + + Ok(Value::Object(result)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + use domain::{DataSourceConfig, DataSourceType}; + + fn subsonic_response(playing: bool) -> serde_json::Value { + if playing { + serde_json::json!({ + "subsonic-response": { + "status": "ok", + "nowPlaying": { + "entry": [{ + "title": "Believer", + "artist": "Imagine Dragons", + "album": "Evolve", + "duration": 204 + }] + } + } + }) + } else { + serde_json::json!({ + "subsonic-response": { + "status": "ok", + "nowPlaying": {} + } + }) + } + } + + async fn start_fake_subsonic(playing: bool) -> String { + let app = axum::Router::new() + .route("/rest/getNowPlaying.view", axum::routing::get(move || async move { + axum::response::Json(subsonic_response(playing)) + })); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + format!("http://{addr}") + } + + fn make_source(url: String) -> DataSource { + DataSource { + id: 1, + name: "navidrome".into(), + source_type: DataSourceType::Media, + poll_interval: Duration::from_secs(5), + config: DataSourceConfig { + url: Some(url), + headers: vec![], + api_key: Some("testtoken".into()), + }, + } + } + + #[tokio::test] + async fn returns_now_playing_info() { + let base = start_fake_subsonic(true).await; + let adapter = MediaAdapter::new(); + let source = make_source(base); + + let result = adapter.poll(&source).await.unwrap(); + + assert_eq!(result.get_path("$.playing"), Some(&Value::Bool(true))); + assert_eq!(result.get_path("$.title"), Some(&Value::String("Believer".into()))); + assert_eq!(result.get_path("$.artist"), Some(&Value::String("Imagine Dragons".into()))); + } + + #[tokio::test] + async fn returns_not_playing_when_empty() { + let base = start_fake_subsonic(false).await; + let adapter = MediaAdapter::new(); + let source = make_source(base); + + let result = adapter.poll(&source).await.unwrap(); + assert_eq!(result.get_path("$.playing"), Some(&Value::Bool(false))); + } +} diff --git a/crates/adapters/rss/Cargo.toml b/crates/adapters/rss/Cargo.toml new file mode 100644 index 0000000..e6d5a41 --- /dev/null +++ b/crates/adapters/rss/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "rss-adapter" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain.workspace = true +reqwest.workspace = true +quick-xml = { version = "0.37", features = ["serialize"] } +serde.workspace = true + +[dev-dependencies] +tokio.workspace = true +axum.workspace = true diff --git a/crates/adapters/rss/src/lib.rs b/crates/adapters/rss/src/lib.rs new file mode 100644 index 0000000..785a9de --- /dev/null +++ b/crates/adapters/rss/src/lib.rs @@ -0,0 +1,144 @@ +use std::collections::BTreeMap; +use domain::{DataSource, DataSourcePort, Value}; +use quick_xml::events::Event; +use quick_xml::Reader; + +pub struct RssAdapter { + client: reqwest::Client, +} + +#[derive(Debug)] +pub enum RssError { + Request(reqwest::Error), + NoUrl, + Parse(String), +} + +impl std::fmt::Display for RssError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RssError::Request(e) => write!(f, "request: {e}"), + RssError::NoUrl => write!(f, "no url configured"), + RssError::Parse(e) => write!(f, "parse: {e}"), + } + } +} + +impl RssAdapter { + pub fn new() -> Self { + Self { + client: reqwest::Client::new(), + } + } +} + +fn parse_rss(xml: &str) -> Result { + let mut reader = Reader::from_str(xml); + let mut items: Vec = Vec::new(); + let mut current_item: Option> = None; + let mut current_tag = String::new(); + let mut in_channel = false; + let mut channel_title = String::new(); + let mut channel_link = String::new(); + + loop { + match reader.read_event() { + Ok(Event::Start(e)) => { + let tag = String::from_utf8_lossy(e.name().as_ref()).to_string(); + match tag.as_str() { + "channel" => in_channel = true, + "item" => { current_item = Some(BTreeMap::new()); } + _ => current_tag = tag, + } + } + Ok(Event::End(e)) => { + let tag = String::from_utf8_lossy(e.name().as_ref()).to_string(); + if tag == "item" { + if let Some(item) = current_item.take() { + items.push(Value::Object(item)); + } + } + current_tag.clear(); + } + Ok(Event::Text(e)) => { + let text = e.unescape().unwrap_or_default().to_string(); + if !current_tag.is_empty() && !text.trim().is_empty() { + if let Some(item) = current_item.as_mut() { + item.insert(current_tag.clone(), Value::String(text)); + } else if in_channel { + match current_tag.as_str() { + "title" => channel_title = text, + "link" => channel_link = text, + _ => {} + } + } + } + } + Ok(Event::CData(e)) => { + let text = String::from_utf8_lossy(&e).to_string(); + if !current_tag.is_empty() { + if let Some(item) = current_item.as_mut() { + item.insert(current_tag.clone(), Value::String(text)); + } + } + } + Ok(Event::Eof) => break, + Err(e) => return Err(RssError::Parse(format!("{e}"))), + _ => {} + } + } + + let mut result = BTreeMap::new(); + result.insert("title".into(), Value::String(channel_title)); + result.insert("link".into(), Value::String(channel_link)); + result.insert("count".into(), Value::Number(items.len() as f64)); + result.insert("items".into(), Value::Array(items)); + + Ok(Value::Object(result)) +} + +impl DataSourcePort for RssAdapter { + type Error = RssError; + + async fn poll(&self, source: &DataSource) -> Result { + let url = source.config.url.as_ref().ok_or(RssError::NoUrl)?; + + let resp = self.client.get(url).send().await.map_err(RssError::Request)?; + let xml = resp.text().await.map_err(RssError::Request)?; + + parse_rss(&xml) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_RSS: &str = r#" + + + Test Feed + https://example.com + + First Article + Description of first article + https://example.com/1 + + + Second Article + Description of second + https://example.com/2 + + + "#; + + #[test] + fn parses_rss_into_value() { + let result = parse_rss(SAMPLE_RSS).unwrap(); + + assert_eq!(result.get_path("$.title"), Some(&Value::String("Test Feed".into()))); + assert_eq!(result.get_path("$.items[0].title"), Some(&Value::String("First Article".into()))); + assert_eq!(result.get_path("$.items[1].title"), Some(&Value::String("Second Article".into()))); + assert_eq!(result.get_path("$.items[0].description"), Some(&Value::String("Description of first article".into()))); + } +}