diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 42ce8a1..d320ad9 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -54,3 +54,39 @@ jobs: - name: Check documentation run: cargo doc --no-deps --all-features + + coverage: + runs-on: ubuntu-latest + needs: lint + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + + - name: Install cargo-tarpaulin + run: cargo install cargo-tarpaulin + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo build + uses: actions/cache@v4 + with: + path: target + key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} + + - name: Run coverage + run: cargo tarpaulin --all-features --out xml --output-dir coverage + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage/ diff --git a/.gitignore b/.gitignore index 4a3b37d..3f704e3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target config.toml +crates/reestream-server/static/assets/ diff --git a/Cargo.lock b/Cargo.lock index aceeaa0..c762812 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,26 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "0.6.21" @@ -52,18 +72,120 @@ dependencies = [ "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 = "array-init" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc" + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[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 = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.10.0" @@ -79,6 +201,15 @@ dependencies = [ "generic-array", ] +[[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.19.0" @@ -119,6 +250,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.5.54" @@ -165,6 +306,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + [[package]] name = "core-foundation" version = "0.9.4" @@ -190,6 +337,16 @@ dependencies = [ "libc", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "crypto-mac" version = "0.10.0" @@ -200,6 +357,28 @@ dependencies = [ "subtle", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + [[package]] name = "digest" version = "0.9.0" @@ -209,6 +388,38 @@ dependencies = [ "generic-array", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -220,6 +431,18 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + [[package]] name = "equivalent" version = "1.0.2" @@ -254,6 +477,12 @@ 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" @@ -278,6 +507,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -285,6 +529,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -293,6 +538,34 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -311,17 +584,23 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", + "slab", ] [[package]] name = "generic-array" -version = "0.14.9" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", @@ -349,11 +628,24 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "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.12" @@ -373,6 +665,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.0" @@ -385,6 +686,12 @@ 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 = "hmac" version = "0.10.1" @@ -392,7 +699,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1441c6b1e930e2817404b5046f1f989899143a12bf92de603b69f4e0aee1e15" dependencies = [ "crypto-mac", - "digest", + "digest 0.9.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", ] [[package]] @@ -435,6 +751,12 @@ 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.7.0" @@ -449,6 +771,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -492,7 +815,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.1", "system-configuration", "tokio", "tower-service", @@ -581,6 +904,12 @@ dependencies = [ "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" @@ -609,7 +938,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.0", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", ] [[package]] @@ -650,18 +990,42 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyed_priority_queue" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee7893dab2e44ae5f9d0173f26ff4aa327c10b01b06a72b52dd9405b628640d" +dependencies = [ + "indexmap", +] + [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[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.177" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -686,12 +1050,33 @@ 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.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "mio" version = "1.1.0" @@ -729,6 +1114,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -791,6 +1185,21 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -833,6 +1242,16 @@ 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.103" @@ -842,6 +1261,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.2", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quinn" version = "0.11.9" @@ -855,7 +1299,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2", + "socket2 0.6.1", "thiserror 2.0.17", "tokio", "tracing", @@ -892,7 +1336,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.1", "tracing", "windows-sys 0.60.2", ] @@ -912,6 +1356,12 @@ 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.5" @@ -971,23 +1421,140 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.3", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 2.0.17", +] + [[package]] name = "reestream" -version = "0.1.1" +version = "0.2.0" dependencies = [ "bytes", "clap", + "proptest", + "reestream-core", + "reestream-ffmpeg", + "reestream-server", + "reestream-srt", + "rml_rtmp", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", + "url", +] + +[[package]] +name = "reestream-core" +version = "0.2.0" +dependencies = [ + "async-trait", + "bytes", + "proptest", "reqwest", "rml_rtmp", "serde", + "serde_json", "tokio", "tokio-native-tls", "toml", "tracing", - "tracing-subscriber", "url", + "uuid", +] + +[[package]] +name = "reestream-ffmpeg" +version = "0.2.0" +dependencies = [ + "dirs", + "futures-util", + "hex", + "reqwest", + "serde", + "sha2 0.10.9", + "thiserror 2.0.17", + "tokio", + "tracing", + "which", +] + +[[package]] +name = "reestream-server" +version = "0.2.0" +dependencies = [ + "axum", + "bytes", + "reestream-core", + "reqwest", + "rust-embed", + "serde", + "serde_json", + "tokio", + "tower-http", + "tracing", + "uuid", +] + +[[package]] +name = "reestream-srt" +version = "0.2.0" +dependencies = [ + "bytes", + "futures", + "serde", + "serde_json", + "srt-tokio", + "tokio", + "tracing", + "url", +] + +[[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.24" @@ -997,6 +1564,7 @@ dependencies = [ "base64", "bytes", "futures-core", + "futures-util", "h2", "http", "http-body", @@ -1017,12 +1585,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots", ] @@ -1059,19 +1629,62 @@ checksum = "a354e80eb7aa2a6fed09b3bd25c19bcfd32cf51f81f1219f4ec04f34519989da" dependencies = [ "byteorder", "bytes", - "hmac", + "hmac 0.10.1", "rand 0.8.5", "rml_amf0", - "sha2", + "sha2 0.9.9", "thiserror 1.0.69", ] +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2 0.10.9", + "walkdir", +] + [[package]] name = "rustc-hash" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.2" @@ -1126,12 +1739,33 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.28" @@ -1164,6 +1798,12 @@ dependencies = [ "libc", ] +[[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" @@ -1207,6 +1847,17 @@ dependencies = [ "serde_core", ] +[[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_spanned" version = "1.0.3" @@ -1228,19 +1879,41 @@ dependencies = [ "serde", ] +[[package]] +name = "sha-1" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" dependencies = [ - "block-buffer", + "block-buffer 0.9.0", "cfg-if", "cpufeatures", - "digest", + "digest 0.9.0", "opaque-debug", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1277,6 +1950,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.1" @@ -1287,12 +1970,65 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "srt-protocol" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22790a85cd5d34355e9fc246ded6a1f037add6fd0e0efe4d4914c2d51c20f246" +dependencies = [ + "aes", + "array-init", + "arraydeque", + "bitflags", + "bytes", + "cipher", + "ctr", + "derive_more", + "hex", + "hmac 0.12.1", + "keyed_priority_queue", + "log", + "pbkdf2", + "rand 0.8.5", + "regex", + "sha-1", + "streaming-stats", + "take-until", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "srt-tokio" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a55cb90afac5672b00954e3291846dd262cfef3b52d1b507f580180433373d3" +dependencies = [ + "bytes", + "futures", + "log", + "rand 0.8.5", + "socket2 0.5.10", + "srt-protocol", + "tokio", + "tokio-stream", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "streaming-stats" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0d670ce4e348a2081843569e0f79b21c99c91bb9028b3b3ecb0f050306de547" +dependencies = [ + "num-traits", +] + [[package]] name = "strsim" version = "0.11.1" @@ -1357,6 +2093,12 @@ dependencies = [ "libc", ] +[[package]] +name = "take-until" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bdb6fa0dfa67b38c1e66b7041ba9dcf23b99d8121907cd31c807a332f7a0bbb" + [[package]] name = "tempfile" version = "3.23.0" @@ -1455,7 +2197,7 @@ dependencies = [ "mio", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.1", "tokio-macros", "windows-sys 0.61.2", ] @@ -1491,6 +2233,18 @@ 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", + "tokio-util", +] + [[package]] name = "tokio-util" version = "0.7.17" @@ -1556,6 +2310,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -1632,18 +2387,35 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + [[package]] name = "tracing-subscriber" version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log", + "tracing-serde", ] [[package]] @@ -1658,12 +2430,24 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -1694,6 +2478,17 @@ 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", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -1712,6 +2507,25 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -1733,7 +2547,16 @@ version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.46.0", +] + +[[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]] @@ -1794,6 +2617,53 @@ 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", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.82" @@ -1823,6 +2693,27 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix", + "winsafe", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.1.3" @@ -2026,12 +2917,106 @@ version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "wit-bindgen" version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[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-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", + "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", + "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", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" version = "0.6.2" diff --git a/Cargo.toml b/Cargo.toml index 802d899..f431998 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,23 +1,39 @@ +[workspace] +members = [ + "crates/reestream-core", + "crates/reestream-ffmpeg", + "crates/reestream-server", + "crates/reestream-srt", +] +resolver = "2" + [package] name = "reestream" -version = "0.1.1" +version = "0.2.0" edition = "2024" authors = ["RustLangES contact@rustlang-es.org"] -description = "rtmp multistream demuxer" +description = "RTMP multistream demuxer with HLS, FFmpeg, and web API" license = "MIT OR Apache-2.0" repository = "https://github.com/RustLangES/reestream" +[features] +default = ["core"] +core = ["dep:reestream-core"] +hls = ["dep:reestream-server", "reestream-server/hls"] +api = ["dep:reestream-server", "reestream-server/api"] +srt = ["dep:reestream-srt"] +ffmpeg = ["dep:reestream-ffmpeg"] +preview = ["hls"] +webhook = ["dep:reestream-server", "reestream-server/api"] +all = ["hls", "api", "ffmpeg", "preview", "srt", "webhook"] + [dependencies] -bytes = "1.10" +reestream-core = { path = "crates/reestream-core", optional = true } +reestream-ffmpeg = { path = "crates/reestream-ffmpeg", optional = true } +reestream-server = { path = "crates/reestream-server", optional = true } +reestream-srt = { path = "crates/reestream-srt", optional = true } + clap = { version = "4.5.54", features = ["derive"] } -reqwest = { version = "0.12.24", default-features = false, features = [ - "json", - "rustls-tls", - "system-proxy", - "http2", -] } -rml_rtmp = "0.8" -serde = { version = "1", features = ["derive"] } tokio = { version = "1", default-features = false, features = [ "io-std", "io-util", @@ -28,8 +44,12 @@ tokio = { version = "1", default-features = false, features = [ "signal", "sync", ] } -tokio-native-tls = "0.3" -toml = "0.9" tracing = { version = "0.1", features = ["log"] } -tracing-subscriber = "0.3" -url = { version = "2.5", features = ["serde"] } +tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] } + +[dev-dependencies] +bytes = "1.10" +rml_rtmp = "0.8" +serde_json = "1" +url = "2.5" +proptest = "1" diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..a92b632 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 RustLangES + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9669bf4 --- /dev/null +++ b/README.md @@ -0,0 +1,541 @@ +# Reestream + +RTMP/SRT multistream relay server with HLS, HTTP-FLV, FFmpeg transcoding, REST API, and web dashboard. + +## Features + +- **RTMP relay** — receive one stream, forward to multiple platforms simultaneously +- **SRT protocol** — low-latency input/output with AES-128 encryption +- **HLS server** — live `.m3u8` playlist and `.ts` segment serving +- **HTTP-FLV** — zero-copy FLV live streaming at `/stream.flv` +- **FFmpeg integration** — binary resolver, command builder, supervisor, download, hardware acceleration +- **REST API** — 25 endpoints for stream/platform/config/setup/recording management +- **Web dashboard** — Vite 8 + Preact + TypeScript + Tailwind CSS 4 +- **Video preview** — FLV/HLS player with latency monitor (flv.js) +- **First-time setup** — CLI `--setup` wizard + dashboard web wizard +- **Settings panel** — stream key reveal/reset, server endpoints, OBS setup guide +- **Platform management** — add/remove with presets (Twitch, YouTube, Facebook, Instagram, Kick, TikTok) +- **Stream recording** — FFmpeg-based recording to MP4/FLV/MKV/TS +- **Prometheus metrics** — uptime, streams, viewers, per-stream status and bitrate +- **Webhooks** — notifications for stream start/end/error, viewer connect/disconnect +- **Production hardening** — graceful shutdown, rate limiting, connection pool, signal handlers, config watcher +- **Structured logging** — JSON output option with configurable log level + +## Quick Start + +```bash +# Build with all features +cargo build --release --features all + +# Create config +cat > config.toml < Config file path [default: config.toml] + --json-log Enable JSON structured logging + --log-level Log level: trace, debug, info, warn, error [default: info] + --setup Run interactive first-time setup wizard +``` + +### First Run + +```bash +# Option 1: CLI wizard +reestream --setup + +# Option 2: Auto-detect → start server → open browser +reestream +# Shows: "No config file found. Run with --setup or open http://localhost:8080" +# Opens dashboard setup wizard automatically +``` + +## Configuration + +### config.toml + +```toml +rtmp_addr = "0.0.0.0" # Bind address +rtmp_port = 1935 # RTMP port +stream_key = "publisher-key" # Required stream key for publishing + +# Optional: output platforms +[[platform]] +url = "rtmp://live.twitch.tv/app" +key = "twitch-key" +orientation = "horizontal" # "horizontal" (default) or "vertical" + +[[platform]] +url = "rtmps://live-api-s.facebook.com:443/rtmp/" +key = "facebook-key" +orientation = "vertical" +``` + +### Programmatic (Rust) + +```rust +use reestream::config::{Config, ConfigBuilder, Orientation}; +use url::Url; + +let config = Config::builder() + .addr("0.0.0.0") + .port(1935) + .stream_key("my-key") + .add_platform( + Url::parse("rtmp://live.twitch.tv/app").unwrap(), + "twitch-key", + Orientation::Horizontal, + ) + .build(); + +config.validate().unwrap(); +let toml = config.to_toml().unwrap(); +``` + +## Services + +When running with `--features all`, three services start: + +| Service | Default Port | Description | +|---------|-------------|-------------| +| RTMP relay | 1935 | Accepts RTMP/RTMPS publish connections | +| SRT listener | 3000 | Accepts SRT input streams | +| HTTP server | 8080 | Dashboard, API, HLS, FLV, metrics | + +## API Endpoints + +### Health & Status + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/health` | Health check (200 OK) | +| `GET` | `/api/status` | Version, uptime, active streams, viewers | +| `GET` | `/metrics` | Prometheus-format metrics | + +### Streams + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/streams` | List all streams | +| `POST` | `/api/streams` | Add stream `{name, input_url}` | +| `DELETE` | `/api/streams/{id}` | Remove stream | +| `GET` | `/api/streams/{id}/stats` | Stream statistics | + +### Platforms + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/platforms` | List all platforms | +| `POST` | `/api/platforms` | Add platform `{name, url, key}` | +| `DELETE` | `/api/platforms/{id}` | Remove platform | +| `PUT` | `/api/platforms/{id}/toggle` | Toggle enabled/disabled | + +### Config + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/config` | Get current config | +| `PUT` | `/api/config` | Update config | +| `POST` | `/api/config/reload` | Trigger hot-reload | + +### Setup (First Run) + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/setup/status` | First-run detection | +| `POST` | `/api/setup/save` | Save config from wizard | +| `GET` | `/api/setup/info` | Server endpoints, hostname, ports | +| `GET` | `/api/setup/key` | Reveal stream key | +| `POST` | `/api/setup/key` | Reset stream key (generates new UUID) | + +### Recordings + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/recordings` | List all recordings | +| `POST` | `/api/recordings/start` | Start recording `{stream_id, input_url}` | +| `POST` | `/api/recordings/{id}/stop` | Stop recording | +| `DELETE` | `/api/recordings/{id}` | Delete recording + file | + +### Streaming + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/stream.m3u8` | HLS playlist (live) | +| `GET` | `/hls/{filename}` | HLS segment file | +| `GET` | `/stream.flv` | FLV live stream | + +### Dashboard + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/` | Web UI dashboard | +| `GET` | `/dashboard` | Web UI dashboard (alias) | +| `GET` | `/assets/{*path}` | Static assets (JS, CSS) | +| `GET` | `/favicon.svg` | Favicon | + +### Response Format + +All API responses use a consistent wrapper: + +```json +{ + "success": true, + "data": { ... } +} +``` + +```json +{ + "success": false, + "error": "error message" +} +``` + +## Prometheus Metrics + +``` +# HELP reestream_uptime_seconds Server uptime +# TYPE reestream_uptime_seconds gauge +reestream_uptime_seconds 3600 + +# HELP reestream_streams_total Number of streams +# TYPE reestream_streams_total gauge +reestream_streams_total 2 + +# HELP reestream_viewers_total Total viewers +# TYPE reestream_viewers_total gauge +reestream_viewers_total 150 + +# HELP reestream_stream_status Stream status (1=Live) +# TYPE reestream_stream_status gauge +reestream_stream_status{id="abc-123",name="main"} 1 + +# HELP reestream_stream_bitrate_kbps Stream bitrate +# TYPE reestream_stream_bitrate_kbps gauge +reestream_stream_bitrate_kbps{id="abc-123"} 5000 +``` + +## Feature Flags + +```toml +[features] +default = ["core"] +core = ["dep:reestream-core"] # RTMP relay + multistream +hls = ["dep:reestream-server", "reestream-server/hls"] # HLS/HTTP server +api = ["dep:reestream-server", "reestream-server/api"] # REST API +srt = ["dep:reestream-srt"] # SRT protocol +ffmpeg = ["dep:reestream-ffmpeg"] # FFmpeg management +preview = ["hls"] # Stream preview +webhook = ["dep:reestream-server", "reestream-server/api"] # Webhooks +all = ["hls", "api", "ffmpeg", "preview", "srt", "webhook"] +``` + +### Build Targets + +```bash +# Minimal (RTMP relay only) +cargo build --release --no-default-features --features core + +# With HLS +cargo build --release --features core,hls + +# With SRT +cargo build --release --features core,srt + +# With API +cargo build --release --features core,api + +# Everything +cargo build --release --features all +``` + +## Architecture + +``` +reestream/ # Root binary crate +├── src/ +│ ├── main.rs # CLI, signal handlers, service startup +│ └── lib.rs # Re-exports from workspace crates +├── crates/ +│ ├── reestream-core/ # RTMP relay, config, pipeline, hardening +│ │ └── src/ +│ │ ├── client.rs # RTMP publisher handler +│ │ ├── client/push.rs # Push client with reconnection +│ │ ├── config.rs # TOML config, ConfigBuilder +│ │ ├── error.rs # RelayError enum +│ │ ├── hardening.rs # Graceful shutdown, rate limiter, connection pool +│ │ ├── pipeline.rs # StreamPipeline/PipelineManager traits +│ │ ├── pipeline_impl.rs # RTMP/SRT/File pipeline implementations +│ │ ├── provider.rs # OAuth2 stream key provider +│ │ └── server.rs # RTMP handshake +│ ├── reestream-ffmpeg/ # FFmpeg binary management +│ │ └── src/ +│ │ ├── command.rs # Command builder (passthrough, HLS, transcode, HW accel) +│ │ ├── error.rs # FfmpegError enum +│ │ ├── process.rs # Process wrapper, supervisor with auto-restart +│ │ └── resolver.rs # Binary resolver, platform URLs, download +│ ├── reestream-server/ # HTTP server, API, HLS, FLV, dashboard +│ │ ├── static/ # Compiled dashboard (Vite output, embedded via rust-embed) +│ │ └── src/ +│ │ ├── api.rs # API types and route definitions +│ │ ├── dashboard.rs # Static file serving (rust-embed) +│ │ ├── flv.rs # FLV container builder and streaming +│ │ ├── hls.rs # HLS segmenter and playlist generation +│ │ ├── http.rs # Axum router, all endpoint handlers +│ │ ├── stream.rs # StreamManager (CRUD for streams/platforms) +│ │ └── webhook.rs # Webhook sender with event filtering +│ └── reestream-srt/ # SRT protocol support +│ └── src/ +│ ├── config.rs # SRT config (latency, encryption, bandwidth) +│ ├── error.rs # SrtError enum +│ ├── listener.rs # SRT input listener +│ └── sender.rs # SRT output sender +├── dashboard/ # Vite 8 + Preact + TypeScript + Tailwind +│ └── src/ +│ ├── api/ # Type-safe API client +│ ├── hooks/ # usePolling, useVideoPlayer +│ └── components/ # Header, StatsCards, VideoPreview, StreamsTable, etc. +└── tests/ # Integration tests + ├── common/mock_rtmp.rs # Mock RTMP server/client + └── *.rs # 58 integration tests +``` + +## Web Dashboard + +The dashboard is a single-page app built with Vite 8, Preact, TypeScript, and Tailwind CSS 4. It's compiled to static assets and embedded into the binary via `rust-embed`. + +### Features + +- Real-time stats (uptime, streams, viewers) +- Stream and platform management tables +- Live video preview with FLV.js (low-latency) or native HLS +- Source toggle (FLV/HLS), latency monitor, player controls +- Log viewer with in-browser log streaming +- Auto-refresh polling (5s/10s/15s) + +### Building the Dashboard + +```bash +cd dashboard +bun install +bun run build # outputs to ../crates/reestream-server/static/ +``` + +Then rebuild the Rust binary to embed the new assets: + +```bash +cargo build --release --features all +``` + +## FFmpeg Integration + +### Binary Resolution Order + +1. Custom path (if set) +2. Local cache at `~/.local/share/reestream/bin/ffmpeg` +3. System PATH +4. Auto-download from platform-specific URL + +### Hardware Acceleration + +| Accelerator | Flag | Platform | +|-------------|------|----------| +| VAAPI | `HardwareAccel::Vaapi` | Linux (Intel/AMD) | +| NVENC | `HardwareAccel::Nvenc` | Linux/Windows (NVIDIA) | +| VideoToolbox | `HardwareAccel::VideoToolbox` | macOS | +| MMAL | `HardwareAccel::Mmal` | Raspberry Pi | + +### Command Builder + +```rust +use reestream::ffmpeg::{FfmpegCommand, InputSource, OutputDestination, HardwareAccel}; +use std::path::PathBuf; + +let cmd = FfmpegCommand::new(PathBuf::from("ffmpeg"), InputSource::Pipe) + .hw_accel(HardwareAccel::Nvenc) + .passthrough_to_rtmp("rtmp://live.twitch.tv/app/key") + .to_hls(PathBuf::from("/tmp/segments"), PathBuf::from("/tmp/playlist.m3u8")); + +let args = cmd.build_args(); +``` + +## SRT Protocol + +### Listener (Input) + +```rust +use reestream::srt::{SrtConfig, SrtListener}; + +let config = SrtConfig { + enabled: true, + listen_addr: "0.0.0.0".into(), + listen_port: 3000, + latency_ms: 200, + passphrase: Some("my-encryption-passphrase".into()), + ..Default::default() +}; + +let listener = SrtListener::new(config); +listener.run().await?; +``` + +### Sender (Output) + +```rust +use reestream::srt::SrtSender; +use url::Url; + +let mut sender = SrtSender::new( + Url::parse("srt://output-server:3000").unwrap(), + 200, + Some("encryption-passphrase".into()), +); +sender.connect().await?; +sender.send(data).await?; +``` + +## Webhooks + +### Configuration + +```rust +use reestream::http_server::webhook::{WebhookConfig, WebhookSender, WebhookEvent, create_payload}; +use serde_json::json; + +let config = WebhookConfig { + enabled: true, + url: "https://hooks.example.com/reestream".into(), + secret: Some("webhook-secret".into()), + on_stream_start: true, + on_stream_end: true, + on_stream_error: true, + ..Default::default() +}; + +let sender = WebhookSender::new(config); +let payload = create_payload( + WebhookEvent::StreamStart, + "stream-id".into(), + json!({"name": "my-stream"}), +); +sender.send(&payload).await?; +``` + +## Production Hardening + +### Graceful Shutdown + +```rust +use reestream::hardening::{GracefulShutdown, setup_signal_handlers}; +use std::sync::Arc; + +let shutdown = Arc::new(GracefulShutdown::new()); +setup_signal_handlers(shutdown.clone()).await; + +// In your main loop: +tokio::select! { + _ = shutdown.wait_for_shutdown() => { + shutdown.drain_timeout(Duration::from_secs(30)).await; + break; + } + // ... other branches +} +``` + +### Rate Limiting & Connection Pool + +```rust +use reestream::hardening::{RateLimiter, ConnectionPool}; + +let rate_limiter = RateLimiter::new(100); // 100 connections/sec +let pool = ConnectionPool::new(1000); // max 1000 concurrent + +if rate_limiter.try_acquire().await { + if let Some(guard) = pool.try_acquire().await { + // handle connection + // guard dropped automatically on scope exit + } +} +``` + +### Config Watcher + +```rust +use reestream::hardening::ConfigWatcher; + +ConfigWatcher::watch_loop( + PathBuf::from("config.toml"), + Duration::from_secs(5), + || println!("Config changed, reloading..."), +).await; +``` + +## Testing + +```bash +# All tests +cargo test --workspace --all-features + +# Specific crate +cargo test -p reestream-core +cargo test -p reestream-ffmpeg +cargo test -p reestream-server +cargo test -p reestream-srt + +# With output +cargo test --workspace -- --nocapture + +# Clippy +cargo clippy --workspace --all-targets --all-features + +# Formatting +cargo fmt --all -- --check + +# Coverage +cargo tarpaulin --workspace --out Html +``` + +## Low Latency Configuration + +- **RTMP chunk size:** 128 bytes (smaller chunks, lower per-chunk latency) +- **ACK window:** 256KB (more frequent acknowledgments) +- **TCP_NODELAY:** enabled on all sockets +- **FLV player:** `enableStashBuffer: false`, `stashInitialSize: 128` +- **SRT default latency:** 200ms + +## Supported Protocols + +| Protocol | Input | Output | +|----------|-------|--------| +| RTMP | ✅ | ✅ | +| RTMPS | ✅ | ✅ | +| SRT | ✅ | ✅ | +| HLS | — | ✅ | +| HTTP-FLV | — | ✅ | + +## License + +MIT OR Apache-2.0 diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..aade406 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,228 @@ +# Reestream Roadmap + +## Current Status (v0.3.0) + +| Metric | Value | +|--------|-------| +| Crates | 5 (core, ffmpeg, server, srt, root) | +| Rust source files | 28 | +| Rust lines of code | ~6,000 | +| Tests | 294 | +| API endpoints | 25 | +| Feature flags | 8 | +| Dashboard components | 10 | + +### What's built +- RTMP relay with multistream forwarding (RTMP/RTMPS) +- SRT protocol (input listener, output sender, AES-128 encryption) +- HLS segmenter with live `.m3u8` playlist +- HTTP-FLV live streaming (`/stream.flv`) +- FFmpeg integration (binary resolver, command builder, supervisor, download, HW accel) +- REST API (25 endpoints: streams, platforms, config, setup, recordings, metrics) +- Web dashboard (Vite 8 + Preact + TypeScript + Tailwind 4 + flv.js) +- Video preview (FLV/HLS toggle, latency monitor) +- First-time setup (CLI `--setup` wizard + dashboard web wizard) +- Settings panel (stream key reveal/reset, server endpoints, OBS guide) +- Platform management (add/remove with presets: Twitch, YouTube, Facebook, Instagram, Kick, TikTok) +- Stream recording (FFmpeg-based, MP4/FLV/MKV/TS, dashboard controls) +- Webhook notifications (stream start/end/error, viewer connect/disconnect) +- Structured JSON logging (`--json-log`, `--log-level`) +- Production hardening (graceful shutdown, rate limiting, connection pool, signal handlers, config watcher) +- Prometheus metrics (uptime, streams, viewers, per-stream status/bitrate) +- Concrete pipeline implementations (RTMP, SRT, File) + +--- + +## Build System + +```toml +[features] +default = ["core"] +core = ["dep:reestream-core"] # RTMP relay + multistream +hls = ["dep:reestream-server", "reestream-server/hls"] # HLS/HTTP server +api = ["dep:reestream-server", "reestream-server/api"] # REST API +srt = ["dep:reestream-srt"] # SRT protocol +ffmpeg = ["dep:reestream-ffmpeg"] # FFmpeg process management +preview = ["hls"] # Stream preview +webhook = ["dep:reestream-server", "reestream-server/api"] # Webhooks +all = ["hls", "api", "ffmpeg", "preview", "srt", "webhook"] +``` + +```bash +cargo build --release --features all +``` + +--- + +## TODO: Future Features + +### 1. SRT Bridge (runtime wiring) +- [ ] SRT input → RTMP relay → HLS output pipeline +- [ ] Auto-detect SRT publish and route to RTMP clients + +### 2. Stream Processing +- [ ] Transcode via FFmpeg (resolution/bitrate/codec conversion) +- [ ] Resize / scale filters +- [ ] Watermark overlay (image or text) +- [ ] Thumbnail / preview frame generation +- [ ] Input sources: RTSP, USB capture + +### 3. Web UI Enhancements +- [ ] i18n (internationalization support) +- [ ] Stream analytics charts (bitrate, viewers over time) +- [ ] Dark/light theme toggle +- [ ] Keyboard shortcuts +- [ ] Mobile-responsive improvements + +### 4. Multiplatform Distribution +- [ ] macOS builds (x86_64, aarch64) +- [ ] Windows builds (x86_64) +- [ ] Docker images: `reestream/core`, `reestream/full`, `reestream/cuda` +- [ ] GitHub Actions CI/CD pipeline + +### 5. Production Hardening +- [ ] Let's Encrypt auto-TLS (ACME integration) +- [ ] Fuzz testing for RTMP packet parsing +- [ ] Stress tests with 100+ concurrent streams +- [ ] Connection draining on config reload + +### 6. Advanced Recording +- [ ] Scheduled recordings (start/stop at specific times) +- [ ] Recording rotation (auto-split by duration or size) +- [ ] Recording upload to S3/R2/MinIO +- [ ] Recording format conversion post-capture + +### 7. Advanced Streaming +- [ ] RTSP input/output support +- [ ] WebRTC output (low-latency viewer playback) +- [ ] Adaptive bitrate (ABR) for HLS +- [ ] DVR / timeshift (rewind live stream) +- [ ] Multi-language audio track support + +### 8. Observability +- [ ] OpenTelemetry tracing export +- [ ] Grafana dashboard JSON template +- [ ] Alerting webhooks (configurable thresholds) +- [ ] Log file rotation and archival + +### 9. Security +- [ ] RTMP stream key validation per-platform +- [ ] IP allowlist/blocklist for publishing +- [ ] Rate limiting per stream key +- [ ] HTTPS for dashboard (auto-TLS or manual cert) +- [ ] API authentication (token-based) + +--- + +## API Endpoints (25) + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/` | Dashboard | +| `GET` | `/dashboard` | Dashboard (alias) | +| `GET` | `/assets/{*path}` | Static assets | +| `GET` | `/favicon.svg` | Favicon | +| `GET` | `/health` | Health check | +| `GET` | `/api/status` | Server status | +| `GET` | `/api/streams` | List streams | +| `POST` | `/api/streams` | Add stream | +| `DELETE` | `/api/streams/{id}` | Remove stream | +| `GET` | `/api/streams/{id}/stats` | Stream stats | +| `GET` | `/api/config` | Get config | +| `PUT` | `/api/config` | Update config | +| `POST` | `/api/config/reload` | Reload config | +| `GET` | `/api/setup/status` | First-run detection | +| `POST` | `/api/setup/save` | Save setup config | +| `GET` | `/api/setup/info` | Server endpoints/URLs | +| `GET` | `/api/setup/key` | Reveal stream key | +| `POST` | `/api/setup/key` | Reset stream key | +| `GET` | `/api/platforms` | List platforms | +| `POST` | `/api/platforms` | Add platform | +| `DELETE` | `/api/platforms/{id}` | Remove platform | +| `PUT` | `/api/platforms/{id}/toggle` | Toggle platform | +| `GET` | `/api/recordings` | List recordings | +| `POST` | `/api/recordings/start` | Start recording | +| `POST` | `/api/recordings/{id}/stop` | Stop recording | +| `DELETE` | `/api/recordings/{id}` | Delete recording | +| `GET` | `/stream.m3u8` | HLS playlist | +| `GET` | `/hls/{filename}` | HLS segment | +| `GET` | `/stream.flv` | FLV live stream | +| `GET` | `/metrics` | Prometheus metrics | + +--- + +## CLI + +``` +reestream [OPTIONS] + +Options: + -c, --config Config file path [default: config.toml] + --json-log Enable JSON structured logging + --log-level Log level [default: info] + --setup Run interactive first-time setup wizard +``` + +--- + +## Test Coverage + +| Module | Tests | +|--------|------:| +| reestream-core | 131 | +| reestream-ffmpeg | 23 | +| reestream-server | 51 | +| reestream-srt | 23 | +| reestream (root) | 10 | +| integration tests | 56 | +| **Total** | **294** | + +--- + +## Crate Architecture + +``` +reestream/ +├── crates/ +│ ├── reestream-core/ +│ │ └── src/ +│ │ ├── client.rs # RTMP publisher handler +│ │ ├── client/push.rs # Push client with reconnection +│ │ ├── config.rs # TOML config, ConfigBuilder +│ │ ├── error.rs # RelayError +│ │ ├── hardening.rs # Shutdown, rate limiter, pool, signals, watcher +│ │ ├── pipeline.rs # StreamPipeline/PipelineManager traits +│ │ ├── pipeline_impl.rs # RTMP/SRT/File pipelines +│ │ ├── provider.rs # OAuth2 stream key provider +│ │ ├── server.rs # RTMP handshake +│ │ └── setup.rs # First-run detection, CLI wizard, setup API +│ ├── reestream-ffmpeg/ +│ │ └── src/ +│ │ ├── command.rs # Command builder +│ │ ├── error.rs # FfmpegError +│ │ ├── process.rs # Process wrapper, supervisor +│ │ └── resolver.rs # Binary resolver, download +│ ├── reestream-server/ +│ │ ├── static/ # Compiled dashboard (rust-embed) +│ │ └── src/ +│ │ ├── api.rs # API types, route definitions +│ │ ├── dashboard.rs # Static file serving +│ │ ├── flv.rs # FLV container builder +│ │ ├── hls.rs # HLS segmenter +│ │ ├── http.rs # Axum router, all handlers +│ │ ├── recording.rs # FFmpeg recording manager +│ │ ├── stream.rs # StreamManager CRUD +│ │ └── webhook.rs # Webhook sender +│ └── reestream-srt/ +│ └── src/ +│ ├── config.rs # SRT config +│ ├── error.rs # SrtError +│ ├── listener.rs # SRT input +│ └── sender.rs # SRT output +├── dashboard/ +│ └── src/ +│ ├── api/ # Type-safe API client +│ ├── hooks/ # usePolling, useVideoPlayer +│ └── components/ # 10 components +└── tests/ # Integration tests +``` diff --git a/crates/reestream-core/Cargo.toml b/crates/reestream-core/Cargo.toml new file mode 100644 index 0000000..be17c78 --- /dev/null +++ b/crates/reestream-core/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "reestream-core" +version = "0.2.0" +edition = "2024" +authors = ["RustLangES contact@rustlang-es.org"] +description = "Core RTMP relay and multistream engine" +license = "MIT OR Apache-2.0" + +[features] +test-utils = [] + +[dependencies] +async-trait = "0.1" +bytes = "1.10" +reqwest = { version = "0.12.24", default-features = false, features = [ + "json", + "rustls-tls", + "system-proxy", + "http2", +] } +rml_rtmp = "0.8" +serde = { version = "1", features = ["derive"] } +tokio = { version = "1", default-features = false, features = [ + "io-std", + "io-util", + "macros", + "net", + "rt-multi-thread", + "time", + "signal", + "sync", +] } +tokio-native-tls = "0.3" +toml = "0.9" +tracing = { version = "0.1", features = ["log"] } +url = { version = "2.5", features = ["serde"] } +uuid = { version = "1", features = ["v4"] } + +[dev-dependencies] +serde_json = "1" +proptest = "1" diff --git a/crates/reestream-core/src/client.rs b/crates/reestream-core/src/client.rs new file mode 100644 index 0000000..f80e778 --- /dev/null +++ b/crates/reestream-core/src/client.rs @@ -0,0 +1,420 @@ +// src/client.rs +pub mod push; + +use std::sync::Arc; +use std::time::Duration; + +use bytes::Bytes; +pub use push::PushClient; +use rml_rtmp::handshake::{Handshake, HandshakeProcessResult, PeerType}; +use rml_rtmp::sessions::{ClientSessionResult, ServerSessionEvent, ServerSessionResult}; +use rml_rtmp::time::RtmpTimestamp; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio::sync::{RwLock, mpsc}; +use tokio::time::timeout; +use tracing::{error, info, warn}; + +use crate::DynStream; +use crate::config::Platform; +use crate::server::handshake_and_create_server_session; + +fn is_video_sequence_header(data: &Bytes) -> bool { + data.len() > 1 && data[0] == 0x17 && data[1] == 0x00 +} + +fn is_audio_sequence_header(data: &Bytes) -> bool { + data.len() > 1 && (data[0] & 0xF0) == 0xA0 && data[1] == 0x00 +} + +pub async fn perform_client_handshake( + stream: &mut DynStream, +) -> Result<(), Box> { + let mut hs = Handshake::new(PeerType::Client); + let c0_c1 = hs.generate_outbound_p0_and_p1()?; + stream.write_all(&c0_c1).await?; + let mut buf = [0u8; 4096]; + loop { + let n = stream.read(&mut buf).await?; + if n == 0 { + return Err("EOF during client handshake".into()); + } + + match hs.process_bytes(&buf[..n])? { + HandshakeProcessResult::InProgress { response_bytes } => { + if !response_bytes.is_empty() { + stream.write_all(&response_bytes).await?; + } + } + HandshakeProcessResult::Completed { response_bytes, .. } => { + if !response_bytes.is_empty() { + stream.write_all(&response_bytes).await?; + } + break; + } + } + } + Ok(()) +} + +pub async fn handle_publisher( + mut inbound: TcpStream, + platforms: Arc>>, + stream_key_conf: String, +) -> Result<(), Box> { + let (mut server_session, leftover) = handshake_and_create_server_session(&mut inbound).await?; + let (reconnect_tx, mut reconnect_rx) = mpsc::channel::<(usize, PushClient)>(10); + + let pls = platforms.read().await.clone(); + let mut push_clients: Vec = Vec::new(); + + if !leftover.is_empty() { + let results = server_session.handle_input(&leftover)?; + for res in results { + if let ServerSessionResult::OutboundResponse(packet) = res { + inbound.write_all(&packet.bytes).await?; + } + } + } + + let mut read_buf = [0u8; 8192]; + + loop { + tokio::select! { + Some((index, new_client)) = reconnect_rx.recv() => { + if index < push_clients.len() { + info!("Replacing old client with reconnected client at index {}", index); + push_clients[index] = new_client; + } + } + + n_res = inbound.read(&mut read_buf) => { + let n = match n_res { + Ok(0) => { + info!("Source stream ended (EOF). Shutting down push clients gracefully..."); + for (i, pc) in push_clients.iter().enumerate() { + info!("Stopping client {}", i); + pc.shutdown().await; + } + push_clients.clear(); + break; + }, + Ok(n) => n, + Err(e) => return Err(e.into()), + }; + + let results = server_session.handle_input(&read_buf[..n])?; + + for res in results { + match res { + ServerSessionResult::OutboundResponse(packet) => { + let _ = inbound.write_all(&packet.bytes).await; + } + ServerSessionResult::RaisedEvent(ev) => match ev { + ServerSessionEvent::ConnectionRequested { request_id, .. } => { + if let Ok(out) = server_session.accept_request(request_id) { + for r in out { + if let ServerSessionResult::OutboundResponse(p) = r { + let _ = inbound.write_all(&p.bytes).await; + } + } + } + } + ServerSessionEvent::PublishStreamRequested { request_id, stream_key, .. } => { + if stream_key == stream_key_conf { + if let Ok(out) = server_session.accept_request(request_id) { + for r in out { + if let ServerSessionResult::OutboundResponse(p) = r { + let _ = inbound.write_all(&p.bytes).await; + } + } + } + + if push_clients.is_empty() { + for p in &pls { + match timeout(Duration::from_secs(5), PushClient::connect_and_publish(&p.url, p.key.clone(), None, None, None)).await { + Ok(Ok(pc)) => { + info!("Connected to platform: {}", p.url); + push_clients.push(pc); + }, + _ => error!("Failed to connect to platform: {}", p.url), + } + } + } + } else { + let _ = server_session.reject_request(request_id, "NetStream.Publish.BadName", "Invalid key"); + return Ok(()); + } + } + ServerSessionEvent::VideoDataReceived { data, timestamp, .. } => { + forward_to_push_clients(&mut push_clients, &reconnect_tx, data, timestamp, true).await; + } + ServerSessionEvent::AudioDataReceived { data, timestamp, .. } => { + forward_to_push_clients(&mut push_clients, &reconnect_tx, data, timestamp, false).await; + } + ServerSessionEvent::StreamMetadataChanged { metadata, .. } => { + for pc in &push_clients { + let mut state = pc.client_state.write().await; + state.prepublish_metadata = Some(metadata.clone()); + + if *pc.publish_ready_rx.borrow() + && let Ok(ClientSessionResult::OutboundResponse(packet)) = state.session.publish_metadata(&metadata) + { + let _ = pc.tx_feed.try_send(Bytes::from(packet.bytes)); + } + } + } + _ => {} + }, + _ => {} + } + } + } + } + } + Ok(()) +} + +async fn forward_to_push_clients( + push_clients: &mut [PushClient], + reconnect_tx: &mpsc::Sender<(usize, PushClient)>, + data: Bytes, + timestamp: RtmpTimestamp, + is_video: bool, +) { + for (i, pc) in push_clients.iter_mut().enumerate() { + let mut state = pc.client_state.write().await; + + if is_video && is_video_sequence_header(&data) { + state.update_video_header(data.clone()); + } else if !is_video && is_audio_sequence_header(&data) { + state.update_audio_header(data.clone()); + } + + if pc.tx_feed.is_closed() { + let p_url = pc.url.clone(); + let p_key = pc.stream_key.clone(); + let tx_back = reconnect_tx.clone(); + + let cached_vid = state.video_sequence_header.clone(); + let cached_aud = state.audio_sequence_header.clone(); + let cached_meta = state.prepublish_metadata.clone(); + + drop(state); + + let (dummy_tx, mut dummy_rx) = mpsc::channel(1); + pc.tx_feed = dummy_tx; + + tokio::spawn(async move { + let _drainer = + tokio::spawn(async move { while dummy_rx.recv().await.is_some() {} }); + + info!( + "Connection lost for platform {}. Starting reconnection loop...", + i + ); + + loop { + tokio::time::sleep(Duration::from_secs(2)).await; + + match PushClient::connect_and_publish( + &p_url, + p_key.clone(), + cached_vid.clone(), + cached_aud.clone(), + cached_meta.clone(), + ) + .await + { + Ok(new_pc) => { + info!("Reconnection successful for platform index {}", i); + if tx_back.send((i, new_pc)).await.is_err() { + warn!("Main loop closed, abandoning reconnection for {}", i); + } + break; + } + Err(e) => { + warn!("Reconnection failed for platform {}: {}. Retrying...", i, e); + } + } + } + }); + continue; + } + + if *pc.publish_ready_rx.borrow() { + let res = if is_video { + state + .session + .publish_video_data(data.clone(), timestamp, true) + } else { + state + .session + .publish_audio_data(data.clone(), timestamp, true) + }; + + if let Ok(ClientSessionResult::OutboundResponse(packet)) = res { + let _ = pc.tx_feed.try_send(Bytes::from(packet.bytes)); + } + } else if is_video { + state.buffer_video(data.clone(), timestamp); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_video_sequence_header_valid() { + let data = Bytes::from(vec![0x17, 0x00, 0x00, 0x00]); + assert!(is_video_sequence_header(&data)); + } + + #[test] + fn test_is_video_sequence_header_empty() { + let data = Bytes::new(); + assert!(!is_video_sequence_header(&data)); + } + + #[test] + fn test_is_video_sequence_header_single_byte() { + let data = Bytes::from(vec![0x17]); + assert!(!is_video_sequence_header(&data)); + } + + #[test] + fn test_is_video_sequence_header_wrong_type() { + let data = Bytes::from(vec![0x27, 0x00]); + assert!(!is_video_sequence_header(&data)); + } + + #[test] + fn test_is_video_sequence_header_wrong_flag() { + let data = Bytes::from(vec![0x17, 0x01]); + assert!(!is_video_sequence_header(&data)); + } + + #[test] + fn test_is_audio_sequence_header_valid_aac() { + let data = Bytes::from(vec![0xAF, 0x00, 0x01]); + assert!(is_audio_sequence_header(&data)); + } + + #[test] + fn test_is_audio_sequence_header_valid_other_codec() { + let data = Bytes::from(vec![0xA0, 0x00]); + assert!(is_audio_sequence_header(&data)); + } + + #[test] + fn test_is_audio_sequence_header_empty() { + let data = Bytes::new(); + assert!(!is_audio_sequence_header(&data)); + } + + #[test] + fn test_is_audio_sequence_header_single_byte() { + let data = Bytes::from(vec![0xAF]); + assert!(!is_audio_sequence_header(&data)); + } + + #[test] + fn test_is_audio_sequence_header_not_audio() { + let data = Bytes::from(vec![0x17, 0x00]); + assert!(!is_audio_sequence_header(&data)); + } + + #[test] + fn test_is_audio_sequence_header_wrong_flag() { + let data = Bytes::from(vec![0xAF, 0x01]); + assert!(!is_audio_sequence_header(&data)); + } + + #[test] + fn test_video_header_all_video_types() { + let data = Bytes::from(vec![0x17, 0x00]); + assert!(is_video_sequence_header(&data)); + + for type_byte in 0x10..=0x16 { + let data = Bytes::from(vec![type_byte, 0x00]); + assert!( + !is_video_sequence_header(&data), + "Expected false for 0x{:02X} 0x00", + type_byte + ); + } + for type_byte in 0x18..=0x1F { + let data = Bytes::from(vec![type_byte, 0x00]); + assert!( + !is_video_sequence_header(&data), + "Expected false for 0x{:02X} 0x00", + type_byte + ); + } + } + + #[test] + fn test_video_header_non_video_types() { + for type_byte in 0x00..=0x0F { + let data = Bytes::from(vec![type_byte, 0x00]); + assert!( + !is_video_sequence_header(&data), + "Expected false for 0x{:02X} 0x00", + type_byte + ); + } + } + + #[test] + fn test_audio_header_all_audio_types() { + for type_byte in 0xA0..=0xAF { + let data = Bytes::from(vec![type_byte, 0x00]); + assert!( + is_audio_sequence_header(&data), + "Expected true for 0x{:02X} 0x00", + type_byte + ); + } + } + + #[test] + fn test_audio_header_non_audio_types() { + let non_audio = [0x00, 0x17, 0x27, 0x50, 0x80, 0x9F]; + for type_byte in non_audio { + let data = Bytes::from(vec![type_byte, 0x00]); + assert!( + !is_audio_sequence_header(&data), + "Expected false for 0x{:02X} 0x00", + type_byte + ); + } + } + + #[test] + fn test_video_header_long_payload() { + let mut data = vec![0x17, 0x00, 0x00, 0x00, 0x00]; + data.extend_from_slice(&[0x01, 0x64, 0x00, 0x1E, 0xFF, 0xE1]); + let data = Bytes::from(data); + assert!(is_video_sequence_header(&data)); + } + + #[test] + fn test_audio_header_long_payload() { + let mut data = vec![0xAF, 0x00]; + data.extend_from_slice(&[0x12, 0x10, 0x56, 0xE5, 0x00]); + let data = Bytes::from(data); + assert!(is_audio_sequence_header(&data)); + } + + #[test] + fn test_both_headers_independent() { + let video = Bytes::from(vec![0x17, 0x00]); + let audio = Bytes::from(vec![0xAF, 0x00]); + assert!(is_video_sequence_header(&video)); + assert!(!is_audio_sequence_header(&video)); + assert!(!is_video_sequence_header(&audio)); + assert!(is_audio_sequence_header(&audio)); + } +} diff --git a/crates/reestream-core/src/client/push.rs b/crates/reestream-core/src/client/push.rs new file mode 100644 index 0000000..5e09124 --- /dev/null +++ b/crates/reestream-core/src/client/push.rs @@ -0,0 +1,466 @@ +// src/client/push.rs +use crate::DynStream; +use crate::client::perform_client_handshake; +use bytes::Bytes; +use rml_rtmp::sessions::{ + ClientSession, ClientSessionConfig, ClientSessionEvent, ClientSessionResult, + PublishRequestType, StreamMetadata, +}; +use rml_rtmp::time::RtmpTimestamp; +use std::collections::VecDeque; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio::sync::{RwLock, mpsc, watch}; +use tokio::task::JoinHandle; +use tokio_native_tls::{TlsConnector, native_tls}; +use tracing::{error, info, trace}; +use url::Url; + +pub const MAX_BUFFER_SIZE: usize = 256; + +pub struct ClientStateWrapper { + pub session: ClientSession, + pub prepublish_video_buffer: VecDeque<(Bytes, RtmpTimestamp)>, + pub prepublish_audio_buffer: VecDeque<(Bytes, RtmpTimestamp)>, + pub prepublish_metadata: Option, + pub video_sequence_header: Option, + pub audio_sequence_header: Option, +} + +impl ClientStateWrapper { + pub fn buffer_video(&mut self, data: Bytes, timestamp: RtmpTimestamp) { + if self.prepublish_video_buffer.len() >= MAX_BUFFER_SIZE { + self.prepublish_video_buffer.pop_front(); + } + self.prepublish_video_buffer.push_back((data, timestamp)); + } + #[allow(dead_code)] + pub fn buffer_audio(&mut self, data: Bytes, timestamp: RtmpTimestamp) { + if self.prepublish_audio_buffer.len() >= MAX_BUFFER_SIZE { + self.prepublish_audio_buffer.pop_front(); + } + self.prepublish_audio_buffer.push_back((data, timestamp)); + } + + pub fn update_video_header(&mut self, data: Bytes) { + self.video_sequence_header = Some(data); + } + + pub fn update_audio_header(&mut self, data: Bytes) { + self.audio_sequence_header = Some(data); + } +} + +pub struct PushClient { + pub tx_feed: mpsc::Sender, + pub client_state: Arc>, + pub publish_ready_rx: watch::Receiver, + pub url: Url, + pub stream_key: String, + _tasks: Vec>, +} + +impl Drop for PushClient { + fn drop(&mut self) { + for task in &self._tasks { + task.abort(); + } + info!("PushClient dropped, background tasks aborted."); + } +} + +impl PushClient { + fn send_packet(tx: &mpsc::Sender, result: ClientSessionResult) { + if let ClientSessionResult::OutboundResponse(packet) = result { + let _ = tx.try_send(Bytes::from(packet.bytes)); + } + } + + pub async fn connect_and_publish( + url: &Url, + stream_key: String, + cached_video_header: Option, + cached_audio_header: Option, + cached_metadata: Option, + ) -> Result> { + let host = url.host_str().ok_or("Invalid host")?.to_string(); + let port = url.port_or_known_default().unwrap_or(1935); + let addr = format!("{host}:{port}"); + + let tcp_stream = TcpStream::connect(&addr).await?; + let _ = tcp_stream.set_nodelay(true); + + let mut stream: DynStream = if url.scheme() == "rtmps" { + let native = native_tls::TlsConnector::builder() + .danger_accept_invalid_certs(true) + .build()?; + let connector = TlsConnector::from(native); + Box::new(connector.connect(&host, tcp_stream).await?) + } else { + Box::new(tcp_stream) + }; + + perform_client_handshake(&mut stream).await?; + + let mut client_cfg = ClientSessionConfig::new(); + let app_segment = url + .path() + .trim_start_matches('/') + .split('/') + .next() + .unwrap_or("") + .to_string(); + + client_cfg.tc_url = Some(format!("rtmp://{host}:{port}/{app_segment}")); + + let (mut session, initial_results) = ClientSession::new(client_cfg)?; + let (tx, mut rx) = mpsc::channel::(256); + let (kill_tx, mut kill_rx) = mpsc::channel::<()>(1); + + let (mut rd, mut wr) = tokio::io::split(stream); + + // Writer Task + let writer_handle = tokio::spawn(async move { + loop { + tokio::select! { + _ = kill_rx.recv() => { + break; + } + msg = rx.recv() => { + match msg { + Some(bytes) => { + if wr.write_all(&bytes).await.is_err() { + break; + } + } + None => break, + } + } + } + } + }); + + for res in initial_results { + Self::send_packet(&tx, res); + } + + let res = session.request_connection(app_segment)?; + Self::send_packet(&tx, res); + + let client_state = Arc::new(RwLock::new(ClientStateWrapper { + session, + prepublish_video_buffer: VecDeque::new(), + prepublish_audio_buffer: VecDeque::new(), + prepublish_metadata: cached_metadata, + video_sequence_header: cached_video_header, + audio_sequence_header: cached_audio_header, + })); + + let (ready_tx, ready_rx) = watch::channel(false); + let state_clone = client_state.clone(); + let tx_clone = tx.clone(); + let stream_key_clone = stream_key.clone(); + + // Reader Task + let reader_handle = tokio::spawn(async move { + let mut buf = [0u8; 8192]; + loop { + let n = match rd.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => n, + }; + + let mut state = state_clone.write().await; + let input_res = + catch_unwind(AssertUnwindSafe(|| state.session.handle_input(&buf[..n]))); + + let results = match input_res { + Ok(Ok(res)) => res, + _ => break, + }; + + for res in results { + match res { + ClientSessionResult::OutboundResponse(packet) => { + let _ = tx_clone.try_send(Bytes::from(packet.bytes)); + } + ClientSessionResult::RaisedEvent(ev) => match ev { + ClientSessionEvent::ConnectionRequestAccepted => { + if let Ok(res) = state.session.request_publishing( + stream_key_clone.clone(), + PublishRequestType::Live, + ) { + Self::send_packet(&tx_clone, res); + } + } + // FIXED: Removed redundant { .. } + ClientSessionEvent::PublishRequestAccepted => { + info!("Publish succeeded for remote RTMP"); + let _ = ready_tx.send(true); + Self::drain_buffers(&mut state, &tx_clone); + } + ClientSessionEvent::UnhandleableOnStatusCode { code } => { + info!("RTMP Status received: {}", code); + if code.contains("BadName") + || code.contains("error") + || code.contains("Failed") + { + error!("Stopping stream due to RTMP status: {}", code); + let _ = kill_tx.send(()).await; + return; + } + } + ClientSessionEvent::ConnectionRequestRejected { description } => { + error!("RTMP Connection Rejected: {}", description); + let _ = kill_tx.send(()).await; + return; + } + _ => trace!("Client Event: {:?}", ev), + }, + ClientSessionResult::UnhandleableMessageReceived(_) => {} + } + } + } + let _ = ready_tx.send(false); + let _ = kill_tx.send(()).await; + }); + + Ok(Self { + tx_feed: tx, + client_state, + publish_ready_rx: ready_rx, + url: url.clone(), + stream_key, + _tasks: vec![writer_handle, reader_handle], + }) + } + + pub fn drain_buffers(state: &mut ClientStateWrapper, tx: &mpsc::Sender) { + // FIXED: Collapsed nested if let + if let Some(meta) = &state.prepublish_metadata + && let Ok(res) = state.session.publish_metadata(meta) + { + Self::send_packet(tx, res); + } + + // FIXED: Collapsed nested if let + if let Some(header) = &state.video_sequence_header + && let Ok(res) = + state + .session + .publish_video_data(header.clone(), RtmpTimestamp::new(0), true) + { + Self::send_packet(tx, res); + } + + // FIXED: Collapsed nested if let + if let Some(header) = &state.audio_sequence_header + && let Ok(res) = + state + .session + .publish_audio_data(header.clone(), RtmpTimestamp::new(0), true) + { + Self::send_packet(tx, res); + } + + while let Some((data, ts)) = state.prepublish_video_buffer.pop_front() { + if let Ok(res) = state.session.publish_video_data(data, ts, true) { + Self::send_packet(tx, res); + } + } + while let Some((data, ts)) = state.prepublish_audio_buffer.pop_front() { + if let Ok(res) = state.session.publish_audio_data(data, ts, true) { + Self::send_packet(tx, res); + } + } + } + + pub async fn shutdown(&self) { + let mut state = self.client_state.write().await; + + info!( + "Sending graceful shutdown (FCUnpublish/deleteStream) to {}", + self.url + ); + + match state.session.stop_publishing() { + Ok(results) => { + for res in results { + Self::send_packet(&self.tx_feed, res); + } + } + Err(e) => error!("Error generating stop_publishing packets: {}", e), + } + + tokio::time::sleep(Duration::from_millis(500)).await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_timestamp(val: u32) -> RtmpTimestamp { + RtmpTimestamp::new(val) + } + + #[test] + fn test_max_buffer_size_constant() { + assert_eq!(MAX_BUFFER_SIZE, 256); + } + + #[test] + fn test_buffer_video_within_limit() { + const { assert!(MAX_BUFFER_SIZE > 0) } + const { assert!(MAX_BUFFER_SIZE <= 1024) } + } + + #[test] + fn test_rtmp_timestamp_creation() { + let ts = make_timestamp(12345); + assert_eq!(ts.value, 12345); + } + + #[test] + fn test_rtmp_timestamp_zero() { + let ts = make_timestamp(0); + assert_eq!(ts.value, 0); + } + + #[test] + fn test_send_packet_ignores_non_outbound() { + let (tx, _rx) = mpsc::channel::(1); + assert!(!tx.is_closed()); + } + + #[test] + fn test_push_client_url_stored() { + let url: Url = "rtmp://live.twitch.tv/app".parse().unwrap(); + assert_eq!(url.host_str(), Some("live.twitch.tv")); + assert_eq!(url.scheme(), "rtmp"); + } + + #[test] + fn test_push_client_rtmps_url() { + let url: Url = "rtmps://live-api-s.facebook.com:443/rtmp/".parse().unwrap(); + assert_eq!(url.scheme(), "rtmps"); + assert_eq!(url.port(), Some(443)); + } + + #[test] + fn test_url_parsing_port_defaults() { + // url crate doesn't know rtmp/rtmps as well-known schemes + let rtmp: Url = "rtmp://example.com/app".parse().unwrap(); + assert_eq!(rtmp.port_or_known_default(), None); + + let rtmps: Url = "rtmps://example.com/app".parse().unwrap(); + assert_eq!(rtmps.port_or_known_default(), None); + + // But explicit port works + let rtmp_port: Url = "rtmp://example.com:1935/app".parse().unwrap(); + assert_eq!(rtmp_port.port(), Some(1935)); + } + + #[test] + fn test_url_parsing_path_extraction() { + let url: Url = "rtmp://live.twitch.tv/app/stream".parse().unwrap(); + let app_segment = url + .path() + .trim_start_matches('/') + .split('/') + .next() + .unwrap_or("") + .to_string(); + assert_eq!(app_segment, "app"); + } + + #[test] + fn test_url_parsing_root_path() { + let url: Url = "rtmp://live.twitch.tv/".parse().unwrap(); + let app_segment = url + .path() + .trim_start_matches('/') + .split('/') + .next() + .unwrap_or("") + .to_string(); + assert_eq!(app_segment, ""); + } + + #[test] + fn test_url_parsing_no_path() { + let url: Url = "rtmp://live.twitch.tv".parse().unwrap(); + let app_segment = url + .path() + .trim_start_matches('/') + .split('/') + .next() + .unwrap_or("") + .to_string(); + assert_eq!(app_segment, ""); + } + + #[test] + fn test_url_parsing_complex_path() { + let url: Url = "rtmp://server.com/live/stream/key123".parse().unwrap(); + let app_segment = url + .path() + .trim_start_matches('/') + .split('/') + .next() + .unwrap_or("") + .to_string(); + assert_eq!(app_segment, "live"); + } + + #[test] + fn test_url_host_extraction_rtmps() { + let url: Url = "rtmps://edge-upload.instagram.com:443/rtmp/" + .parse() + .unwrap(); + assert_eq!(url.host_str(), Some("edge-upload.instagram.com")); + assert_eq!(url.port(), Some(443)); + assert_eq!(url.scheme(), "rtmps"); + } + + #[test] + fn test_rtmp_timestamp_max() { + let ts = make_timestamp(u32::MAX); + assert_eq!(ts.value, u32::MAX); + } + + #[test] + fn test_rtmp_timestamp_arithmetic() { + let ts1 = make_timestamp(100); + let ts2 = make_timestamp(200); + assert_eq!(ts1.value + 100, ts2.value); + } + + #[test] + fn test_buffer_size_is_power_of_two() { + assert!(MAX_BUFFER_SIZE.is_power_of_two()); + } + + #[test] + fn test_channel_send_receive() { + let (tx, mut rx) = mpsc::channel::(10); + let data = Bytes::from_static(&[0x17, 0x00, 0x00, 0x01]); + tx.try_send(data.clone()).unwrap(); + let received = rx.try_recv().unwrap(); + assert_eq!(data, received); + } + + #[test] + fn test_channel_capacity() { + let (tx, _rx) = mpsc::channel::(256); + // Fill to capacity + for i in 0..256 { + assert!(tx.try_send(Bytes::from(vec![i as u8])).is_ok()); + } + // Next should fail (full) + assert!(tx.try_send(Bytes::from(vec![0xFF])).is_err()); + } +} diff --git a/crates/reestream-core/src/config.rs b/crates/reestream-core/src/config.rs new file mode 100644 index 0000000..67f10e5 --- /dev/null +++ b/crates/reestream-core/src/config.rs @@ -0,0 +1,493 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::Path; +use std::str::FromStr; +use url::Url; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Config { + pub rtmp_addr: String, + pub rtmp_port: u16, + pub stream_key: String, + pub platform: Option>, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct Platform { + pub url: Url, + pub key: String, + pub orientation: Orientation, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum Orientation { + #[default] + Horizontal, + Vertical, +} + +pub struct ConfigBuilder { + rtmp_addr: String, + rtmp_port: u16, + stream_key: String, + platforms: Vec, +} + +impl ConfigBuilder { + pub fn new() -> Self { + Self { + rtmp_addr: "0.0.0.0".into(), + rtmp_port: 1935, + stream_key: String::new(), + platforms: Vec::new(), + } + } + + pub fn addr(mut self, addr: impl Into) -> Self { + self.rtmp_addr = addr.into(); + self + } + + pub fn port(mut self, port: u16) -> Self { + self.rtmp_port = port; + self + } + + pub fn stream_key(mut self, key: impl Into) -> Self { + self.stream_key = key.into(); + self + } + + pub fn add_platform( + mut self, + url: Url, + key: impl Into, + orientation: Orientation, + ) -> Self { + self.platforms.push(Platform { + url, + key: key.into(), + orientation, + }); + self + } + + pub fn build(self) -> Config { + Config { + rtmp_addr: self.rtmp_addr, + rtmp_port: self.rtmp_port, + stream_key: self.stream_key, + platform: if self.platforms.is_empty() { + None + } else { + Some(self.platforms) + }, + } + } + + pub fn validate(&self) -> Result<(), String> { + if self.stream_key.is_empty() { + return Err("stream_key cannot be empty".into()); + } + if self.rtmp_port == 0 { + return Err("rtmp_port cannot be 0".into()); + } + if self.rtmp_addr.is_empty() { + return Err("rtmp_addr cannot be empty".into()); + } + for (i, p) in self.platforms.iter().enumerate() { + if p.key.is_empty() { + return Err(format!("platform[{i}] key cannot be empty")); + } + if p.url.host().is_none() { + return Err(format!("platform[{i}] url has no host")); + } + } + Ok(()) + } +} + +impl Default for ConfigBuilder { + fn default() -> Self { + Self::new() + } +} + +impl Config { + pub fn builder() -> ConfigBuilder { + ConfigBuilder::new() + } + + pub fn validate(&self) -> Result<(), String> { + if self.stream_key.is_empty() { + return Err("stream_key cannot be empty".into()); + } + if self.rtmp_port == 0 { + return Err("rtmp_port cannot be 0".into()); + } + Ok(()) + } + + pub fn to_toml(&self) -> Result { + toml::to_string(self) + } +} + +impl FromStr for Config { + type Err = Box; + + fn from_str(s: &str) -> Result { + let config: Config = toml::from_str(s)?; + Ok(config) + } +} + +impl Config { + pub fn from_file>(path: P) -> Result> { + let contents = fs::read_to_string(path)?; + Self::from_str(&contents) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn test_parse_minimal_config() { + let toml = r#" + rtmp_addr = "0.0.0.0" + rtmp_port = 1945 + stream_key = "test-key" + "#; + let config = Config::from_str(toml).unwrap(); + assert_eq!(config.rtmp_addr, "0.0.0.0"); + assert_eq!(config.rtmp_port, 1945); + assert_eq!(config.stream_key, "test-key"); + assert!(config.platform.is_none()); + } + + #[test] + fn test_parse_config_with_platforms() { + let toml = r#" + rtmp_addr = "127.0.0.1" + rtmp_port = 1935 + stream_key = "my-key" + + [[platform]] + url = "rtmp://live.twitch.tv/app" + key = "twitch-key" + orientation = "horizontal" + + [[platform]] + url = "rtmps://live-api-s.facebook.com:443/rtmp/" + key = "fb-key" + orientation = "vertical" + "#; + let config = Config::from_str(toml).unwrap(); + let platforms = config.platform.unwrap(); + assert_eq!(platforms.len(), 2); + assert_eq!(platforms[0].key, "twitch-key"); + assert_eq!(platforms[0].orientation, Orientation::Horizontal); + assert_eq!(platforms[1].key, "fb-key"); + assert_eq!(platforms[1].orientation, Orientation::Vertical); + } + + #[test] + fn test_parse_config_with_rtmps_flag() { + let toml = r#" + rtmps = true + rtmp_addr = "0.0.0.0" + rtmp_port = 443 + stream_key = "key" + "#; + let config = Config::from_str(toml).unwrap(); + assert_eq!(config.rtmp_port, 443); + } + + #[test] + fn test_orientation_default() { + let toml = r#" + rtmp_addr = "0.0.0.0" + rtmp_port = 1945 + stream_key = "key" + + [[platform]] + url = "rtmp://live.twitch.tv/app" + key = "test" + orientation = "horizontal" + "#; + let config = Config::from_str(toml).unwrap(); + let platforms = config.platform.unwrap(); + assert_eq!(platforms[0].orientation, Orientation::Horizontal); + } + + #[test] + fn test_invalid_toml_fails() { + let toml = "not valid toml [[["; + let result = Config::from_str(toml); + assert!(result.is_err()); + } + + #[test] + fn test_missing_required_field_fails() { + let toml = r#" + rtmp_addr = "0.0.0.0" + "#; + let result = Config::from_str(toml); + assert!(result.is_err()); + } + + #[test] + fn test_from_file_success() { + let dir = std::env::temp_dir().join("reestream_test_config"); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("test.toml"); + let mut f = std::fs::File::create(&path).unwrap(); + writeln!( + f, + r#"rtmp_addr = "0.0.0.0" +rtmp_port = 1935 +stream_key = "file-key""# + ) + .unwrap(); + let config = Config::from_file(&path).unwrap(); + assert_eq!(config.stream_key, "file-key"); + assert_eq!(config.rtmp_port, 1935); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn test_from_file_not_found() { + let result = Config::from_file("/nonexistent/path/config.toml"); + assert!(result.is_err()); + } + + #[test] + fn test_empty_platforms_array() { + let toml = r#" + rtmp_addr = "0.0.0.0" + rtmp_port = 1935 + stream_key = "key" + platform = [] + "#; + let config = Config::from_str(toml).unwrap(); + let platforms = config.platform.unwrap(); + assert!(platforms.is_empty()); + } + + #[test] + fn test_port_boundary_min() { + let toml = r#" + rtmp_addr = "0.0.0.0" + rtmp_port = 1 + stream_key = "key" + "#; + let config = Config::from_str(toml).unwrap(); + assert_eq!(config.rtmp_port, 1); + } + + #[test] + fn test_port_boundary_max() { + let toml = r#" + rtmp_addr = "0.0.0.0" + rtmp_port = 65535 + stream_key = "key" + "#; + let config = Config::from_str(toml).unwrap(); + assert_eq!(config.rtmp_port, 65535); + } + + #[test] + fn test_orientation_vertical() { + let toml = r#" + rtmp_addr = "0.0.0.0" + rtmp_port = 1935 + stream_key = "key" + + [[platform]] + url = "rtmp://live.instagram.com/rtmp" + key = "ig-key" + orientation = "vertical" + "#; + let config = Config::from_str(toml).unwrap(); + let platforms = config.platform.unwrap(); + assert_eq!(platforms[0].orientation, Orientation::Vertical); + } + + #[test] + fn test_orientation_clone_copy() { + let o = Orientation::Horizontal; + let o2 = o; + assert_eq!(o, o2); + } + + #[test] + fn test_platform_url_parsing() { + let toml = r#" + rtmp_addr = "0.0.0.0" + rtmp_port = 1935 + stream_key = "key" + + [[platform]] + url = "rtmps://custom.server.com:9999/live/stream" + key = "custom-key" + orientation = "horizontal" + "#; + let config = Config::from_str(toml).unwrap(); + let p = &config.platform.unwrap()[0]; + assert_eq!(p.url.scheme(), "rtmps"); + assert_eq!(p.url.host_str(), Some("custom.server.com")); + assert_eq!(p.url.port(), Some(9999)); + } + + #[test] + fn test_config_clone() { + let toml = r#" + rtmp_addr = "0.0.0.0" + rtmp_port = 1935 + stream_key = "key" + "#; + let config = Config::from_str(toml).unwrap(); + let cloned = config.clone(); + assert_eq!(config.rtmp_addr, cloned.rtmp_addr); + assert_eq!(config.rtmp_port, cloned.rtmp_port); + } + + #[test] + fn test_config_debug() { + let toml = r#" + rtmp_addr = "0.0.0.0" + rtmp_port = 1935 + stream_key = "key" + "#; + let config = Config::from_str(toml).unwrap(); + let debug = format!("{:?}", config); + assert!(debug.contains("Config")); + assert!(debug.contains("0.0.0.0")); + } + + #[test] + fn test_multiple_platforms_same_url() { + let toml = r#" + rtmp_addr = "0.0.0.0" + rtmp_port = 1935 + stream_key = "key" + + [[platform]] + url = "rtmp://live.twitch.tv/app" + key = "key1" + orientation = "horizontal" + + [[platform]] + url = "rtmp://live.twitch.tv/app" + key = "key2" + orientation = "horizontal" + "#; + let config = Config::from_str(toml).unwrap(); + let platforms = config.platform.unwrap(); + assert_eq!(platforms.len(), 2); + assert_eq!(platforms[0].key, "key1"); + assert_eq!(platforms[1].key, "key2"); + } + + #[test] + fn test_config_builder_defaults() { + let config = ConfigBuilder::new().stream_key("test-key").build(); + assert_eq!(config.rtmp_addr, "0.0.0.0"); + assert_eq!(config.rtmp_port, 1935); + assert_eq!(config.stream_key, "test-key"); + assert!(config.platform.is_none()); + } + + #[test] + fn test_config_builder_full() { + let config = ConfigBuilder::new() + .addr("127.0.0.1") + .port(9999) + .stream_key("my-key") + .add_platform( + Url::parse("rtmp://twitch.tv/app").unwrap(), + "twitch-key", + Orientation::Horizontal, + ) + .add_platform( + Url::parse("rtmp://youtube.com/live2").unwrap(), + "yt-key", + Orientation::Vertical, + ) + .build(); + assert_eq!(config.rtmp_addr, "127.0.0.1"); + assert_eq!(config.rtmp_port, 9999); + let platforms = config.platform.unwrap(); + assert_eq!(platforms.len(), 2); + } + + #[test] + fn test_config_builder_validate_ok() { + let builder = ConfigBuilder::new().stream_key("key"); + assert!(builder.validate().is_ok()); + } + + #[test] + fn test_config_builder_validate_empty_key() { + let builder = ConfigBuilder::new(); + assert!(builder.validate().is_err()); + } + + #[test] + fn test_config_builder_validate_zero_port() { + let builder = ConfigBuilder::new().port(0).stream_key("key"); + assert!(builder.validate().is_err()); + } + + #[test] + fn test_config_builder_validate_empty_platform_key() { + let builder = ConfigBuilder::new().stream_key("key").add_platform( + Url::parse("rtmp://twitch.tv/app").unwrap(), + "", + Orientation::Horizontal, + ); + assert!(builder.validate().is_err()); + } + + #[test] + fn test_config_validate() { + let config = ConfigBuilder::new().stream_key("key").build(); + assert!(config.validate().is_ok()); + + let bad = ConfigBuilder::new().build(); + assert!(bad.validate().is_err()); + } + + #[test] + fn test_config_to_toml() { + let config = ConfigBuilder::new() + .addr("0.0.0.0") + .port(1935) + .stream_key("key") + .build(); + let toml = config.to_toml().unwrap(); + assert!(toml.contains("rtmp_addr")); + assert!(toml.contains("stream_key")); + } + + #[test] + fn test_config_builder_default_trait() { + let builder = ConfigBuilder::default(); + assert_eq!(builder.rtmp_addr, "0.0.0.0"); + } + + #[test] + fn test_config_builder_chaining() { + let config = Config::builder() + .addr("10.0.0.1") + .port(8080) + .stream_key("chain-key") + .build(); + assert_eq!(config.rtmp_addr, "10.0.0.1"); + assert_eq!(config.rtmp_port, 8080); + } +} diff --git a/crates/reestream-core/src/error.rs b/crates/reestream-core/src/error.rs new file mode 100644 index 0000000..e560c74 --- /dev/null +++ b/crates/reestream-core/src/error.rs @@ -0,0 +1,161 @@ +use std::fmt; + +#[derive(Debug)] +#[allow(dead_code)] +pub enum RelayError { + Io(std::io::Error), + Tls(tokio_native_tls::native_tls::Error), + Handshake(String), + Session(String), + Connection(String), + Timeout(String), + InvalidConfig(String), + PublishRejected(String), +} + +impl fmt::Display for RelayError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(e) => write!(f, "IO error: {e}"), + Self::Handshake(msg) => write!(f, "Handshake error: {msg}"), + Self::Session(msg) => write!(f, "Session error: {msg}"), + Self::Connection(msg) => write!(f, "Connection error: {msg}"), + Self::Timeout(msg) => write!(f, "Timeout: {msg}"), + Self::InvalidConfig(msg) => write!(f, "Invalid config: {msg}"), + Self::PublishRejected(msg) => write!(f, "Publish rejected: {msg}"), + Self::Tls(error) => write!(f, "Tls on rtmps: {error}"), + } + } +} + +impl std::error::Error for RelayError {} + +impl From for RelayError { + fn from(e: std::io::Error) -> Self { + Self::Io(e) + } +} + +impl From for RelayError { + fn from(e: tokio_native_tls::native_tls::Error) -> Self { + Self::Tls(e) + } +} + +#[allow(dead_code)] +pub type Result = std::result::Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_display_io() { + let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused"); + let err = RelayError::Io(io_err); + assert!(err.to_string().contains("IO error")); + assert!(err.to_string().contains("refused")); + } + + #[test] + fn test_display_handshake() { + let err = RelayError::Handshake("bad handshake".into()); + assert_eq!(err.to_string(), "Handshake error: bad handshake"); + } + + #[test] + fn test_display_session() { + let err = RelayError::Session("session expired".into()); + assert_eq!(err.to_string(), "Session error: session expired"); + } + + #[test] + fn test_display_connection() { + let err = RelayError::Connection("timeout".into()); + assert_eq!(err.to_string(), "Connection error: timeout"); + } + + #[test] + fn test_display_timeout() { + let err = RelayError::Timeout("30s".into()); + assert_eq!(err.to_string(), "Timeout: 30s"); + } + + #[test] + fn test_display_invalid_config() { + let err = RelayError::InvalidConfig("missing field".into()); + assert_eq!(err.to_string(), "Invalid config: missing field"); + } + + #[test] + fn test_display_publish_rejected() { + let err = RelayError::PublishRejected("bad key".into()); + assert_eq!(err.to_string(), "Publish rejected: bad key"); + } + + #[test] + fn test_from_io_error() { + let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing"); + let err: RelayError = io_err.into(); + assert!(matches!(err, RelayError::Io(_))); + } + + #[test] + fn test_error_trait_implemented() { + let err: Box = Box::new(RelayError::Handshake("test".into())); + assert_eq!(err.to_string(), "Handshake error: test"); + } + + #[test] + fn test_relay_error_debug() { + let err = RelayError::Connection("debug test".into()); + let debug = format!("{:?}", err); + assert!(debug.contains("Connection")); + assert!(debug.contains("debug test")); + } + + #[test] + fn test_relay_error_all_variants_display() { + let variants = vec![ + RelayError::Handshake("h".into()), + RelayError::Session("s".into()), + RelayError::Connection("c".into()), + RelayError::Timeout("t".into()), + RelayError::InvalidConfig("i".into()), + RelayError::PublishRejected("p".into()), + ]; + for err in variants { + let msg = err.to_string(); + assert!(!msg.is_empty(), "Display should not be empty for {:?}", err); + } + } + + #[test] + fn test_from_io_error_various_kinds() { + let kinds = vec![ + std::io::ErrorKind::NotFound, + std::io::ErrorKind::PermissionDenied, + std::io::ErrorKind::ConnectionRefused, + std::io::ErrorKind::ConnectionReset, + std::io::ErrorKind::TimedOut, + std::io::ErrorKind::BrokenPipe, + std::io::ErrorKind::AddrInUse, + std::io::ErrorKind::AddrNotAvailable, + ]; + for kind in kinds { + let io_err = std::io::Error::new(kind, "test"); + let err: RelayError = io_err.into(); + assert!(matches!(err, RelayError::Io(_))); + assert!(err.to_string().contains("IO error")); + } + } + + #[test] + fn test_result_type_alias() { + let ok: crate::error::Result = Ok(42); + assert!(ok.is_ok()); + + let err: crate::error::Result = Err(RelayError::Timeout("test".into())); + assert!(err.is_err()); + } +} diff --git a/crates/reestream-core/src/hardening.rs b/crates/reestream-core/src/hardening.rs new file mode 100644 index 0000000..3f821cd --- /dev/null +++ b/crates/reestream-core/src/hardening.rs @@ -0,0 +1,397 @@ +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::{RwLock, watch}; +use tracing::info; + +pub struct GracefulShutdown { + shutdown_tx: watch::Sender, + shutdown_rx: watch::Receiver, +} + +impl GracefulShutdown { + pub fn new() -> Self { + let (shutdown_tx, shutdown_rx) = watch::channel(false); + Self { + shutdown_tx, + shutdown_rx, + } + } + + pub fn shutdown_signal(&self) -> watch::Receiver { + self.shutdown_rx.clone() + } + + pub fn is_shutdown(&self) -> bool { + *self.shutdown_rx.borrow() + } + + pub async fn trigger_shutdown(&self) { + info!("Triggering graceful shutdown..."); + let _ = self.shutdown_tx.send(true); + } + + pub async fn wait_for_shutdown(&self) { + let mut rx = self.shutdown_rx.clone(); + while !*rx.borrow_and_update() { + if rx.changed().await.is_err() { + break; + } + } + } + + pub async fn drain_timeout(&self, timeout: Duration) -> bool { + info!("Draining in-flight operations (timeout: {:?})...", timeout); + tokio::time::timeout(timeout, self.wait_for_shutdown()) + .await + .is_ok() + } +} + +impl Default for GracefulShutdown { + fn default() -> Self { + Self::new() + } +} + +pub async fn setup_signal_handlers(shutdown: Arc) { + let shutdown_clone = shutdown.clone(); + + tokio::spawn(async move { + let mut sigterm = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).unwrap(); + let mut sigint = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()).unwrap(); + let mut sighup = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup()).unwrap(); + + tokio::select! { + _ = sigterm.recv() => { + info!("Received SIGTERM, initiating graceful shutdown"); + shutdown_clone.trigger_shutdown().await; + } + _ = sigint.recv() => { + info!("Received SIGINT, initiating graceful shutdown"); + shutdown_clone.trigger_shutdown().await; + } + _ = sighup.recv() => { + info!("Received SIGHUP, config reload requested"); + } + } + }); +} + +pub struct ConfigWatcher { + path: PathBuf, + last_modified: Option, +} + +impl ConfigWatcher { + pub fn new(path: PathBuf) -> Self { + Self { + path, + last_modified: None, + } + } + + pub fn check_changed(&mut self) -> bool { + if let Ok(metadata) = std::fs::metadata(&self.path) + && let Ok(modified) = metadata.modified() + { + let changed = self.last_modified.is_none_or(|last| modified > last); + self.last_modified = Some(modified); + return changed; + } + false + } + + pub async fn watch_loop(path: PathBuf, interval: Duration, on_change: F) + where + F: Fn() + Send + 'static, + { + let mut watcher = ConfigWatcher::new(path); + loop { + tokio::time::sleep(interval).await; + if watcher.check_changed() { + info!("Config file changed, triggering reload"); + on_change(); + } + } + } +} + +pub struct RateLimiter { + max_connections_per_second: u32, + current_count: Arc>, + window_start: Arc>, +} + +impl RateLimiter { + pub fn new(max_connections_per_second: u32) -> Self { + Self { + max_connections_per_second, + current_count: Arc::new(RwLock::new(0)), + window_start: Arc::new(RwLock::new(std::time::Instant::now())), + } + } + + pub async fn try_acquire(&self) -> bool { + let mut count = self.current_count.write().await; + let mut window = self.window_start.write().await; + + let now = std::time::Instant::now(); + if now.duration_since(*window) >= Duration::from_secs(1) { + *count = 0; + *window = now; + } + + if *count < self.max_connections_per_second { + *count += 1; + true + } else { + false + } + } +} + +pub struct ConnectionPool { + max_connections: usize, + current: Arc>, +} + +impl ConnectionPool { + pub fn new(max_connections: usize) -> Self { + Self { + max_connections, + current: Arc::new(RwLock::new(0)), + } + } + + pub async fn try_acquire(&self) -> Option { + let mut current = self.current.write().await; + if *current < self.max_connections { + *current += 1; + Some(ConnectionGuard { + current: self.current.clone(), + }) + } else { + None + } + } + + pub async fn active_connections(&self) -> usize { + *self.current.read().await + } + + pub fn max_connections(&self) -> usize { + self.max_connections + } +} + +pub struct ConnectionGuard { + current: Arc>, +} + +impl Drop for ConnectionGuard { + fn drop(&mut self) { + let current = self.current.clone(); + tokio::spawn(async move { + let mut c = current.write().await; + if *c > 0 { + *c -= 1; + } + }); + } +} + +pub struct BandwidthLimiter { + max_bytes_per_second: u64, + bytes_used: Arc>, + window_start: Arc>, +} + +impl BandwidthLimiter { + pub fn new(max_bytes_per_second: u64) -> Self { + Self { + max_bytes_per_second, + bytes_used: Arc::new(RwLock::new(0)), + window_start: Arc::new(RwLock::new(std::time::Instant::now())), + } + } + + pub async fn try_send(&self, bytes: u64) -> bool { + let mut used = self.bytes_used.write().await; + let mut window = self.window_start.write().await; + + let now = std::time::Instant::now(); + if now.duration_since(*window) >= Duration::from_secs(1) { + *used = 0; + *window = now; + } + + if *used + bytes <= self.max_bytes_per_second { + *used += bytes; + true + } else { + false + } + } + + pub async fn bytes_used(&self) -> u64 { + *self.bytes_used.read().await + } +} + +pub struct ViewerLimit { + max_viewers: u32, + current: Arc>, +} + +impl ViewerLimit { + pub fn new(max_viewers: u32) -> Self { + Self { + max_viewers, + current: Arc::new(RwLock::new(0)), + } + } + + pub async fn try_add_viewer(&self) -> bool { + let mut current = self.current.write().await; + if *current < self.max_viewers { + *current += 1; + true + } else { + false + } + } + + pub async fn remove_viewer(&self) { + let mut current = self.current.write().await; + if *current > 0 { + *current -= 1; + } + } + + pub async fn current_viewers(&self) -> u32 { + *self.current.read().await + } + + pub fn max_viewers(&self) -> u32 { + self.max_viewers + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_graceful_shutdown_default() { + let shutdown = GracefulShutdown::new(); + assert!(!shutdown.is_shutdown()); + } + + #[tokio::test] + async fn test_graceful_shutdown_trigger() { + let shutdown = GracefulShutdown::new(); + shutdown.trigger_shutdown().await; + assert!(shutdown.is_shutdown()); + } + + #[tokio::test] + async fn test_graceful_shutdown_wait() { + let shutdown = Arc::new(GracefulShutdown::new()); + let s = shutdown.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(50)).await; + s.trigger_shutdown().await; + }); + shutdown.wait_for_shutdown().await; + assert!(shutdown.is_shutdown()); + } + + #[tokio::test] + async fn test_graceful_shutdown_drain_timeout() { + let shutdown = GracefulShutdown::new(); + let result = shutdown.drain_timeout(Duration::from_millis(10)).await; + assert!(!result); + } + + #[tokio::test] + async fn test_rate_limiter_allows() { + let limiter = RateLimiter::new(10); + assert!(limiter.try_acquire().await); + } + + #[tokio::test] + async fn test_rate_limiter_blocks() { + let limiter = RateLimiter::new(2); + assert!(limiter.try_acquire().await); + assert!(limiter.try_acquire().await); + assert!(!limiter.try_acquire().await); + } + + #[tokio::test] + async fn test_connection_pool_basic() { + let pool = ConnectionPool::new(5); + let guard = pool.try_acquire().await; + assert!(guard.is_some()); + assert_eq!(pool.active_connections().await, 1); + } + + #[tokio::test] + async fn test_connection_pool_exhausted() { + let pool = ConnectionPool::new(1); + let _guard = pool.try_acquire().await.unwrap(); + assert!(pool.try_acquire().await.is_none()); + } + + #[tokio::test] + async fn test_connection_pool_guard_drop() { + let pool = ConnectionPool::new(5); + { + let _guard = pool.try_acquire().await.unwrap(); + assert_eq!(pool.active_connections().await, 1); + } + tokio::time::sleep(Duration::from_millis(10)).await; + assert_eq!(pool.active_connections().await, 0); + } + + #[tokio::test] + async fn test_bandwidth_limiter_allows() { + let limiter = BandwidthLimiter::new(1024); + assert!(limiter.try_send(512).await); + assert!(limiter.try_send(512).await); + } + + #[tokio::test] + async fn test_bandwidth_limiter_blocks() { + let limiter = BandwidthLimiter::new(100); + assert!(limiter.try_send(100).await); + assert!(!limiter.try_send(1).await); + } + + #[tokio::test] + async fn test_viewer_limit() { + let limit = ViewerLimit::new(2); + assert!(limit.try_add_viewer().await); + assert!(limit.try_add_viewer().await); + assert!(!limit.try_add_viewer().await); + limit.remove_viewer().await; + assert!(limit.try_add_viewer().await); + } + + #[test] + fn test_config_watcher_check_changed() { + let dir = std::env::temp_dir().join("reestream_test_watcher"); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("test.toml"); + std::fs::write(&path, "test").unwrap(); + let mut watcher = ConfigWatcher::new(path.clone()); + assert!(watcher.check_changed()); + assert!(!watcher.check_changed()); + std::fs::write(&path, "test2").unwrap(); + assert!(watcher.check_changed()); + let _ = std::fs::remove_file(&path); + } +} diff --git a/crates/reestream-core/src/lib.rs b/crates/reestream-core/src/lib.rs new file mode 100644 index 0000000..3d8c9c5 --- /dev/null +++ b/crates/reestream-core/src/lib.rs @@ -0,0 +1,17 @@ +pub mod client; +pub mod config; +pub mod error; +pub mod hardening; +pub mod pipeline; +pub mod pipeline_impl; +pub mod provider; +pub mod server; +pub mod setup; + +use tokio::io::{AsyncRead, AsyncWrite}; + +pub trait AsyncReadWrite: AsyncRead + AsyncWrite + Send + Unpin {} + +impl AsyncReadWrite for T {} + +pub type DynStream = Box; diff --git a/crates/reestream-core/src/pipeline.rs b/crates/reestream-core/src/pipeline.rs new file mode 100644 index 0000000..bf23ced --- /dev/null +++ b/crates/reestream-core/src/pipeline.rs @@ -0,0 +1,178 @@ +use async_trait::async_trait; +use bytes::Bytes; +use serde::{Deserialize, Serialize}; +use std::fmt; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub enum PipelineStatus { + #[default] + Idle, + Running, + Error(String), + Stopped, +} + +impl fmt::Display for PipelineStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Idle => write!(f, "idle"), + Self::Running => write!(f, "running"), + Self::Error(msg) => write!(f, "error: {msg}"), + Self::Stopped => write!(f, "stopped"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PipelineStats { + pub bytes_in: u64, + pub bytes_out: u64, + pub uptime_secs: u64, + pub viewers: u32, + pub bitrate_kbps: u32, + pub fps: f64, +} + +impl Default for PipelineStats { + fn default() -> Self { + Self { + bytes_in: 0, + bytes_out: 0, + uptime_secs: 0, + viewers: 0, + bitrate_kbps: 0, + fps: 0.0, + } + } +} + +#[derive(Debug, Clone)] +pub enum PipelineEvent { + Started, + Stopped, + Error(String), + ViewerConnected, + ViewerDisconnected, + DataReceived(Bytes), +} + +#[async_trait] +pub trait StreamPipeline: Send + Sync { + fn name(&self) -> &str; + fn status(&self) -> PipelineStatus; + fn stats(&self) -> PipelineStats; + + async fn start(&mut self) -> Result<(), Box>; + async fn stop(&mut self) -> Result<(), Box>; + async fn restart(&mut self) -> Result<(), Box> { + self.stop().await?; + self.start().await + } +} + +#[async_trait] +pub trait PipelineManager: Send + Sync { + async fn create_pipeline( + &self, + name: String, + input: String, + outputs: Vec, + ) -> Result>; + + async fn remove_pipeline( + &self, + id: &str, + ) -> Result<(), Box>; + + async fn list_pipelines(&self) -> Vec; + + async fn get_pipeline(&self, id: &str) -> Option>; +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PipelineInfo { + pub id: String, + pub name: String, + pub input: String, + pub outputs: Vec, + pub status: PipelineStatus, + pub stats: PipelineStats, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pipeline_status_display() { + assert_eq!(PipelineStatus::Idle.to_string(), "idle"); + assert_eq!(PipelineStatus::Running.to_string(), "running"); + assert_eq!( + PipelineStatus::Error("test".into()).to_string(), + "error: test" + ); + assert_eq!(PipelineStatus::Stopped.to_string(), "stopped"); + } + + #[test] + fn test_pipeline_status_default() { + assert_eq!(PipelineStatus::default(), PipelineStatus::Idle); + } + + #[test] + fn test_pipeline_status_serialize() { + let status = PipelineStatus::Running; + let json = serde_json::to_string(&status).unwrap(); + assert!(json.contains("Running")); + } + + #[test] + fn test_pipeline_stats_default() { + let stats = PipelineStats::default(); + assert_eq!(stats.bytes_in, 0); + assert_eq!(stats.bytes_out, 0); + assert_eq!(stats.viewers, 0); + } + + #[test] + fn test_pipeline_stats_serialize() { + let stats = PipelineStats { + bytes_in: 1024, + bytes_out: 2048, + uptime_secs: 60, + viewers: 5, + bitrate_kbps: 2500, + fps: 30.0, + }; + let json = serde_json::to_string(&stats).unwrap(); + assert!(json.contains("1024")); + assert!(json.contains("2500")); + } + + #[test] + fn test_pipeline_info_serialize() { + let info = PipelineInfo { + id: "test-id".into(), + name: "test".into(), + input: "rtmp://input".into(), + outputs: vec!["rtmp://output".into()], + status: PipelineStatus::Running, + stats: PipelineStats::default(), + }; + let json = serde_json::to_string(&info).unwrap(); + assert!(json.contains("test-id")); + } + + #[test] + fn test_pipeline_event_variants() { + let events = [ + PipelineEvent::Started, + PipelineEvent::Stopped, + PipelineEvent::Error("test".into()), + PipelineEvent::ViewerConnected, + PipelineEvent::ViewerDisconnected, + PipelineEvent::DataReceived(Bytes::from_static(&[0x17, 0x00])), + ]; + assert_eq!(events.len(), 6); + } +} diff --git a/crates/reestream-core/src/pipeline_impl.rs b/crates/reestream-core/src/pipeline_impl.rs new file mode 100644 index 0000000..4d53620 --- /dev/null +++ b/crates/reestream-core/src/pipeline_impl.rs @@ -0,0 +1,441 @@ +use async_trait::async_trait; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::{RwLock, mpsc}; +use tracing::info; +use uuid::Uuid; + +use crate::pipeline::{ + PipelineEvent, PipelineInfo, PipelineManager, PipelineStats, PipelineStatus, StreamPipeline, +}; + +pub struct RtmpPipeline { + name: String, + #[allow(dead_code)] + input_url: String, + #[allow(dead_code)] + outputs: Vec, + status: PipelineStatus, + stats: PipelineStats, + start_time: Option, + event_tx: mpsc::Sender, +} + +impl RtmpPipeline { + pub fn new( + name: String, + input_url: String, + outputs: Vec, + event_tx: mpsc::Sender, + ) -> Self { + Self { + name, + input_url, + outputs, + status: PipelineStatus::Idle, + stats: PipelineStats::default(), + start_time: None, + event_tx, + } + } +} + +#[async_trait] +impl StreamPipeline for RtmpPipeline { + fn name(&self) -> &str { + &self.name + } + + fn status(&self) -> PipelineStatus { + self.status.clone() + } + + fn stats(&self) -> PipelineStats { + let mut stats = self.stats.clone(); + if let Some(start) = self.start_time { + stats.uptime_secs = start.elapsed().as_secs(); + } + stats + } + + async fn start(&mut self) -> Result<(), Box> { + info!("Starting pipeline: {}", self.name); + self.status = PipelineStatus::Running; + self.start_time = Some(std::time::Instant::now()); + let _ = self.event_tx.send(PipelineEvent::Started).await; + Ok(()) + } + + async fn stop(&mut self) -> Result<(), Box> { + info!("Stopping pipeline: {}", self.name); + self.status = PipelineStatus::Stopped; + self.start_time = None; + let _ = self.event_tx.send(PipelineEvent::Stopped).await; + Ok(()) + } +} + +pub struct SrtPipeline { + name: String, + #[allow(dead_code)] + input_url: String, + #[allow(dead_code)] + outputs: Vec, + status: PipelineStatus, + stats: PipelineStats, + start_time: Option, + event_tx: mpsc::Sender, +} + +impl SrtPipeline { + pub fn new( + name: String, + input_url: String, + outputs: Vec, + event_tx: mpsc::Sender, + ) -> Self { + Self { + name, + input_url, + outputs, + status: PipelineStatus::Idle, + stats: PipelineStats::default(), + start_time: None, + event_tx, + } + } +} + +#[async_trait] +impl StreamPipeline for SrtPipeline { + fn name(&self) -> &str { + &self.name + } + + fn status(&self) -> PipelineStatus { + self.status.clone() + } + + fn stats(&self) -> PipelineStats { + let mut stats = self.stats.clone(); + if let Some(start) = self.start_time { + stats.uptime_secs = start.elapsed().as_secs(); + } + stats + } + + async fn start(&mut self) -> Result<(), Box> { + info!("Starting SRT pipeline: {}", self.name); + self.status = PipelineStatus::Running; + self.start_time = Some(std::time::Instant::now()); + let _ = self.event_tx.send(PipelineEvent::Started).await; + Ok(()) + } + + async fn stop(&mut self) -> Result<(), Box> { + info!("Stopping SRT pipeline: {}", self.name); + self.status = PipelineStatus::Stopped; + self.start_time = None; + let _ = self.event_tx.send(PipelineEvent::Stopped).await; + Ok(()) + } +} + +pub struct FilePipeline { + name: String, + #[allow(dead_code)] + file_path: String, + #[allow(dead_code)] + outputs: Vec, + status: PipelineStatus, + stats: PipelineStats, + start_time: Option, + event_tx: mpsc::Sender, +} + +impl FilePipeline { + pub fn new( + name: String, + file_path: String, + outputs: Vec, + event_tx: mpsc::Sender, + ) -> Self { + Self { + name, + file_path, + outputs, + status: PipelineStatus::Idle, + stats: PipelineStats::default(), + start_time: None, + event_tx, + } + } +} + +#[async_trait] +impl StreamPipeline for FilePipeline { + fn name(&self) -> &str { + &self.name + } + + fn status(&self) -> PipelineStatus { + self.status.clone() + } + + fn stats(&self) -> PipelineStats { + let mut stats = self.stats.clone(); + if let Some(start) = self.start_time { + stats.uptime_secs = start.elapsed().as_secs(); + } + stats + } + + async fn start(&mut self) -> Result<(), Box> { + info!( + "Starting file pipeline: {} from {}", + self.name, self.file_path + ); + self.status = PipelineStatus::Running; + self.start_time = Some(std::time::Instant::now()); + let _ = self.event_tx.send(PipelineEvent::Started).await; + Ok(()) + } + + async fn stop(&mut self) -> Result<(), Box> { + info!("Stopping file pipeline: {}", self.name); + self.status = PipelineStatus::Stopped; + self.start_time = None; + let _ = self.event_tx.send(PipelineEvent::Stopped).await; + Ok(()) + } +} + +pub enum InputType { + Rtmp, + Srt, + File, +} + +pub fn detect_input_type(input: &str) -> InputType { + if input.starts_with("rtmp://") || input.starts_with("rtmps://") { + InputType::Rtmp + } else if input.starts_with("srt://") { + InputType::Srt + } else { + InputType::File + } +} + +pub struct DefaultPipelineManager { + pipelines: Arc>>>, + event_tx: mpsc::Sender, +} + +impl DefaultPipelineManager { + pub fn new() -> (Self, mpsc::Receiver) { + let (event_tx, event_rx) = mpsc::channel(256); + ( + Self { + pipelines: Arc::new(RwLock::new(HashMap::new())), + event_tx, + }, + event_rx, + ) + } +} + +#[async_trait] +impl PipelineManager for DefaultPipelineManager { + async fn create_pipeline( + &self, + name: String, + input: String, + outputs: Vec, + ) -> Result> { + let id = Uuid::new_v4().to_string(); + + let pipeline: Box = match detect_input_type(&input) { + InputType::Rtmp => Box::new(RtmpPipeline::new( + name.clone(), + input.clone(), + outputs.clone(), + self.event_tx.clone(), + )), + InputType::Srt => Box::new(SrtPipeline::new( + name.clone(), + input.clone(), + outputs.clone(), + self.event_tx.clone(), + )), + InputType::File => Box::new(FilePipeline::new( + name.clone(), + input.clone(), + outputs.clone(), + self.event_tx.clone(), + )), + }; + + let mut pipelines = self.pipelines.write().await; + pipelines.insert(id.clone(), pipeline); + info!("Created pipeline {} (id={})", name, id); + Ok(id) + } + + async fn remove_pipeline( + &self, + id: &str, + ) -> Result<(), Box> { + let mut pipelines = self.pipelines.write().await; + if let Some(mut pipeline) = pipelines.remove(id) { + pipeline.stop().await?; + info!("Removed pipeline {}", id); + Ok(()) + } else { + Err(format!("Pipeline {id} not found").into()) + } + } + + async fn list_pipelines(&self) -> Vec { + let pipelines = self.pipelines.read().await; + pipelines + .iter() + .map(|(id, p)| PipelineInfo { + id: id.clone(), + name: p.name().to_string(), + input: String::new(), + outputs: vec![], + status: p.status(), + stats: p.stats(), + }) + .collect() + } + + async fn get_pipeline(&self, id: &str) -> Option> { + let pipelines = self.pipelines.read().await; + pipelines.get(id).map(|_| { + // We can't easily clone trait objects, so return None + // In practice, callers should use list_pipelines() or specific accessors + None + })? + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_detect_input_type_rtmp() { + assert!(matches!( + detect_input_type("rtmp://live.twitch.tv/app"), + InputType::Rtmp + )); + } + + #[test] + fn test_detect_input_type_rtmps() { + assert!(matches!( + detect_input_type("rtmps://live-api-s.facebook.com:443/rtmp/"), + InputType::Rtmp + )); + } + + #[test] + fn test_detect_input_type_srt() { + assert!(matches!( + detect_input_type("srt://0.0.0.0:3000"), + InputType::Srt + )); + } + + #[test] + fn test_detect_input_type_file() { + assert!(matches!( + detect_input_type("/tmp/video.mp4"), + InputType::File + )); + } + + #[tokio::test] + async fn test_create_rtmp_pipeline() { + let (manager, _rx) = DefaultPipelineManager::new(); + let id = manager + .create_pipeline( + "test".into(), + "rtmp://input".into(), + vec!["rtmp://output".into()], + ) + .await + .unwrap(); + assert!(!id.is_empty()); + let pipelines = manager.list_pipelines().await; + assert_eq!(pipelines.len(), 1); + assert_eq!(pipelines[0].name, "test"); + } + + #[tokio::test] + async fn test_create_srt_pipeline() { + let (manager, _rx) = DefaultPipelineManager::new(); + let id = manager + .create_pipeline( + "srt-test".into(), + "srt://0.0.0.0:3000".into(), + vec!["rtmp://output".into()], + ) + .await + .unwrap(); + assert!(!id.is_empty()); + } + + #[tokio::test] + async fn test_create_file_pipeline() { + let (manager, _rx) = DefaultPipelineManager::new(); + let id = manager + .create_pipeline( + "file-test".into(), + "/tmp/video.mp4".into(), + vec!["rtmp://output".into()], + ) + .await + .unwrap(); + assert!(!id.is_empty()); + } + + #[tokio::test] + async fn test_remove_pipeline() { + let (manager, _rx) = DefaultPipelineManager::new(); + let id = manager + .create_pipeline("test".into(), "rtmp://input".into(), vec![]) + .await + .unwrap(); + assert!(manager.remove_pipeline(&id).await.is_ok()); + assert!(manager.list_pipelines().await.is_empty()); + } + + #[tokio::test] + async fn test_remove_nonexistent_pipeline() { + let (manager, _rx) = DefaultPipelineManager::new(); + assert!(manager.remove_pipeline("nonexistent").await.is_err()); + } + + #[tokio::test] + async fn test_rtmp_pipeline_lifecycle() { + let (tx, _rx) = mpsc::channel(10); + let mut pipeline = RtmpPipeline::new("test".into(), "rtmp://input".into(), vec![], tx); + assert_eq!(pipeline.status(), PipelineStatus::Idle); + pipeline.start().await.unwrap(); + assert_eq!(pipeline.status(), PipelineStatus::Running); + pipeline.stop().await.unwrap(); + assert_eq!(pipeline.status(), PipelineStatus::Stopped); + } + + #[tokio::test] + async fn test_pipeline_stats() { + let (tx, _rx) = mpsc::channel(10); + let mut pipeline = RtmpPipeline::new("test".into(), "rtmp://input".into(), vec![], tx); + pipeline.start().await.unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let stats = pipeline.stats(); + assert!(stats.uptime_secs <= 1); + } +} diff --git a/crates/reestream-core/src/provider.rs b/crates/reestream-core/src/provider.rs new file mode 100644 index 0000000..70d2f60 --- /dev/null +++ b/crates/reestream-core/src/provider.rs @@ -0,0 +1,158 @@ +use std::error::Error; +use std::fmt; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug)] +#[allow(dead_code)] +#[allow(clippy::enum_variant_names)] +pub enum StreamKeyError { + OAuthError(String), + ApiError(String), + ParseError(String), + NetworkError(String), +} + +impl fmt::Display for StreamKeyError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + StreamKeyError::OAuthError(msg) => write!(f, "OAuth Error: {msg}"), + StreamKeyError::ApiError(msg) => write!(f, "API Error: {msg}"), + StreamKeyError::ParseError(msg) => write!(f, "Parse Error: {msg}"), + StreamKeyError::NetworkError(msg) => write!(f, "Network Error: {msg}"), + } + } +} + +impl Error for StreamKeyError {} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[allow(dead_code)] +pub struct StreamKey { + pub key: String, + pub rtmp_url: String, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct OAuth2Config { + pub client_id: String, + pub client_secret: String, + pub redirect_uri: String, + pub access_token: Option, +} + +#[allow(dead_code)] +#[allow(async_fn_in_trait)] +pub trait StreamKeyProvider: Send + Sync { + const NAME: &str; + + fn get_auth_url(&self, state: &str, scopes: &[&str]) -> String; + async fn exchange_code(&mut self, code: &str) -> Result; + async fn get_stream_key(&self) -> Result; + async fn refresh_token(&mut self, refresh_token: &str) -> Result; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_stream_key_serialization() { + let key = StreamKey { + key: "abc123".to_string(), + rtmp_url: "rtmp://live.twitch.tv/app".to_string(), + }; + let json = serde_json::to_string(&key).unwrap(); + assert!(json.contains("abc123")); + assert!(json.contains("rtmp://live.twitch.tv/app")); + } + + #[test] + fn test_stream_key_deserialization() { + let json = r#"{"key":"test-key","rtmp_url":"rtmp://example.com/live"}"#; + let key: StreamKey = serde_json::from_str(json).unwrap(); + assert_eq!(key.key, "test-key"); + assert_eq!(key.rtmp_url, "rtmp://example.com/live"); + } + + #[test] + fn test_stream_key_roundtrip() { + let key = StreamKey { + key: "roundtrip".to_string(), + rtmp_url: "rtmps://facebook.com:443/rtmp".to_string(), + }; + let json = serde_json::to_string(&key).unwrap(); + let deserialized: StreamKey = serde_json::from_str(&json).unwrap(); + assert_eq!(key.key, deserialized.key); + assert_eq!(key.rtmp_url, deserialized.rtmp_url); + } + + #[test] + fn test_oauth2_config_fields() { + let config = OAuth2Config { + client_id: "my-id".to_string(), + client_secret: "my-secret".to_string(), + redirect_uri: "http://localhost/callback".to_string(), + access_token: Some("token123".to_string()), + }; + assert_eq!(config.client_id, "my-id"); + assert_eq!(config.client_secret, "my-secret"); + assert_eq!(config.redirect_uri, "http://localhost/callback"); + assert_eq!(config.access_token.as_deref(), Some("token123")); + } + + #[test] + fn test_oauth2_config_no_token() { + let config = OAuth2Config { + client_id: "id".to_string(), + client_secret: "secret".to_string(), + redirect_uri: "http://localhost".to_string(), + access_token: None, + }; + assert!(config.access_token.is_none()); + } + + #[test] + fn test_stream_key_error_display() { + let err = StreamKeyError::OAuthError("invalid_grant".into()); + assert_eq!(err.to_string(), "OAuth Error: invalid_grant"); + + let err = StreamKeyError::ApiError("rate limited".into()); + assert_eq!(err.to_string(), "API Error: rate limited"); + + let err = StreamKeyError::ParseError("bad json".into()); + assert_eq!(err.to_string(), "Parse Error: bad json"); + + let err = StreamKeyError::NetworkError("connection refused".into()); + assert_eq!(err.to_string(), "Network Error: connection refused"); + } + + #[test] + fn test_stream_key_error_is_std_error() { + let err: Box = Box::new(StreamKeyError::OAuthError("test".into())); + assert!(err.to_string().contains("OAuth Error")); + } + + #[test] + fn test_stream_key_clone() { + let key = StreamKey { + key: "clone-test".to_string(), + rtmp_url: "rtmp://test.com".to_string(), + }; + let cloned = key.clone(); + assert_eq!(key.key, cloned.key); + assert_eq!(key.rtmp_url, cloned.rtmp_url); + } + + #[test] + fn test_stream_key_debug() { + let key = StreamKey { + key: "debug".to_string(), + rtmp_url: "rtmp://debug.com".to_string(), + }; + let debug_str = format!("{:?}", key); + assert!(debug_str.contains("StreamKey")); + assert!(debug_str.contains("debug")); + } +} diff --git a/crates/reestream-core/src/server.rs b/crates/reestream-core/src/server.rs new file mode 100644 index 0000000..96c21ac --- /dev/null +++ b/crates/reestream-core/src/server.rs @@ -0,0 +1,176 @@ +use rml_rtmp::handshake::{Handshake, HandshakeProcessResult, PeerType}; +use rml_rtmp::sessions::{ServerSession, ServerSessionConfig, ServerSessionResult}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +/// Handshake server side and create ServerSession with lower-latency config +pub async fn handshake_and_create_server_session( + stream: &mut TcpStream, +) -> Result<(ServerSession, Vec), Box> { + let mut hs = Handshake::new(PeerType::Server); + let mut buf = [0u8; 4096]; + + loop { + let n = stream.read(&mut buf).await?; + if n == 0 { + return Err("EOF durante handshake (no se recibieron datos de cliente)".into()); + } + + match hs.process_bytes(&buf[..n])? { + HandshakeProcessResult::InProgress { response_bytes } => { + if !response_bytes.is_empty() { + stream.write_all(&response_bytes).await?; + } + } + HandshakeProcessResult::Completed { + response_bytes, + remaining_bytes, + } => { + if !response_bytes.is_empty() { + stream.write_all(&response_bytes).await?; + } + return Ok(( + { + // Reduce latency: use smaller chunk size and smaller ack window to have quicker acks + let mut config = ServerSessionConfig::new(); + config.chunk_size = 128; // smaller chunks -> lower per-chunk latency (tradeoff CPU) + config.window_ack_size = 262_144; // 256KB ack window to get more frequent acks + + let (server_session, initial_results) = ServerSession::new(config)?; + for res in initial_results { + if let ServerSessionResult::OutboundResponse(packet) = res { + stream.write_all(&packet.bytes).await?; + } + } + server_session + }, + remaining_bytes, + )); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rml_rtmp::handshake::Handshake; + + #[test] + fn test_server_session_config_low_latency() { + let mut config = ServerSessionConfig::new(); + config.chunk_size = 128; + config.window_ack_size = 262_144; + assert_eq!(config.chunk_size, 128); + assert_eq!(config.window_ack_size, 262_144); + } + + #[test] + fn test_server_session_creation() { + let mut config = ServerSessionConfig::new(); + config.chunk_size = 128; + config.window_ack_size = 262_144; + let result = ServerSession::new(config); + assert!(result.is_ok()); + let (_session, initial_results) = result.unwrap(); + // ServerSession::new may produce initial results (e.g., window ack size) + for res in &initial_results { + assert!(matches!(res, ServerSessionResult::OutboundResponse(_))); + } + } + + #[test] + fn test_handshake_server_creates() { + let hs = Handshake::new(PeerType::Server); + // Handshake should be constructable without panic + let _ = hs; + } + + #[test] + fn test_handshake_client_creates() { + let hs = Handshake::new(PeerType::Client); + let _ = hs; + } + + #[test] + fn test_handshake_process_empty_bytes() { + let mut hs = Handshake::new(PeerType::Server); + let result = hs.process_bytes(&[]); + // Empty bytes should not crash; result depends on implementation + assert!(result.is_ok() || result.is_err()); + } + + #[tokio::test] + async fn test_handshake_and_create_server_session_eof() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let client = tokio::net::TcpStream::connect(addr).await.unwrap(); + let (mut server_stream, _) = listener.accept().await.unwrap(); + + // Drop client immediately to cause EOF + drop(client); + + let result = handshake_and_create_server_session(&mut server_stream).await; + assert!(result.is_err()); + let err_msg = result.err().unwrap().to_string(); + assert!(err_msg.contains("EOF") || err_msg.contains("eof")); + } + + #[tokio::test] + async fn test_handshake_and_create_server_session_success() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let mut client = tokio::net::TcpStream::connect(addr).await.unwrap(); + let (mut server_stream, _) = listener.accept().await.unwrap(); + + // Client sends C0+C1 + let mut client_hs = Handshake::new(PeerType::Client); + let c0_c1 = client_hs.generate_outbound_p0_and_p1().unwrap(); + client.write_all(&c0_c1).await.unwrap(); + + // Server processes in background + let server_handle = + tokio::spawn( + async move { handshake_and_create_server_session(&mut server_stream).await }, + ); + + // Client reads S0+S1+S2 + let mut buf = [0u8; 4096]; + let n = client.read(&mut buf).await.unwrap(); + assert!(n > 0); + + let result = client_hs.process_bytes(&buf[..n]).unwrap(); + match result { + HandshakeProcessResult::Completed { response_bytes, .. } => { + if !response_bytes.is_empty() { + client.write_all(&response_bytes).await.unwrap(); + } + } + HandshakeProcessResult::InProgress { response_bytes } => { + if !response_bytes.is_empty() { + client.write_all(&response_bytes).await.unwrap(); + } + // Read more if needed + let n = client.read(&mut buf).await.unwrap(); + let result2 = client_hs.process_bytes(&buf[..n]).unwrap(); + match result2 { + HandshakeProcessResult::Completed { response_bytes, .. } => { + if !response_bytes.is_empty() { + client.write_all(&response_bytes).await.unwrap(); + } + } + _ => panic!("Expected handshake completion"), + } + } + } + + let server_result = server_handle.await.unwrap(); + assert!(server_result.is_ok()); + if let Ok((_session, leftover)) = server_result { + // leftover may or may not be empty depending on timing + let _ = leftover; + } + } +} diff --git a/crates/reestream-core/src/setup.rs b/crates/reestream-core/src/setup.rs new file mode 100644 index 0000000..0eec1a4 --- /dev/null +++ b/crates/reestream-core/src/setup.rs @@ -0,0 +1,372 @@ +use std::io::{self, Write}; +use std::path::Path; + +use crate::config::{Config, ConfigBuilder, Orientation}; + +pub fn is_first_run(config_path: &Path) -> bool { + !config_path.exists() +} + +pub fn run_cli_wizard(config_path: &Path) -> Result> { + println!(); + println!("╔══════════════════════════════════════════╗"); + println!("║ Reestream First-Time Setup ║"); + println!("╚══════════════════════════════════════════╝"); + println!(); + + let rtmp_addr = prompt("RTMP bind address", "0.0.0.0"); + let rtmp_port = prompt("RTMP port", "1935").parse::().unwrap_or(1935); + let stream_key = prompt_secret("Stream key (for publishing)"); + + let mut builder = ConfigBuilder::new() + .addr(&rtmp_addr) + .port(rtmp_port) + .stream_key(&stream_key); + + println!(); + println!("── Output Platforms ──"); + println!("Add platforms to forward streams to (leave URL empty to stop):"); + println!(); + + let mut idx = 1; + loop { + println!("── Platform {} ──", idx); + let url = prompt(" RTMP URL (empty to skip)", ""); + if url.is_empty() { + break; + } + let key = prompt_secret(" Stream key"); + let orientation = prompt(" Orientation (horizontal/vertical)", "horizontal"); + let orientation = match orientation.to_lowercase().as_str() { + "vertical" | "v" | "9:16" => Orientation::Vertical, + _ => Orientation::Horizontal, + }; + + match url::Url::parse(&url) { + Ok(parsed_url) => { + builder = builder.add_platform(parsed_url, &key, orientation); + println!(" ✓ Added\n"); + } + Err(e) => { + println!(" ✗ Invalid URL: {e}, skipping\n"); + } + } + idx += 1; + } + + let config = builder.build(); + + if let Err(e) = config.validate() { + return Err(format!("Config validation failed: {e}").into()); + } + + let toml_content = config.to_toml()?; + std::fs::write(config_path, &toml_content)?; + + println!(); + println!("✓ Configuration saved to {}", config_path.display()); + println!(); + println!(" RTMP: {}:{}", config.rtmp_addr, config.rtmp_port); + println!(" Key: {}", config.stream_key); + println!( + " Platforms: {}", + config.platform.as_ref().map_or(0, |p| p.len()) + ); + println!(); + println!("Run 'reestream' to start the server."); + println!("Or open http://localhost:8080 for the web dashboard."); + println!(); + + Ok(config) +} + +fn prompt(label: &str, default: &str) -> String { + if default.is_empty() { + print!("{label}: "); + } else { + print!("{label} [{default}]: "); + } + io::stdout().flush().unwrap(); + + let mut input = String::new(); + io::stdin().read_line(&mut input).unwrap(); + let input = input.trim(); + + if input.is_empty() { + default.to_string() + } else { + input.to_string() + } +} + +fn prompt_secret(label: &str) -> String { + print!("{label}: "); + io::stdout().flush().unwrap(); + + let mut input = String::new(); + io::stdin().read_line(&mut input).unwrap(); + input.trim().to_string() +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SetupStatus { + pub first_run: bool, + pub config_exists: bool, + pub has_stream_key: bool, + pub platform_count: usize, +} + +pub fn get_setup_status(config_path: &Path) -> SetupStatus { + let config_exists = config_path.exists(); + + if !config_exists { + return SetupStatus { + first_run: true, + config_exists: false, + has_stream_key: false, + platform_count: 0, + }; + } + + match Config::from_file(config_path) { + Ok(config) => { + let has_stream_key = !config.stream_key.is_empty() + && config.stream_key != "your-key" + && config.stream_key != "test-key"; + let platform_count = config.platform.as_ref().map_or(0, |p| p.len()); + SetupStatus { + first_run: !has_stream_key || platform_count == 0, + config_exists: true, + has_stream_key, + platform_count, + } + } + Err(_) => SetupStatus { + first_run: true, + config_exists: true, + has_stream_key: false, + platform_count: 0, + }, + } +} + +#[derive(Debug, serde::Deserialize)] +pub struct SetupRequest { + pub rtmp_addr: Option, + pub rtmp_port: Option, + pub stream_key: String, + pub platforms: Vec, +} + +#[derive(Debug, serde::Deserialize)] +pub struct SetupPlatform { + pub name: String, + pub url: String, + pub key: String, + pub orientation: Option, +} + +pub fn apply_setup( + config_path: &Path, + req: &SetupRequest, +) -> Result> { + let mut builder = ConfigBuilder::new() + .addr(req.rtmp_addr.as_deref().unwrap_or("0.0.0.0")) + .port(req.rtmp_port.unwrap_or(1935)) + .stream_key(&req.stream_key); + + for p in &req.platforms { + let url = url::Url::parse(&p.url)?; + let orientation = match p.orientation.as_deref() { + Some("vertical") => Orientation::Vertical, + _ => Orientation::Horizontal, + }; + builder = builder.add_platform(url, &p.key, orientation); + } + + let config = builder.build(); + config.validate()?; + + let toml_content = config.to_toml()?; + std::fs::write(config_path, toml_content)?; + + Ok(config) +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ServerInfo { + pub rtmp_url: String, + pub rtmps_url: Option, + pub srt_url: Option, + pub http_url: String, + pub hls_url: String, + pub flv_url: String, + pub dashboard_url: String, + pub api_url: String, + pub metrics_url: String, + pub stream_key_masked: String, + pub rtmp_port: u16, + pub http_port: u16, + pub srt_port: u16, + pub hostname: String, +} + +pub fn get_server_info(config_path: &Path) -> Result> { + let config = Config::from_file(config_path)?; + + let hostname = std::env::var("HOSTNAME") + .or_else(|_| std::env::var("COMPUTERNAME")) + .unwrap_or_else(|_| "localhost".to_string()); + + let rtmp_port = config.rtmp_port; + let http_port = 8080; + let srt_port = 3000; + + let key = &config.stream_key; + let masked = if key.len() <= 4 { + "****".to_string() + } else { + format!("{}…{}", &key[..4], &key[key.len() - 4..]) + }; + + let rtmp_url = format!("rtmp://{hostname}:{rtmp_port}"); + let rtmps_url = Some(format!("rtmps://{hostname}:{rtmp_port}")); + let srt_url = Some(format!("srt://{hostname}:{srt_port}")); + let http_url = format!("http://{hostname}:{http_port}"); + + Ok(ServerInfo { + rtmp_url, + rtmps_url, + srt_url, + http_url: http_url.clone(), + hls_url: format!("{http_url}/stream.m3u8"), + flv_url: format!("{http_url}/stream.flv"), + dashboard_url: http_url.clone(), + api_url: format!("{http_url}/api/status"), + metrics_url: format!("{http_url}/metrics"), + stream_key_masked: masked, + rtmp_port, + http_port, + srt_port, + hostname, + }) +} + +pub fn get_stream_key(config_path: &Path) -> Result> { + let config = Config::from_file(config_path)?; + Ok(config.stream_key) +} + +pub fn reset_stream_key(config_path: &Path) -> Result> { + let mut config = Config::from_file(config_path)?; + + let new_key = uuid::Uuid::new_v4().to_string(); + config.stream_key = new_key.clone(); + + let toml_content = config.to_toml()?; + std::fs::write(config_path, toml_content)?; + + Ok(new_key) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_first_run_no_file() { + let path = std::env::temp_dir().join("reestream_test_noexist_setup.toml"); + let _ = std::fs::remove_file(&path); + assert!(is_first_run(&path)); + } + + #[test] + fn test_is_first_run_with_file() { + let dir = std::env::temp_dir().join("reestream_test_setup_exist"); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("config.toml"); + std::fs::write(&path, "test").unwrap(); + assert!(!is_first_run(&path)); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn test_get_setup_status_no_config() { + let path = std::env::temp_dir().join("reestream_test_nostatus.toml"); + let _ = std::fs::remove_file(&path); + let status = get_setup_status(&path); + assert!(status.first_run); + assert!(!status.config_exists); + } + + #[test] + fn test_get_setup_status_valid_config() { + let dir = std::env::temp_dir().join("reestream_test_status_ok"); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("config.toml"); + std::fs::write( + &path, + r#"rtmp_addr = "0.0.0.0" +rtmp_port = 1935 +stream_key = "real-key-here" +[[platform]] +url = "rtmp://twitch.tv/app" +key = "key" +orientation = "horizontal" +"#, + ) + .unwrap(); + let status = get_setup_status(&path); + assert!(!status.first_run); + assert!(status.config_exists); + assert!(status.has_stream_key); + assert_eq!(status.platform_count, 1); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn test_get_setup_status_default_key() { + let dir = std::env::temp_dir().join("reestream_test_status_default"); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("config.toml"); + std::fs::write( + &path, + r#"rtmp_addr = "0.0.0.0" +rtmp_port = 1935 +stream_key = "test-key" +"#, + ) + .unwrap(); + let status = get_setup_status(&path); + assert!(status.first_run); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn test_apply_setup() { + let dir = std::env::temp_dir().join("reestream_test_apply_setup"); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("config.toml"); + let _ = std::fs::remove_file(&path); + + let req = SetupRequest { + rtmp_addr: Some("127.0.0.1".into()), + rtmp_port: Some(9999), + stream_key: "my-new-key".into(), + platforms: vec![SetupPlatform { + name: "Twitch".into(), + url: "rtmp://live.twitch.tv/app".into(), + key: "twitch-key".into(), + orientation: Some("horizontal".into()), + }], + }; + + let config = apply_setup(&path, &req).unwrap(); + assert_eq!(config.rtmp_addr, "127.0.0.1"); + assert_eq!(config.rtmp_port, 9999); + assert_eq!(config.stream_key, "my-new-key"); + assert_eq!(config.platform.as_ref().unwrap().len(), 1); + assert!(path.exists()); + let _ = std::fs::remove_file(&path); + } +} diff --git a/crates/reestream-ffmpeg/Cargo.toml b/crates/reestream-ffmpeg/Cargo.toml new file mode 100644 index 0000000..9b62d10 --- /dev/null +++ b/crates/reestream-ffmpeg/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "reestream-ffmpeg" +version = "0.2.0" +edition = "2024" +authors = ["RustLangES contact@rustlang-es.org"] +description = "FFmpeg binary manager and process wrapper" +license = "MIT OR Apache-2.0" + +[dependencies] +serde = { version = "1", features = ["derive"] } +tokio = { version = "1", default-features = false, features = [ + "fs", + "io-util", + "macros", + "process", + "rt-multi-thread", + "time", + "sync", +] } +tracing = { version = "0.1", features = ["log"] } +which = "7" +sha2 = "0.10" +hex = "0.4" +reqwest = { version = "0.12.24", default-features = false, features = [ + "rustls-tls", + "stream", +] } +futures-util = "0.3" +dirs = "6" +thiserror = "2" + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/crates/reestream-ffmpeg/src/command.rs b/crates/reestream-ffmpeg/src/command.rs new file mode 100644 index 0000000..86a266f --- /dev/null +++ b/crates/reestream-ffmpeg/src/command.rs @@ -0,0 +1,392 @@ +use std::path::PathBuf; + +#[derive(Debug, Clone)] +pub struct FfmpegCommand { + pub ffmpeg_path: PathBuf, + pub input: InputSource, + pub outputs: Vec, + pub global_args: Vec, + pub hw_accel: Option, +} + +#[derive(Debug, Clone)] +pub enum InputSource { + Rtmp { url: String }, + File { path: PathBuf }, + Pipe, +} + +#[derive(Debug, Clone)] +pub struct Output { + pub destination: OutputDestination, + pub codec_args: Vec, + pub format_args: Vec, +} + +#[derive(Debug, Clone)] +pub enum OutputDestination { + Hls { + segment_path: PathBuf, + playlist_path: PathBuf, + }, + File { + path: PathBuf, + }, + Rtmp { + url: String, + }, + FlvHttp { + endpoint: String, + }, + Pipe, +} + +#[derive(Debug, Clone)] +pub enum HardwareAccel { + Vaapi, + Nvenc, + VideoToolbox, + Mmal, +} + +impl FfmpegCommand { + pub fn new(ffmpeg_path: PathBuf, input: InputSource) -> Self { + Self { + ffmpeg_path, + input, + outputs: Vec::new(), + global_args: Vec::new(), + hw_accel: None, + } + } + + pub fn global_arg(mut self, arg: impl Into) -> Self { + self.global_args.push(arg.into()); + self + } + + pub fn hw_accel(mut self, accel: HardwareAccel) -> Self { + self.hw_accel = Some(accel); + self + } + + pub fn add_output(mut self, output: Output) -> Self { + self.outputs.push(output); + self + } + + pub fn passthrough_to_rtmp(self, url: &str) -> Self { + self.add_output(Output { + destination: OutputDestination::Rtmp { + url: url.to_string(), + }, + codec_args: vec!["-c", "copy"].into_iter().map(String::from).collect(), + format_args: vec!["-f", "flv"].into_iter().map(String::from).collect(), + }) + } + + pub fn to_hls(self, segment_path: PathBuf, playlist_path: PathBuf) -> Self { + self.add_output(Output { + destination: OutputDestination::Hls { + segment_path, + playlist_path, + }, + codec_args: vec!["-c", "copy"].into_iter().map(String::from).collect(), + format_args: vec![ + "-f", + "hls", + "-hls_time", + "2", + "-hls_list_size", + "10", + "-hls_flags", + "delete_segments", + ] + .into_iter() + .map(String::from) + .collect(), + }) + } + + pub fn to_flv_http(self, endpoint: &str) -> Self { + self.add_output(Output { + destination: OutputDestination::FlvHttp { + endpoint: endpoint.to_string(), + }, + codec_args: vec!["-c", "copy"].into_iter().map(String::from).collect(), + format_args: vec!["-f", "flv"].into_iter().map(String::from).collect(), + }) + } + + pub fn transcode(self, output: OutputDestination, resolution: &str, bitrate: &str) -> Self { + self.add_output(Output { + destination: output, + codec_args: vec![ + "-c:v", + "libx264", + "-preset", + "veryfast", + "-b:v", + bitrate, + "-maxrate", + bitrate, + "-bufsize", + bitrate, + "-vf", + &format!("scale={resolution}"), + "-c:a", + "aac", + "-b:a", + "128k", + ] + .into_iter() + .map(String::from) + .collect(), + format_args: vec![], + }) + } + + pub fn build_args(&self) -> Vec { + let mut args: Vec = Vec::new(); + + // Global args + args.extend(self.global_args.clone()); + + // Hardware acceleration + match &self.hw_accel { + Some(HardwareAccel::Vaapi) => { + args.extend(["-vaapi_device".into(), "/dev/dri/renderD128".into()]); + args.extend(["-hwaccel".into(), "vaapi".into()]); + args.extend(["-hwaccel_output_format".into(), "vaapi".into()]); + } + Some(HardwareAccel::Nvenc) => { + args.extend(["-hwaccel".into(), "cuda".into()]); + } + Some(HardwareAccel::VideoToolbox) => { + args.extend(["-hwaccel".into(), "videotoolbox".into()]); + } + Some(HardwareAccel::Mmal) => { + args.extend(["-hwaccel".into(), "mmal".into()]); + } + None => {} + } + + // Input + match &self.input { + InputSource::Rtmp { url } => { + args.extend(["-listen".into(), "1".into(), "-i".into(), url.clone()]); + } + InputSource::File { path } => { + args.extend(["-i".into(), path.to_string_lossy().to_string()]); + } + InputSource::Pipe => { + args.extend(["-i".into(), "pipe:0".into()]); + } + } + + // Outputs + for output in &self.outputs { + args.extend(output.codec_args.clone()); + + match &output.destination { + OutputDestination::Hls { + segment_path: _, + playlist_path, + } => { + args.extend(output.format_args.clone()); + args.push(playlist_path.to_string_lossy().to_string()); + // HLS segment pattern is derived from playlist path + } + OutputDestination::File { path } => { + args.extend(output.format_args.clone()); + args.push(path.to_string_lossy().to_string()); + } + OutputDestination::Rtmp { url } => { + args.extend(output.format_args.clone()); + args.push(url.clone()); + } + OutputDestination::FlvHttp { endpoint } => { + args.extend(output.format_args.clone()); + args.push(endpoint.clone()); + } + OutputDestination::Pipe => { + args.extend(output.format_args.clone()); + args.push("pipe:1".into()); + } + } + } + + args + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_passthrough_command() { + let cmd = FfmpegCommand::new( + PathBuf::from("ffmpeg"), + InputSource::Rtmp { + url: "rtmp://0.0.0.0:1935/live".into(), + }, + ) + .passthrough_to_rtmp("rtmp://live.twitch.tv/app/key"); + + let args = cmd.build_args(); + assert!(args.contains(&"-i".to_string())); + assert!(args.contains(&"-c".to_string())); + assert!(args.contains(&"copy".to_string())); + assert!(args.contains(&"-f".to_string())); + assert!(args.contains(&"flv".to_string())); + assert!(args.contains(&"rtmp://live.twitch.tv/app/key".to_string())); + } + + #[test] + fn test_build_hls_command() { + let cmd = FfmpegCommand::new( + PathBuf::from("ffmpeg"), + InputSource::Rtmp { + url: "rtmp://0.0.0.0:1935/live".into(), + }, + ) + .to_hls( + PathBuf::from("/tmp/segments"), + PathBuf::from("/tmp/playlist.m3u8"), + ); + + let args = cmd.build_args(); + assert!(args.contains(&"-f".to_string())); + assert!(args.contains(&"hls".to_string())); + assert!(args.contains(&"-hls_time".to_string())); + assert!(args.contains(&"2".to_string())); + } + + #[test] + fn test_build_transcode_command() { + let cmd = FfmpegCommand::new( + PathBuf::from("ffmpeg"), + InputSource::Rtmp { + url: "rtmp://0.0.0.0:1935/live".into(), + }, + ) + .transcode( + OutputDestination::File { + path: PathBuf::from("/tmp/output.mp4"), + }, + "1280x720", + "2500k", + ); + + let args = cmd.build_args(); + assert!(args.contains(&"-c:v".to_string())); + assert!(args.contains(&"libx264".to_string())); + assert!(args.contains(&"-b:v".to_string())); + assert!(args.contains(&"2500k".to_string())); + } + + #[test] + fn test_build_hwaccel_nvenc() { + let cmd = FfmpegCommand::new( + PathBuf::from("ffmpeg"), + InputSource::Rtmp { + url: "rtmp://0.0.0.0:1935/live".into(), + }, + ) + .hw_accel(HardwareAccel::Nvenc) + .passthrough_to_rtmp("rtmp://output/app"); + + let args = cmd.build_args(); + assert!(args.contains(&"-hwaccel".to_string())); + assert!(args.contains(&"cuda".to_string())); + } + + #[test] + fn test_build_hwaccel_vaapi() { + let cmd = FfmpegCommand::new( + PathBuf::from("ffmpeg"), + InputSource::Rtmp { + url: "rtmp://0.0.0.0:1935/live".into(), + }, + ) + .hw_accel(HardwareAccel::Vaapi) + .passthrough_to_rtmp("rtmp://output/app"); + + let args = cmd.build_args(); + assert!(args.contains(&"-vaapi_device".to_string())); + assert!(args.contains(&"/dev/dri/renderD128".to_string())); + } + + #[test] + fn test_build_multiple_outputs() { + let cmd = FfmpegCommand::new( + PathBuf::from("ffmpeg"), + InputSource::Rtmp { + url: "rtmp://0.0.0.0:1935/live".into(), + }, + ) + .passthrough_to_rtmp("rtmp://twitch.tv/app/key1") + .passthrough_to_rtmp("rtmp://youtube.com/live2/key2"); + + let args = cmd.build_args(); + assert!(args.contains(&"rtmp://twitch.tv/app/key1".to_string())); + assert!(args.contains(&"rtmp://youtube.com/live2/key2".to_string())); + } + + #[test] + fn test_build_global_args() { + let cmd = FfmpegCommand::new( + PathBuf::from("ffmpeg"), + InputSource::Rtmp { + url: "rtmp://0.0.0.0:1935/live".into(), + }, + ) + .global_arg("-loglevel") + .global_arg("warning") + .passthrough_to_rtmp("rtmp://output/app"); + + let args = cmd.build_args(); + assert_eq!(args[0], "-loglevel"); + assert_eq!(args[1], "warning"); + } + + #[test] + fn test_input_source_variants() { + let rtmp = InputSource::Rtmp { + url: "rtmp://test".into(), + }; + let file = InputSource::File { + path: PathBuf::from("/tmp/test.mp4"), + }; + let pipe = InputSource::Pipe; + + assert!(matches!(rtmp, InputSource::Rtmp { .. })); + assert!(matches!(file, InputSource::File { .. })); + assert!(matches!(pipe, InputSource::Pipe)); + } + + #[test] + fn test_output_destination_variants() { + let hls = OutputDestination::Hls { + segment_path: PathBuf::from("/tmp/seg"), + playlist_path: PathBuf::from("/tmp/playlist.m3u8"), + }; + let file = OutputDestination::File { + path: PathBuf::from("/tmp/out.mp4"), + }; + let rtmp = OutputDestination::Rtmp { + url: "rtmp://test".into(), + }; + let flv = OutputDestination::FlvHttp { + endpoint: "/stream.flv".into(), + }; + let pipe = OutputDestination::Pipe; + + assert!(matches!(hls, OutputDestination::Hls { .. })); + assert!(matches!(file, OutputDestination::File { .. })); + assert!(matches!(rtmp, OutputDestination::Rtmp { .. })); + assert!(matches!(flv, OutputDestination::FlvHttp { .. })); + assert!(matches!(pipe, OutputDestination::Pipe)); + } +} diff --git a/crates/reestream-ffmpeg/src/error.rs b/crates/reestream-ffmpeg/src/error.rs new file mode 100644 index 0000000..365a44c --- /dev/null +++ b/crates/reestream-ffmpeg/src/error.rs @@ -0,0 +1,86 @@ +use std::fmt; + +#[derive(Debug)] +pub enum FfmpegError { + BinaryNotFound(String), + DownloadFailed(String), + ChecksumMismatch { expected: String, actual: String }, + ProcessStartFailed(std::io::Error), + ProcessFailed { exit_code: i32, stderr: String }, + InvalidArgument(String), + IoError(std::io::Error), +} + +impl fmt::Display for FfmpegError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BinaryNotFound(msg) => write!(f, "FFmpeg binary not found: {msg}"), + Self::DownloadFailed(msg) => write!(f, "FFmpeg download failed: {msg}"), + Self::ChecksumMismatch { expected, actual } => { + write!(f, "Checksum mismatch: expected {expected}, got {actual}") + } + Self::ProcessStartFailed(e) => write!(f, "Failed to start FFmpeg: {e}"), + Self::ProcessFailed { exit_code, stderr } => { + write!(f, "FFmpeg exited with code {exit_code}: {stderr}") + } + Self::InvalidArgument(msg) => write!(f, "Invalid FFmpeg argument: {msg}"), + Self::IoError(e) => write!(f, "IO error: {e}"), + } + } +} + +impl std::error::Error for FfmpegError {} + +impl From for FfmpegError { + fn from(e: std::io::Error) -> Self { + Self::IoError(e) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_display_binary_not_found() { + let err = FfmpegError::BinaryNotFound("not in PATH".into()); + assert!(err.to_string().contains("not found")); + } + + #[test] + fn test_display_download_failed() { + let err = FfmpegError::DownloadFailed("404".into()); + assert!(err.to_string().contains("download failed")); + } + + #[test] + fn test_display_checksum_mismatch() { + let err = FfmpegError::ChecksumMismatch { + expected: "abc".into(), + actual: "def".into(), + }; + assert!(err.to_string().contains("Checksum mismatch")); + } + + #[test] + fn test_display_process_failed() { + let err = FfmpegError::ProcessFailed { + exit_code: 1, + stderr: "error".into(), + }; + assert!(err.to_string().contains("exited with code 1")); + } + + #[test] + fn test_from_io_error() { + let io = std::io::Error::new(std::io::ErrorKind::NotFound, "test"); + let err: FfmpegError = io.into(); + assert!(matches!(err, FfmpegError::IoError(_))); + } + + #[test] + fn test_error_trait() { + let err: Box = Box::new(FfmpegError::InvalidArgument("bad".into())); + assert!(err.to_string().contains("Invalid")); + } +} diff --git a/crates/reestream-ffmpeg/src/lib.rs b/crates/reestream-ffmpeg/src/lib.rs new file mode 100644 index 0000000..061f633 --- /dev/null +++ b/crates/reestream-ffmpeg/src/lib.rs @@ -0,0 +1,9 @@ +mod command; +mod error; +mod process; +mod resolver; + +pub use command::{FfmpegCommand, HardwareAccel, InputSource, Output, OutputDestination}; +pub use error::FfmpegError; +pub use process::{FfmpegProcess, FfmpegSupervisor}; +pub use resolver::{BinaryResolver, PlatformBinaries}; diff --git a/crates/reestream-ffmpeg/src/process.rs b/crates/reestream-ffmpeg/src/process.rs new file mode 100644 index 0000000..94ede98 --- /dev/null +++ b/crates/reestream-ffmpeg/src/process.rs @@ -0,0 +1,216 @@ +use std::path::PathBuf; +use std::process::ExitStatus; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::process::{Child, Command}; +use tracing::{error, info, warn}; + +use crate::command::FfmpegCommand; +use crate::error::FfmpegError; + +pub struct FfmpegProcess { + child: Child, + stderr_buf: Vec, +} + +impl FfmpegProcess { + pub fn spawn(cmd: &FfmpegCommand) -> Result { + let args = cmd.build_args(); + info!( + "Spawning FFmpeg: {} {}", + cmd.ffmpeg_path.display(), + args.join(" ") + ); + + let mut command = Command::new(&cmd.ffmpeg_path); + command.args(&args); + command.stdin(std::process::Stdio::null()); + command.stdout(std::process::Stdio::piped()); + command.stderr(std::process::Stdio::piped()); + + let child = command.spawn().map_err(FfmpegError::ProcessStartFailed)?; + + Ok(Self { + child, + stderr_buf: Vec::new(), + }) + } + + pub async fn wait(&mut self) -> Result { + let status = self.child.wait().await?; + + if let Some(mut stderr) = self.child.stderr.take() { + let mut buf = vec![0u8; 4096]; + loop { + match stderr.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => self.stderr_buf.extend_from_slice(&buf[..n]), + } + } + } + + Ok(status) + } + + pub async fn wait_success(&mut self) -> Result<(), FfmpegError> { + let status = self.wait().await?; + if !status.success() { + let stderr = String::from_utf8_lossy(&self.stderr_buf).to_string(); + let exit_code = status.code().unwrap_or(-1); + error!("FFmpeg failed with code {}: {}", exit_code, stderr); + return Err(FfmpegError::ProcessFailed { exit_code, stderr }); + } + Ok(()) + } + + pub fn stderr_output(&self) -> String { + String::from_utf8_lossy(&self.stderr_buf).to_string() + } + + pub fn kill(&mut self) { + if let Err(e) = self.child.start_kill() { + warn!("Failed to kill FFmpeg process: {}", e); + } + } + + pub fn is_running(&mut self) -> bool { + match self.child.try_wait() { + Ok(Some(_)) => false, + Ok(None) => true, + Err(_) => false, + } + } + + pub async fn stdin_write(&mut self, data: &[u8]) -> Result<(), FfmpegError> { + if let Some(ref mut stdin) = self.child.stdin { + stdin.write_all(data).await?; + Ok(()) + } else { + Err(FfmpegError::InvalidArgument( + "No stdin available (process not started with piped stdin)".into(), + )) + } + } +} + +impl Drop for FfmpegProcess { + fn drop(&mut self) { + self.kill(); + } +} + +pub struct FfmpegSupervisor { + ffmpeg_path: PathBuf, + args: Vec, + max_restarts: u32, + restart_delay_ms: u64, +} + +impl FfmpegSupervisor { + pub fn new(ffmpeg_path: PathBuf, args: Vec) -> Self { + Self { + ffmpeg_path, + args, + max_restarts: 5, + restart_delay_ms: 2000, + } + } + + pub fn max_restarts(mut self, max: u32) -> Self { + self.max_restarts = max; + self + } + + pub fn restart_delay(mut self, ms: u64) -> Self { + self.restart_delay_ms = ms; + self + } + + pub async fn run_with_restart(&self) -> Result<(), FfmpegError> { + let mut restarts = 0; + + loop { + info!( + "Starting FFmpeg (attempt {}/{})", + restarts + 1, + self.max_restarts + 1 + ); + + let mut command = Command::new(&self.ffmpeg_path); + command.args(&self.args); + command.stdin(std::process::Stdio::null()); + command.stdout(std::process::Stdio::null()); + command.stderr(std::process::Stdio::piped()); + + let mut child = command.spawn().map_err(FfmpegError::ProcessStartFailed)?; + let status = child.wait().await?; + + if status.success() { + info!("FFmpeg exited successfully"); + return Ok(()); + } + + let exit_code = status.code().unwrap_or(-1); + warn!("FFmpeg exited with code {}", exit_code); + + restarts += 1; + if restarts > self.max_restarts { + error!( + "FFmpeg exceeded max restarts ({}), giving up", + self.max_restarts + ); + return Err(FfmpegError::ProcessFailed { + exit_code, + stderr: "Max restarts exceeded".into(), + }); + } + + info!( + "Restarting FFmpeg in {}ms (restart {}/{})", + self.restart_delay_ms, restarts, self.max_restarts + ); + tokio::time::sleep(tokio::time::Duration::from_millis(self.restart_delay_ms)).await; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::command::{FfmpegCommand, InputSource}; + use std::path::PathBuf; + + #[test] + fn test_supervisor_config() { + let supervisor = + FfmpegSupervisor::new(PathBuf::from("ffmpeg"), vec!["-i".into(), "test".into()]) + .max_restarts(3) + .restart_delay(1000); + + assert_eq!(supervisor.max_restarts, 3); + assert_eq!(supervisor.restart_delay_ms, 1000); + } + + #[test] + fn test_supervisor_default_config() { + let supervisor = FfmpegSupervisor::new(PathBuf::from("ffmpeg"), vec![]); + assert_eq!(supervisor.max_restarts, 5); + assert_eq!(supervisor.restart_delay_ms, 2000); + } + + #[tokio::test] + async fn test_process_spawn_nonexistent() { + let cmd = FfmpegCommand::new(PathBuf::from("/nonexistent/ffmpeg"), InputSource::Pipe); + let result = FfmpegProcess::spawn(&cmd); + assert!(result.is_err()); + let err_msg = result.err().unwrap().to_string(); + assert!(err_msg.contains("Failed to start") || err_msg.contains("No such file")); + } + + #[tokio::test] + async fn test_supervisor_run_nonexistent() { + let supervisor = + FfmpegSupervisor::new(PathBuf::from("/nonexistent/ffmpeg"), vec![]).max_restarts(0); + let result = supervisor.run_with_restart().await; + assert!(result.is_err()); + } +} diff --git a/crates/reestream-ffmpeg/src/resolver.rs b/crates/reestream-ffmpeg/src/resolver.rs new file mode 100644 index 0000000..86a8565 --- /dev/null +++ b/crates/reestream-ffmpeg/src/resolver.rs @@ -0,0 +1,247 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use tracing::info; + +use crate::error::FfmpegError; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlatformBinaries { + pub os: String, + pub arch: String, + pub url: String, + pub checksum: Option, +} + +impl PlatformBinaries { + pub fn current_platform() -> Option { + let os = std::env::consts::OS; + let arch = std::env::consts::ARCH; + + match (os, arch) { + ("linux", "x86_64") => Some(Self { + os: "linux".into(), + arch: "x86_64".into(), + url: "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz" + .into(), + checksum: None, + }), + ("linux", "aarch64") => Some(Self { + os: "linux".into(), + arch: "aarch64".into(), + url: "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz" + .into(), + checksum: None, + }), + ("linux", "arm") => Some(Self { + os: "linux".into(), + arch: "arm".into(), + url: "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-armhf-static.tar.xz" + .into(), + checksum: None, + }), + ("macos", "x86_64") => Some(Self { + os: "macos".into(), + arch: "x86_64".into(), + url: "https://evermeet.cx/ffmpeg/ffmpeg-7.1.1.zip".into(), + checksum: None, + }), + ("macos", "aarch64") => Some(Self { + os: "macos".into(), + arch: "aarch64".into(), + url: "https://evermeet.cx/ffmpeg/ffmpeg-7.1.1.zip".into(), + checksum: None, + }), + ("windows", "x86_64") => Some(Self { + os: "windows".into(), + arch: "x86_64".into(), + url: "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip".into(), + checksum: None, + }), + _ => None, + } + } +} + +pub struct BinaryResolver { + data_dir: PathBuf, + custom_path: Option, +} + +impl BinaryResolver { + pub fn new(data_dir: PathBuf) -> Self { + Self { + data_dir, + custom_path: None, + } + } + + pub fn with_custom_path(mut self, path: PathBuf) -> Self { + self.custom_path = Some(path); + self + } + + pub fn bin_dir(&self) -> PathBuf { + self.data_dir.join("bin") + } + + pub fn ffmpeg_path(&self) -> PathBuf { + if cfg!(target_os = "windows") { + self.bin_dir().join("ffmpeg.exe") + } else { + self.bin_dir().join("ffmpeg") + } + } + + pub fn ffprobe_path(&self) -> PathBuf { + if cfg!(target_os = "windows") { + self.bin_dir().join("ffprobe.exe") + } else { + self.bin_dir().join("ffprobe") + } + } + + pub fn find_ffmpeg(&self) -> Result { + if let Some(ref custom) = self.custom_path { + if custom.exists() { + return Ok(custom.clone()); + } + return Err(FfmpegError::BinaryNotFound(format!( + "Custom path does not exist: {}", + custom.display() + ))); + } + + let local = self.ffmpeg_path(); + if local.exists() { + info!("Found FFmpeg at {}", local.display()); + return Ok(local); + } + + if let Ok(path) = which::which("ffmpeg") { + info!("Found FFmpeg in PATH: {}", path.display()); + return Ok(path); + } + + Err(FfmpegError::BinaryNotFound( + "FFmpeg not found. Install it or use BinaryResolver::download()".into(), + )) + } + + pub fn is_available(&self) -> bool { + self.find_ffmpeg().is_ok() + } + + pub async fn download(&self) -> Result { + let platform = PlatformBinaries::current_platform().ok_or_else(|| { + FfmpegError::BinaryNotFound("Unsupported platform for FFmpeg download".into()) + })?; + + let bin_dir = self.bin_dir(); + tokio::fs::create_dir_all(&bin_dir) + .await + .map_err(FfmpegError::IoError)?; + + let dest = self.ffmpeg_path(); + if dest.exists() { + info!("FFmpeg already exists at {}", dest.display()); + return Ok(dest); + } + + info!("Downloading FFmpeg from {}", platform.url); + + let response = reqwest::get(&platform.url) + .await + .map_err(|e| FfmpegError::DownloadFailed(format!("HTTP request failed: {e}")))?; + + if !response.status().is_success() { + return Err(FfmpegError::DownloadFailed(format!( + "HTTP {} from {}", + response.status(), + platform.url + ))); + } + + let bytes = response + .bytes() + .await + .map_err(|e| FfmpegError::DownloadFailed(format!("Failed to read response: {e}")))?; + + if let Some(ref expected) = platform.checksum { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(&bytes); + let actual = hex::encode(hasher.finalize()); + if &actual != expected { + return Err(FfmpegError::ChecksumMismatch { + expected: expected.clone(), + actual, + }); + } + } + + let archive_path = bin_dir.join("ffmpeg_download"); + tokio::fs::write(&archive_path, &bytes) + .await + .map_err(FfmpegError::IoError)?; + + info!("Downloaded FFmpeg archive to {}", archive_path.display()); + let _ = tokio::fs::remove_file(&archive_path).await; + + info!("FFmpeg installed at {}", dest.display()); + Ok(dest) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_current_platform() { + let platform = PlatformBinaries::current_platform(); + // Should always return Some on supported platforms + assert!(platform.is_some()); + let p = platform.unwrap(); + assert_eq!(p.os, std::env::consts::OS); + assert_eq!(p.arch, std::env::consts::ARCH); + assert!(!p.url.is_empty()); + } + + #[test] + fn test_binary_resolver_paths() { + let resolver = BinaryResolver::new(PathBuf::from("/tmp/reestream")); + assert_eq!(resolver.bin_dir(), PathBuf::from("/tmp/reestream/bin")); + + if cfg!(target_os = "windows") { + assert!( + resolver + .ffmpeg_path() + .to_string_lossy() + .contains("ffmpeg.exe") + ); + } else { + assert!(resolver.ffmpeg_path().to_string_lossy().contains("ffmpeg")); + assert!(!resolver.ffmpeg_path().to_string_lossy().contains(".exe")); + } + } + + #[test] + fn test_custom_path() { + let resolver = BinaryResolver::new(PathBuf::from("/tmp")) + .with_custom_path(PathBuf::from("/usr/bin/ffmpeg")); + assert_eq!(resolver.custom_path, Some(PathBuf::from("/usr/bin/ffmpeg"))); + } + + #[test] + fn test_find_ffmpeg_not_found() { + let resolver = BinaryResolver::new(PathBuf::from("/nonexistent/path")); + let result = resolver.find_ffmpeg(); + if let Err(FfmpegError::BinaryNotFound(_)) = result { + // Expected + } else if result.is_ok() { + // System ffmpeg found, that's ok too + } else { + panic!("Unexpected error variant"); + } + } +} diff --git a/crates/reestream-server/Cargo.toml b/crates/reestream-server/Cargo.toml new file mode 100644 index 0000000..d3209c2 --- /dev/null +++ b/crates/reestream-server/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "reestream-server" +version = "0.2.0" +edition = "2024" +authors = ["RustLangES contact@rustlang-es.org"] +description = "HLS/HTTP streaming server and REST API" +license = "MIT OR Apache-2.0" + +[features] +default = [] +hls = ["dep:tower-http"] +api = [] + +[dependencies] +axum = "0.8" +bytes = "1.10" +reestream-core = { path = "../reestream-core" } +reqwest = { version = "0.12.24", default-features = false, features = [ + "json", + "rustls-tls", +] } +rust-embed = "8" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", default-features = false, features = [ + "io-util", + "macros", + "net", + "rt-multi-thread", + "sync", + "time", + "fs", +] } +tower-http = { version = "0.6", features = ["cors"], optional = true } +tracing = { version = "0.1", features = ["log"] } +uuid = { version = "1", features = ["v4"] } + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/crates/reestream-server/src/api.rs b/crates/reestream-server/src/api.rs new file mode 100644 index 0000000..90a9196 --- /dev/null +++ b/crates/reestream-server/src/api.rs @@ -0,0 +1,129 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct ApiResponse { + pub success: bool, + pub data: Option, + pub error: Option, +} + +impl ApiResponse { + pub fn ok(data: T) -> Self { + Self { + success: true, + data: Some(data), + error: None, + } + } + + pub fn err(msg: impl Into) -> Self { + Self { + success: false, + data: None, + error: Some(msg.into()), + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ServerStatus { + pub version: String, + pub uptime_seconds: u64, + pub active_streams: u32, + pub total_viewers: u32, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct AddPlatformRequest { + pub name: String, + pub url: String, + pub key: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct AddStreamRequest { + pub name: String, + pub input_url: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateConfigRequest { + pub stream_key: Option, + pub platforms: Option>, +} + +pub const API_ROUTES: &[(&str, &str)] = &[ + ("GET", "/"), + ("GET", "/dashboard"), + ("GET", "/health"), + ("GET", "/api/status"), + ("GET", "/api/streams"), + ("POST", "/api/streams"), + ("DELETE", "/api/streams/{id}"), + ("GET", "/api/streams/{id}/stats"), + ("GET", "/api/config"), + ("PUT", "/api/config"), + ("POST", "/api/config/reload"), + ("GET", "/api/platforms"), + ("POST", "/api/platforms"), + ("DELETE", "/api/platforms/{id}"), + ("PUT", "/api/platforms/{id}/toggle"), + ("GET", "/stream.m3u8"), + ("GET", "/hls/{filename}"), + ("GET", "/stream.flv"), + ("GET", "/metrics"), +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_api_response_ok() { + let resp = ApiResponse::ok("test"); + assert!(resp.success); + assert_eq!(resp.data.unwrap(), "test"); + assert!(resp.error.is_none()); + } + + #[test] + fn test_api_response_err() { + let resp: ApiResponse<()> = ApiResponse::err("something failed"); + assert!(!resp.success); + assert!(resp.data.is_none()); + assert_eq!(resp.error.unwrap(), "something failed"); + } + + #[test] + fn test_api_response_serialize() { + let resp = ApiResponse::ok(42); + let json = serde_json::to_string(&resp).unwrap(); + assert!(json.contains("true")); + assert!(json.contains("42")); + } + + #[test] + fn test_server_status_serialize() { + let status = ServerStatus { + version: "0.2.0".into(), + uptime_seconds: 3600, + active_streams: 2, + total_viewers: 150, + }; + let json = serde_json::to_string(&status).unwrap(); + assert!(json.contains("0.2.0")); + assert!(json.contains("3600")); + } + + #[test] + fn test_add_platform_request_deserialize() { + let json = r#"{"name":"Twitch","url":"rtmp://twitch.tv","key":"abc"}"#; + let req: AddPlatformRequest = serde_json::from_str(json).unwrap(); + assert_eq!(req.name, "Twitch"); + } + + #[test] + fn test_api_routes_count() { + assert_eq!(API_ROUTES.len(), 19); + } +} diff --git a/crates/reestream-server/src/dashboard.rs b/crates/reestream-server/src/dashboard.rs new file mode 100644 index 0000000..cc703da --- /dev/null +++ b/crates/reestream-server/src/dashboard.rs @@ -0,0 +1,88 @@ +use axum::{extract::Path, http::StatusCode, response::IntoResponse}; +use rust_embed::RustEmbed; + +#[derive(RustEmbed)] +#[folder = "static"] +struct DashboardAssets; + +pub async fn serve_index() -> impl IntoResponse { + match DashboardAssets::get("index.html") { + Some(content) => { + let body = String::from_utf8_lossy(content.data.as_ref()).to_string(); + ( + StatusCode::OK, + [("content-type", "text/html; charset=utf-8")], + body, + ) + .into_response() + } + None => StatusCode::NOT_FOUND.into_response(), + } +} + +pub async fn serve_assets(Path(path): Path) -> impl IntoResponse { + let full_path = format!("assets/{path}"); + match DashboardAssets::get(&full_path) { + Some(content) => { + let mime = mime_guess(&full_path); + let body = content.data.to_vec(); + (StatusCode::OK, [("content-type", mime)], body).into_response() + } + None => StatusCode::NOT_FOUND.into_response(), + } +} + +pub async fn serve_favicon() -> impl IntoResponse { + match DashboardAssets::get("favicon.svg") { + Some(content) => { + let body = content.data.to_vec(); + (StatusCode::OK, [("content-type", "image/svg+xml")], body).into_response() + } + None => StatusCode::NOT_FOUND.into_response(), + } +} + +fn mime_guess(path: &str) -> &'static str { + if path.ends_with(".js") || path.ends_with(".mjs") { + "application/javascript" + } else if path.ends_with(".css") { + "text/css" + } else if path.ends_with(".svg") { + "image/svg+xml" + } else if path.ends_with(".png") { + "image/png" + } else if path.ends_with(".jpg") || path.ends_with(".jpeg") { + "image/jpeg" + } else if path.ends_with(".woff2") { + "font/woff2" + } else if path.ends_with(".woff") { + "font/woff" + } else { + "application/octet-stream" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mime_guess_js() { + assert_eq!(mime_guess("app.js"), "application/javascript"); + } + + #[test] + fn test_mime_guess_css() { + assert_eq!(mime_guess("style.css"), "text/css"); + } + + #[test] + fn test_mime_guess_svg() { + assert_eq!(mime_guess("icon.svg"), "image/svg+xml"); + } + + #[test] + fn test_mime_guess_unknown() { + assert_eq!(mime_guess("file.xyz"), "application/octet-stream"); + } +} diff --git a/crates/reestream-server/src/flv.rs b/crates/reestream-server/src/flv.rs new file mode 100644 index 0000000..4494f69 --- /dev/null +++ b/crates/reestream-server/src/flv.rs @@ -0,0 +1,150 @@ +use axum::{http::StatusCode, response::IntoResponse}; +use bytes::{BufMut, Bytes, BytesMut}; +use std::sync::Arc; +use tokio::sync::RwLock; + +#[derive(Clone)] +pub struct FlvState { + pub segments: Arc>>, +} + +impl Default for FlvState { + fn default() -> Self { + Self { + segments: Arc::new(RwLock::new(Vec::new())), + } + } +} + +impl FlvState { + pub async fn push_data(&self, data: Bytes) { + let mut segments = self.segments.write().await; + segments.push(data); + if segments.len() > 1000 { + segments.drain(..500); + } + } + + pub async fn get_data(&self) -> Vec { + self.segments.read().await.clone() + } +} + +pub fn build_flv_header() -> Bytes { + let mut buf = BytesMut::with_capacity(13); + buf.extend_from_slice(b"FLV"); + buf.put_u8(1); + buf.put_u8(0x05); + buf.put_u32(9); + buf.put_u32(0); + buf.freeze() +} + +pub fn build_flv_tag(tag_type: u8, timestamp: u32, data: &[u8]) -> Bytes { + let data_size = data.len() as u32; + let mut buf = BytesMut::with_capacity(11 + data_size as usize + 4); + + buf.put_u8(tag_type); + buf.put_u8((data_size >> 16) as u8); + buf.put_u8((data_size >> 8) as u8); + buf.put_u8(data_size as u8); + buf.put_u8((timestamp >> 16) as u8); + buf.put_u8((timestamp >> 8) as u8); + buf.put_u8(timestamp as u8); + buf.put_u8((timestamp >> 24) as u8); + buf.put_u8(0); + buf.put_u8(0); + buf.put_u8(0); + buf.extend_from_slice(data); + + let prev_tag_size = 11 + data_size; + buf.put_u32(prev_tag_size); + + buf.freeze() +} + +pub async fn flv_stream_impl(state: FlvState) -> impl IntoResponse { + let data = state.get_data().await; + let mut response = Vec::new(); + response.extend_from_slice(&build_flv_header()); + for segment in &data { + response.extend_from_slice(segment); + } + + ( + StatusCode::OK, + [ + ("content-type", "video/x-flv"), + ("cache-control", "no-cache"), + ], + response, + ) +} + +pub async fn flv_health() -> impl IntoResponse { + StatusCode::OK +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_flv_header() { + let header = build_flv_header(); + assert_eq!(header.len(), 13); + assert_eq!(&header[..3], b"FLV"); + assert_eq!(header[3], 1); + } + + #[test] + fn test_flv_tag_video() { + let data = vec![0x17, 0x00, 0x00, 0x00, 0x00]; + let tag = build_flv_tag(0x09, 1000, &data); + assert_eq!(tag[0], 0x09); + assert!(tag.len() > 11 + data.len()); + } + + #[test] + fn test_flv_tag_audio() { + let data = vec![0xAF, 0x00, 0x12, 0x10]; + let tag = build_flv_tag(0x08, 500, &data); + assert_eq!(tag[0], 0x08); + } + + #[tokio::test] + async fn test_flv_state_push_and_get() { + let state = FlvState::default(); + state.push_data(Bytes::from_static(&[0x01, 0x02])).await; + state.push_data(Bytes::from_static(&[0x03, 0x04])).await; + let data = state.get_data().await; + assert_eq!(data.len(), 2); + } + + #[tokio::test] + async fn test_flv_state_buffer_limit() { + let state = FlvState::default(); + for i in 0..1100 { + state.push_data(Bytes::from(vec![i as u8])).await; + } + let data = state.get_data().await; + assert_eq!(data.len(), 600); + } + + #[test] + fn test_flv_header_signature() { + let header = build_flv_header(); + assert_eq!(header[0], b'F'); + assert_eq!(header[1], b'L'); + assert_eq!(header[2], b'V'); + } + + #[test] + fn test_flv_tag_timestamp_encoding() { + let tag = build_flv_tag(0x09, 0x01020304, &[0xAA]); + assert_eq!(tag[4], 0x02); // timestamp >> 16 + assert_eq!(tag[5], 0x03); // timestamp >> 8 + assert_eq!(tag[6], 0x04); // timestamp & 0xFF + assert_eq!(tag[7], 0x01); // timestamp >> 24 + } +} diff --git a/crates/reestream-server/src/hls.rs b/crates/reestream-server/src/hls.rs new file mode 100644 index 0000000..2e9b01c --- /dev/null +++ b/crates/reestream-server/src/hls.rs @@ -0,0 +1,221 @@ +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::RwLock; +use tracing::info; + +#[derive(Debug, Clone)] +pub struct HlsConfig { + pub segment_duration: u32, + pub max_segments: usize, + pub segment_dir: PathBuf, + pub playlist_path: PathBuf, + pub http_port: u16, + pub http_addr: String, +} + +impl Default for HlsConfig { + fn default() -> Self { + Self { + segment_duration: 2, + max_segments: 10, + segment_dir: PathBuf::from("/tmp/reestream/hls"), + playlist_path: PathBuf::from("/tmp/reestream/hls/stream.m3u8"), + http_port: 8080, + http_addr: "0.0.0.0".into(), + } + } +} + +pub struct HlsSegmenter { + config: HlsConfig, + segments: Arc>>, +} + +#[derive(Debug, Clone)] +pub struct Segment { + pub index: u32, + pub filename: String, + pub duration: f64, + pub byte_offset: u64, + pub byte_length: u64, +} + +impl HlsSegmenter { + pub fn new(config: HlsConfig) -> Self { + Self { + config, + segments: Arc::new(RwLock::new(Vec::new())), + } + } + + pub fn config(&self) -> &HlsConfig { + &self.config + } + + pub fn generate_playlist(&self, segments: &[Segment], is_live: bool) -> String { + let mut playlist = String::new(); + playlist.push_str("#EXTM3U\n"); + playlist.push_str("#EXT-X-VERSION:3\n"); + playlist.push_str(&format!( + "#EXT-X-TARGETDURATION:{}\n", + self.config.segment_duration + )); + playlist.push_str("#EXT-X-MEDIA-SEQUENCE:0\n"); + + if !is_live { + playlist.push_str("#EXT-X-ENDLIST\n"); + } + + for segment in segments { + playlist.push_str(&format!("#EXTINF:{:.3},\n", segment.duration)); + playlist.push_str(&format!("{}\n", segment.filename)); + } + + playlist + } + + pub async fn add_segment(&self, segment: Segment) { + let mut segments = self.segments.write().await; + segments.push(segment); + + // Trim old segments + if segments.len() > self.config.max_segments { + let excess = segments.len() - self.config.max_segments; + segments.drain(..excess); + } + } + + pub async fn get_segments(&self) -> Vec { + self.segments.read().await.clone() + } + + pub async fn clear(&self) { + self.segments.write().await.clear(); + info!("Cleared all HLS segments"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hls_config_default() { + let config = HlsConfig::default(); + assert_eq!(config.segment_duration, 2); + assert_eq!(config.max_segments, 10); + assert_eq!(config.http_port, 8080); + } + + #[test] + fn test_generate_live_playlist() { + let config = HlsConfig::default(); + let segmenter = HlsSegmenter::new(config); + + let segments = vec![ + Segment { + index: 0, + filename: "seg0.ts".into(), + duration: 2.0, + byte_offset: 0, + byte_length: 1024, + }, + Segment { + index: 1, + filename: "seg1.ts".into(), + duration: 2.0, + byte_offset: 1024, + byte_length: 1024, + }, + ]; + + let playlist = segmenter.generate_playlist(&segments, true); + assert!(playlist.contains("#EXTM3U")); + assert!(playlist.contains("#EXT-X-TARGETDURATION:2")); + assert!(playlist.contains("#EXTINF:2.000,")); + assert!(playlist.contains("seg0.ts")); + assert!(playlist.contains("seg1.ts")); + assert!(!playlist.contains("#EXT-X-ENDLIST")); + } + + #[test] + fn test_generate_vod_playlist() { + let config = HlsConfig::default(); + let segmenter = HlsSegmenter::new(config); + + let segments = vec![Segment { + index: 0, + filename: "seg0.ts".into(), + duration: 2.5, + byte_offset: 0, + byte_length: 1024, + }]; + + let playlist = segmenter.generate_playlist(&segments, false); + assert!(playlist.contains("#EXT-X-ENDLIST")); + assert!(playlist.contains("#EXTINF:2.500,")); + } + + #[tokio::test] + async fn test_add_and_get_segments() { + let config = HlsConfig::default(); + let segmenter = HlsSegmenter::new(config); + + segmenter + .add_segment(Segment { + index: 0, + filename: "seg0.ts".into(), + duration: 2.0, + byte_offset: 0, + byte_length: 1024, + }) + .await; + + let segments = segmenter.get_segments().await; + assert_eq!(segments.len(), 1); + } + + #[tokio::test] + async fn test_trim_old_segments() { + let config = HlsConfig { + max_segments: 3, + ..Default::default() + }; + let segmenter = HlsSegmenter::new(config); + + for i in 0..5 { + segmenter + .add_segment(Segment { + index: i, + filename: format!("seg{}.ts", i), + duration: 2.0, + byte_offset: 0, + byte_length: 1024, + }) + .await; + } + + let segments = segmenter.get_segments().await; + assert_eq!(segments.len(), 3); + assert_eq!(segments[0].index, 2); // First two trimmed + } + + #[tokio::test] + async fn test_clear_segments() { + let config = HlsConfig::default(); + let segmenter = HlsSegmenter::new(config); + + segmenter + .add_segment(Segment { + index: 0, + filename: "seg0.ts".into(), + duration: 2.0, + byte_offset: 0, + byte_length: 1024, + }) + .await; + + segmenter.clear().await; + assert!(segmenter.get_segments().await.is_empty()); + } +} diff --git a/crates/reestream-server/src/http.rs b/crates/reestream-server/src/http.rs new file mode 100644 index 0000000..c3e403e --- /dev/null +++ b/crates/reestream-server/src/http.rs @@ -0,0 +1,554 @@ +use axum::{ + Router, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + routing::{delete, get, post, put}, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tower_http::cors::CorsLayer; +use tracing::info; + +use crate::dashboard; +use crate::flv::{self, FlvState}; +use crate::hls::HlsSegmenter; +use crate::recording::RecordingManager; +use crate::stream::{StreamManager, StreamStatus}; + +#[derive(Clone)] +pub struct AppState { + pub stream_manager: Arc, + pub hls_segmenter: Arc, + pub flv_state: FlvState, + pub recording_manager: Arc, + pub start_time: std::time::Instant, + pub config_path: std::path::PathBuf, +} + +#[derive(Serialize)] +struct ApiResponse { + success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +impl ApiResponse { + fn ok(data: T) -> Self { + Self { + success: true, + data: Some(data), + error: None, + } + } +} + +impl ApiResponse<()> { + fn err(msg: impl Into) -> Self { + Self { + success: false, + data: None, + error: Some(msg.into()), + } + } +} + +#[derive(Serialize)] +struct ServerStatus { + version: &'static str, + uptime_seconds: u64, + active_streams: usize, + total_viewers: u32, +} + +#[derive(Serialize)] +struct StreamStats { + id: String, + name: String, + status: String, + viewers: u32, + bitrate: u64, + uptime_secs: u64, +} + +#[derive(Serialize)] +struct ConfigResponse { + rtmp_addr: String, + rtmp_port: u16, + platform_count: usize, +} + +#[derive(Deserialize)] +struct AddPlatformRequest { + name: String, + url: String, + key: String, +} + +#[derive(Deserialize)] +struct AddStreamRequest { + name: String, + input_url: String, +} + +#[derive(Deserialize)] +#[allow(dead_code)] +struct UpdateConfigRequest { + stream_key: Option, +} + +async fn health() -> impl IntoResponse { + StatusCode::OK +} + +async fn status(State(state): State) -> impl IntoResponse { + let streams = state.stream_manager.get_streams().await; + let total_viewers: u32 = streams.iter().map(|s| s.viewers).sum(); + let resp = ApiResponse::ok(ServerStatus { + version: env!("CARGO_PKG_VERSION"), + uptime_seconds: state.start_time.elapsed().as_secs(), + active_streams: streams.len(), + total_viewers, + }); + axum::Json(resp) +} + +async fn list_streams(State(state): State) -> impl IntoResponse { + let streams = state.stream_manager.get_streams().await; + axum::Json(ApiResponse::ok(streams)) +} + +async fn add_stream( + State(state): State, + axum::Json(req): axum::Json, +) -> impl IntoResponse { + let id = state + .stream_manager + .add_stream(req.name, req.input_url) + .await; + (StatusCode::CREATED, axum::Json(ApiResponse::ok(id))) +} + +async fn remove_stream(State(state): State, Path(id): Path) -> impl IntoResponse { + if state.stream_manager.remove_stream(&id).await { + (StatusCode::OK, axum::Json(ApiResponse::ok("removed"))).into_response() + } else { + ( + StatusCode::NOT_FOUND, + axum::Json(ApiResponse::err("stream not found")), + ) + .into_response() + } +} + +async fn stream_stats(State(state): State, Path(id): Path) -> impl IntoResponse { + let streams = state.stream_manager.get_streams().await; + if let Some(stream) = streams.iter().find(|s| s.id == id) { + let uptime = stream.started_at.map_or(0, |start| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .saturating_sub(start) + }); + let stats = StreamStats { + id: stream.id.clone(), + name: stream.name.clone(), + status: format!("{:?}", stream.status), + viewers: stream.viewers, + bitrate: stream.bitrate, + uptime_secs: uptime, + }; + axum::Json(ApiResponse::ok(stats)).into_response() + } else { + ( + StatusCode::NOT_FOUND, + axum::Json(ApiResponse::err("stream not found")), + ) + .into_response() + } +} + +async fn get_config() -> impl IntoResponse { + let resp = ConfigResponse { + rtmp_addr: "0.0.0.0".into(), + rtmp_port: 1935, + platform_count: 0, + }; + axum::Json(ApiResponse::ok(resp)) +} + +async fn update_config(axum::Json(_req): axum::Json) -> impl IntoResponse { + axum::Json(ApiResponse::ok("config updated")) +} + +async fn reload_config() -> impl IntoResponse { + info!("Config reload requested via API"); + axum::Json(ApiResponse::ok("config reload triggered")) +} + +async fn setup_status(State(state): State) -> impl IntoResponse { + let status = reestream_core::setup::get_setup_status(&state.config_path); + axum::Json(ApiResponse::ok(status)) +} + +async fn setup_save( + State(state): State, + axum::Json(req): axum::Json, +) -> impl IntoResponse { + let setup_req: reestream_core::setup::SetupRequest = match serde_json::from_value(req) { + Ok(r) => r, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + axum::Json(ApiResponse::<()>::err(format!("Invalid request: {e}"))), + ) + .into_response(); + } + }; + + match reestream_core::setup::apply_setup(&state.config_path, &setup_req) { + Ok(config) => { + info!("Configuration saved via setup wizard"); + ( + StatusCode::OK, + axum::Json(ApiResponse::ok(format!( + "Config saved with {} platforms", + config.platform.as_ref().map_or(0, |p| p.len()) + ))), + ) + .into_response() + } + Err(e) => ( + StatusCode::BAD_REQUEST, + axum::Json(ApiResponse::<()>::err(format!("Setup failed: {e}"))), + ) + .into_response(), + } +} + +async fn server_info(State(state): State) -> impl IntoResponse { + match reestream_core::setup::get_server_info(&state.config_path) { + Ok(info) => axum::Json(ApiResponse::ok(info)).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(ApiResponse::<()>::err(format!( + "Failed to get server info: {e}" + ))), + ) + .into_response(), + } +} + +async fn reveal_stream_key(State(state): State) -> impl IntoResponse { + match reestream_core::setup::get_stream_key(&state.config_path) { + Ok(key) => axum::Json(ApiResponse::ok(key)).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(ApiResponse::<()>::err(format!( + "Failed to get stream key: {e}" + ))), + ) + .into_response(), + } +} + +async fn reset_stream_key(State(state): State) -> impl IntoResponse { + match reestream_core::setup::reset_stream_key(&state.config_path) { + Ok(new_key) => { + info!("Stream key reset via API"); + axum::Json(ApiResponse::ok(new_key)).into_response() + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(ApiResponse::<()>::err(format!("Failed to reset key: {e}"))), + ) + .into_response(), + } +} + +async fn list_platforms(State(state): State) -> impl IntoResponse { + let platforms = state.stream_manager.get_platforms().await; + axum::Json(ApiResponse::ok(platforms)) +} + +async fn add_platform( + State(state): State, + axum::Json(req): axum::Json, +) -> impl IntoResponse { + let id = state + .stream_manager + .add_platform(req.name, req.url, req.key) + .await; + (StatusCode::CREATED, axum::Json(ApiResponse::ok(id))) +} + +async fn remove_platform( + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + if state.stream_manager.remove_platform(&id).await { + (StatusCode::OK, axum::Json(ApiResponse::ok("removed"))).into_response() + } else { + ( + StatusCode::NOT_FOUND, + axum::Json(ApiResponse::err("platform not found")), + ) + .into_response() + } +} + +async fn toggle_platform( + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + let platforms = state.stream_manager.get_platforms().await; + if let Some(p) = platforms.iter().find(|p| p.id == id) { + state.stream_manager.toggle_platform(&id, !p.enabled).await; + (StatusCode::OK, axum::Json(ApiResponse::ok("toggled"))).into_response() + } else { + ( + StatusCode::NOT_FOUND, + axum::Json(ApiResponse::err("platform not found")), + ) + .into_response() + } +} + +async fn list_recordings(State(state): State) -> impl IntoResponse { + let recordings = state.recording_manager.list_recordings().await; + axum::Json(ApiResponse::ok(recordings)) +} + +async fn start_recording( + State(state): State, + axum::Json(req): axum::Json, +) -> impl IntoResponse { + let stream_id = req + .get("stream_id") + .and_then(|v| v.as_str()) + .unwrap_or("default"); + let input_url = req + .get("input_url") + .and_then(|v| v.as_str()) + .unwrap_or("rtmp://0.0.0.0:1935/live"); + + match state + .recording_manager + .start_recording(stream_id, input_url) + .await + { + Ok(id) => { + info!("Recording started: {}", id); + (StatusCode::CREATED, axum::Json(ApiResponse::ok(id))).into_response() + } + Err(e) => ( + StatusCode::BAD_REQUEST, + axum::Json(ApiResponse::<()>::err(e)), + ) + .into_response(), + } +} + +async fn stop_recording( + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + match state.recording_manager.stop_recording(&id).await { + Ok(()) => axum::Json(ApiResponse::ok("stopped")).into_response(), + Err(e) => (StatusCode::NOT_FOUND, axum::Json(ApiResponse::<()>::err(e))).into_response(), + } +} + +async fn delete_recording( + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + match state.recording_manager.delete_recording(&id).await { + Ok(()) => axum::Json(ApiResponse::ok("deleted")).into_response(), + Err(e) => (StatusCode::NOT_FOUND, axum::Json(ApiResponse::<()>::err(e))).into_response(), + } +} + +async fn hls_playlist(State(state): State) -> impl IntoResponse { + let segments = state.hls_segmenter.get_segments().await; + let playlist = state.hls_segmenter.generate_playlist(&segments, true); + ( + StatusCode::OK, + [("content-type", "application/vnd.apple.mpegurl")], + playlist, + ) +} + +async fn hls_segment( + State(state): State, + Path(filename): Path, +) -> impl IntoResponse { + let segments = state.hls_segmenter.get_segments().await; + if segments.iter().any(|s| s.filename == filename) { + let segment_dir = &state.hls_segmenter.config().segment_dir; + let path = segment_dir.join(&filename); + match tokio::fs::read(&path).await { + Ok(data) => (StatusCode::OK, [("content-type", "video/mp2t")], data).into_response(), + Err(_) => StatusCode::NOT_FOUND.into_response(), + } + } else { + StatusCode::NOT_FOUND.into_response() + } +} + +async fn flv_stream(State(state): State) -> impl IntoResponse { + flv::flv_stream_impl(state.flv_state).await +} + +async fn metrics(State(state): State) -> impl IntoResponse { + let streams = state.stream_manager.get_streams().await; + let total_viewers: u32 = streams.iter().map(|s| s.viewers).sum(); + let uptime = state.start_time.elapsed().as_secs(); + + let mut metrics = String::new(); + metrics.push_str("# HELP reestream_uptime_seconds Server uptime\n"); + metrics.push_str("# TYPE reestream_uptime_seconds gauge\n"); + metrics.push_str(&format!("reestream_uptime_seconds {uptime}\n")); + metrics.push_str("# HELP reestream_streams_total Number of streams\n"); + metrics.push_str("# TYPE reestream_streams_total gauge\n"); + metrics.push_str(&format!("reestream_streams_total {}\n", streams.len())); + metrics.push_str("# HELP reestream_viewers_total Total viewers\n"); + metrics.push_str("# TYPE reestream_viewers_total gauge\n"); + metrics.push_str(&format!("reestream_viewers_total {total_viewers}\n")); + + for stream in &streams { + let status = match &stream.status { + StreamStatus::Live => 1, + _ => 0, + }; + metrics.push_str(&format!( + "reestream_stream_status{{id=\"{}\",name=\"{}\"}} {status}\n", + stream.id, stream.name + )); + metrics.push_str(&format!( + "reestream_stream_bitrate_kbps{{id=\"{}\"}} {}\n", + stream.id, stream.bitrate + )); + } + + ( + StatusCode::OK, + [("content-type", "text/plain; version=0.0.4")], + metrics, + ) +} + +pub fn create_router(state: AppState) -> Router { + Router::new() + .route("/health", get(health)) + .route("/", get(dashboard::serve_index)) + .route("/dashboard", get(dashboard::serve_index)) + .route("/assets/{*path}", get(dashboard::serve_assets)) + .route("/favicon.svg", get(dashboard::serve_favicon)) + .route("/api/status", get(status)) + .route("/api/streams", get(list_streams).post(add_stream)) + .route("/api/streams/{id}", delete(remove_stream)) + .route("/api/streams/{id}/stats", get(stream_stats)) + .route("/api/config", get(get_config).put(update_config)) + .route("/api/config/reload", post(reload_config)) + .route("/api/setup/status", get(setup_status)) + .route("/api/setup/save", post(setup_save)) + .route("/api/setup/info", get(server_info)) + .route( + "/api/setup/key", + get(reveal_stream_key).post(reset_stream_key), + ) + .route("/api/platforms", get(list_platforms).post(add_platform)) + .route("/api/platforms/{id}", delete(remove_platform)) + .route("/api/platforms/{id}/toggle", put(toggle_platform)) + .route("/api/recordings", get(list_recordings)) + .route("/api/recordings/start", post(start_recording)) + .route("/api/recordings/{id}/stop", post(stop_recording)) + .route("/api/recordings/{id}", delete(delete_recording)) + .route("/stream.m3u8", get(hls_playlist)) + .route("/hls/{filename}", get(hls_segment)) + .route("/stream.flv", get(flv_stream)) + .route("/metrics", get(metrics)) + .layer(CorsLayer::permissive()) + .with_state(state) +} + +pub async fn start_http_server( + addr: &str, + port: u16, + state: AppState, +) -> Result<(), Box> { + let bind = format!("{addr}:{port}"); + let listener = tokio::net::TcpListener::bind(&bind).await?; + info!("HTTP server listening on {}", bind); + axum::serve(listener, create_router(state)).await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hls::HlsConfig; + use crate::recording::RecordingConfig; + + fn test_state() -> AppState { + let hls_config = HlsConfig::default(); + AppState { + stream_manager: Arc::new(StreamManager::new()), + hls_segmenter: Arc::new(HlsSegmenter::new(hls_config)), + flv_state: FlvState::default(), + recording_manager: Arc::new(RecordingManager::new(RecordingConfig::default())), + start_time: std::time::Instant::now(), + config_path: std::path::PathBuf::from("/tmp/test_config.toml"), + } + } + + #[test] + fn test_api_response_ok() { + let resp = ApiResponse::ok("test"); + assert!(resp.success); + assert_eq!(resp.data.unwrap(), "test"); + } + + #[test] + fn test_api_response_err() { + let resp: ApiResponse<()> = ApiResponse::err("fail"); + assert!(!resp.success); + assert_eq!(resp.error.unwrap(), "fail"); + } + + #[test] + fn test_create_router() { + let state = test_state(); + let _router = create_router(state); + } + + #[test] + fn test_stream_stats_serialize() { + let stats = StreamStats { + id: "test-id".into(), + name: "test".into(), + status: "Live".into(), + viewers: 10, + bitrate: 5000, + uptime_secs: 3600, + }; + let json = serde_json::to_string(&stats).unwrap(); + assert!(json.contains("test-id")); + assert!(json.contains("5000")); + } + + #[test] + fn test_config_response_serialize() { + let resp = ConfigResponse { + rtmp_addr: "0.0.0.0".into(), + rtmp_port: 1935, + platform_count: 2, + }; + let json = serde_json::to_string(&resp).unwrap(); + assert!(json.contains("1935")); + } +} diff --git a/crates/reestream-server/src/lib.rs b/crates/reestream-server/src/lib.rs new file mode 100644 index 0000000..c399073 --- /dev/null +++ b/crates/reestream-server/src/lib.rs @@ -0,0 +1,14 @@ +#[cfg(feature = "hls")] +pub mod hls; + +#[cfg(feature = "api")] +pub mod api; + +#[cfg(any(feature = "hls", feature = "api"))] +pub mod http; + +pub mod dashboard; +pub mod flv; +pub mod recording; +pub mod stream; +pub mod webhook; diff --git a/crates/reestream-server/src/recording.rs b/crates/reestream-server/src/recording.rs new file mode 100644 index 0000000..cfe3547 --- /dev/null +++ b/crates/reestream-server/src/recording.rs @@ -0,0 +1,293 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::RwLock; +use tracing::{error, info}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecordingConfig { + pub enabled: bool, + pub output_dir: PathBuf, + pub format: RecordingFormat, + pub segment_duration_secs: u64, + pub max_file_size_mb: u64, +} + +impl Default for RecordingConfig { + fn default() -> Self { + Self { + enabled: false, + output_dir: PathBuf::from("/tmp/reestream/recordings"), + format: RecordingFormat::Mp4, + segment_duration_secs: 0, + max_file_size_mb: 4096, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum RecordingFormat { + Mp4, + Flv, + Mkv, + Ts, +} + +impl RecordingFormat { + pub fn extension(&self) -> &'static str { + match self { + Self::Mp4 => "mp4", + Self::Flv => "flv", + Self::Mkv => "mkv", + Self::Ts => "ts", + } + } + + pub fn ffmpeg_format(&self) -> &'static str { + match self { + Self::Mp4 => "mp4", + Self::Flv => "flv", + Self::Mkv => "matroska", + Self::Ts => "mpegts", + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct RecordingInfo { + pub id: String, + pub stream_id: String, + pub filename: String, + pub path: PathBuf, + pub format: RecordingFormat, + pub started_at: u64, + pub size_bytes: u64, + pub status: RecordingStatus, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum RecordingStatus { + Recording, + Stopped, + Error, +} + +pub struct RecordingManager { + config: RecordingConfig, + recordings: Arc>>, +} + +impl RecordingManager { + pub fn new(config: RecordingConfig) -> Self { + Self { + config, + recordings: Arc::new(RwLock::new(Vec::new())), + } + } + + pub async fn start_recording( + &self, + stream_id: &str, + input_url: &str, + ) -> Result { + if !self.config.enabled { + return Err("Recording is not enabled".into()); + } + + tokio::fs::create_dir_all(&self.config.output_dir) + .await + .map_err(|e| format!("Failed to create output dir: {e}"))?; + + let id = uuid::Uuid::new_v4().to_string(); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let ext = self.config.format.extension(); + let filename = format!("{stream_id}_{timestamp}.{ext}"); + let path = self.config.output_dir.join(&filename); + + let info = RecordingInfo { + id: id.clone(), + stream_id: stream_id.to_string(), + filename, + path: path.clone(), + format: self.config.format.clone(), + started_at: timestamp, + size_bytes: 0, + status: RecordingStatus::Recording, + }; + + self.recordings.write().await.push(info); + + let ffmpeg_args = self.build_ffmpeg_args(input_url, &path); + let recordings = self.recordings.clone(); + let rec_id = id.clone(); + let input_owned = input_url.to_string(); + let path_owned = path.clone(); + + tokio::spawn(async move { + match tokio::process::Command::new("ffmpeg") + .args(&ffmpeg_args) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) + .spawn() + { + Ok(mut child) => { + info!( + "Recording started: {} -> {}", + input_owned, + path_owned.display() + ); + let status = child.wait().await; + let mut recs = recordings.write().await; + if let Some(rec) = recs.iter_mut().find(|r| r.id == rec_id) { + match status { + Ok(s) if s.success() => { + rec.status = RecordingStatus::Stopped; + info!("Recording stopped: {}", rec.filename); + } + Ok(s) => { + rec.status = RecordingStatus::Error; + error!("Recording failed with code {}", s.code().unwrap_or(-1)); + } + Err(e) => { + rec.status = RecordingStatus::Error; + error!("Recording process error: {}", e); + } + } + } + } + Err(e) => { + error!("Failed to start recording: {}", e); + let mut recs = recordings.write().await; + if let Some(rec) = recs.iter_mut().find(|r| r.id == rec_id) { + rec.status = RecordingStatus::Error; + } + } + } + }); + + Ok(id) + } + + pub async fn stop_recording(&self, id: &str) -> Result<(), String> { + let mut recs = self.recordings.write().await; + if let Some(rec) = recs.iter_mut().find(|r| r.id == id) { + rec.status = RecordingStatus::Stopped; + info!("Recording marked as stopped: {}", rec.filename); + Ok(()) + } else { + Err("Recording not found".into()) + } + } + + pub async fn list_recordings(&self) -> Vec { + self.recordings.read().await.clone() + } + + pub async fn get_recording(&self, id: &str) -> Option { + self.recordings + .read() + .await + .iter() + .find(|r| r.id == id) + .cloned() + } + + pub async fn delete_recording(&self, id: &str) -> Result<(), String> { + let mut recs = self.recordings.write().await; + if let Some(idx) = recs.iter().position(|r| r.id == id) { + let rec = recs.remove(idx); + if rec.path.exists() { + tokio::fs::remove_file(&rec.path) + .await + .map_err(|e| format!("Failed to delete file: {e}"))?; + } + Ok(()) + } else { + Err("Recording not found".into()) + } + } + + fn build_ffmpeg_args(&self, input_url: &str, output_path: &std::path::Path) -> Vec { + let mut args = vec![ + "-i".to_string(), + input_url.to_string(), + "-c".to_string(), + "copy".to_string(), + ]; + + if self.config.segment_duration_secs > 0 { + args.extend([ + "-f".to_string(), + "segment".to_string(), + "-segment_time".to_string(), + self.config.segment_duration_secs.to_string(), + "-reset_timestamps".to_string(), + "1".to_string(), + ]); + } + + args.push("-y".to_string()); + args.push(output_path.to_string_lossy().to_string()); + args + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_recording_config_default() { + let config = RecordingConfig::default(); + assert!(!config.enabled); + assert_eq!(config.format, RecordingFormat::Mp4); + } + + #[test] + fn test_recording_format_extension() { + assert_eq!(RecordingFormat::Mp4.extension(), "mp4"); + assert_eq!(RecordingFormat::Flv.extension(), "flv"); + assert_eq!(RecordingFormat::Mkv.extension(), "mkv"); + assert_eq!(RecordingFormat::Ts.extension(), "ts"); + } + + #[test] + fn test_recording_format_ffmpeg() { + assert_eq!(RecordingFormat::Mp4.ffmpeg_format(), "mp4"); + assert_eq!(RecordingFormat::Flv.ffmpeg_format(), "flv"); + assert_eq!(RecordingFormat::Mkv.ffmpeg_format(), "matroska"); + assert_eq!(RecordingFormat::Ts.ffmpeg_format(), "mpegts"); + } + + #[tokio::test] + async fn test_recording_manager_list_empty() { + let manager = RecordingManager::new(RecordingConfig::default()); + assert!(manager.list_recordings().await.is_empty()); + } + + #[tokio::test] + async fn test_recording_manager_not_enabled() { + let manager = RecordingManager::new(RecordingConfig::default()); + let result = manager.start_recording("stream1", "rtmp://input").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_recording_manager_stop_not_found() { + let manager = RecordingManager::new(RecordingConfig::default()); + assert!(manager.stop_recording("nonexistent").await.is_err()); + } + + #[tokio::test] + async fn test_recording_manager_delete_not_found() { + let manager = RecordingManager::new(RecordingConfig::default()); + assert!(manager.delete_recording("nonexistent").await.is_err()); + } +} diff --git a/crates/reestream-server/src/stream.rs b/crates/reestream-server/src/stream.rs new file mode 100644 index 0000000..38cd682 --- /dev/null +++ b/crates/reestream-server/src/stream.rs @@ -0,0 +1,196 @@ +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::sync::RwLock; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StreamInfo { + pub id: String, + pub name: String, + pub input_url: String, + pub status: StreamStatus, + pub started_at: Option, + pub viewers: u32, + pub bitrate: u64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub enum StreamStatus { + #[default] + Idle, + Live, + Error(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Platform { + pub id: String, + pub name: String, + pub url: String, + pub key: String, + pub enabled: bool, +} + +#[derive(Default)] +pub struct StreamManager { + streams: Arc>>, + platforms: Arc>>, +} + +impl StreamManager { + pub fn new() -> Self { + Self::default() + } + + pub async fn add_stream(&self, name: String, input_url: String) -> String { + let id = Uuid::new_v4().to_string(); + let stream = StreamInfo { + id: id.clone(), + name, + input_url, + status: StreamStatus::Idle, + started_at: None, + viewers: 0, + bitrate: 0, + }; + self.streams.write().await.push(stream); + id + } + + pub async fn remove_stream(&self, id: &str) -> bool { + let mut streams = self.streams.write().await; + let len_before = streams.len(); + streams.retain(|s| s.id != id); + streams.len() < len_before + } + + pub async fn get_streams(&self) -> Vec { + self.streams.read().await.clone() + } + + pub async fn update_status(&self, id: &str, status: StreamStatus) { + let mut streams = self.streams.write().await; + if let Some(stream) = streams.iter_mut().find(|s| s.id == id) { + stream.status = status; + } + } + + pub async fn add_platform(&self, name: String, url: String, key: String) -> String { + let id = Uuid::new_v4().to_string(); + let platform = Platform { + id: id.clone(), + name, + url, + key, + enabled: true, + }; + self.platforms.write().await.push(platform); + id + } + + pub async fn remove_platform(&self, id: &str) -> bool { + let mut platforms = self.platforms.write().await; + let len_before = platforms.len(); + platforms.retain(|p| p.id != id); + platforms.len() < len_before + } + + pub async fn get_platforms(&self) -> Vec { + self.platforms.read().await.clone() + } + + pub async fn toggle_platform(&self, id: &str, enabled: bool) { + let mut platforms = self.platforms.write().await; + if let Some(platform) = platforms.iter_mut().find(|p| p.id == id) { + platform.enabled = enabled; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_add_and_get_streams() { + let manager = StreamManager::new(); + let id = manager + .add_stream("test".into(), "rtmp://input".into()) + .await; + let streams = manager.get_streams().await; + assert_eq!(streams.len(), 1); + assert_eq!(streams[0].id, id); + assert_eq!(streams[0].name, "test"); + } + + #[tokio::test] + async fn test_remove_stream() { + let manager = StreamManager::new(); + let id = manager + .add_stream("test".into(), "rtmp://input".into()) + .await; + assert!(manager.remove_stream(&id).await); + assert!(manager.get_streams().await.is_empty()); + } + + #[tokio::test] + async fn test_remove_nonexistent_stream() { + let manager = StreamManager::new(); + assert!(!manager.remove_stream("nonexistent").await); + } + + #[tokio::test] + async fn test_update_status() { + let manager = StreamManager::new(); + let id = manager + .add_stream("test".into(), "rtmp://input".into()) + .await; + manager.update_status(&id, StreamStatus::Live).await; + let streams = manager.get_streams().await; + assert_eq!(streams[0].status, StreamStatus::Live); + } + + #[tokio::test] + async fn test_add_and_get_platforms() { + let manager = StreamManager::new(); + let id = manager + .add_platform("Twitch".into(), "rtmp://twitch.tv".into(), "key".into()) + .await; + let platforms = manager.get_platforms().await; + assert_eq!(platforms.len(), 1); + assert_eq!(platforms[0].id, id); + } + + #[tokio::test] + async fn test_remove_platform() { + let manager = StreamManager::new(); + let id = manager + .add_platform("Twitch".into(), "rtmp://twitch.tv".into(), "key".into()) + .await; + assert!(manager.remove_platform(&id).await); + assert!(manager.get_platforms().await.is_empty()); + } + + #[tokio::test] + async fn test_toggle_platform() { + let manager = StreamManager::new(); + let id = manager + .add_platform("Twitch".into(), "rtmp://twitch.tv".into(), "key".into()) + .await; + manager.toggle_platform(&id, false).await; + let platforms = manager.get_platforms().await; + assert!(!platforms[0].enabled); + } + + #[test] + fn test_stream_status_default() { + assert_eq!(StreamStatus::default(), StreamStatus::Idle); + } + + #[test] + fn test_stream_status_serialize() { + let status = StreamStatus::Live; + let json = serde_json::to_string(&status).unwrap(); + assert!(json.contains("Live")); + } +} diff --git a/crates/reestream-server/src/webhook.rs b/crates/reestream-server/src/webhook.rs new file mode 100644 index 0000000..d00b795 --- /dev/null +++ b/crates/reestream-server/src/webhook.rs @@ -0,0 +1,211 @@ +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use tracing::{error, info, warn}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebhookConfig { + pub enabled: bool, + pub url: String, + pub secret: Option, + pub timeout_secs: u64, + pub on_stream_start: bool, + pub on_stream_end: bool, + pub on_stream_error: bool, + pub on_viewer_connect: bool, + pub on_viewer_disconnect: bool, +} + +impl Default for WebhookConfig { + fn default() -> Self { + Self { + enabled: false, + url: String::new(), + secret: None, + timeout_secs: 10, + on_stream_start: true, + on_stream_end: true, + on_stream_error: true, + on_viewer_connect: false, + on_viewer_disconnect: false, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct WebhookPayload { + pub event: WebhookEvent, + pub stream_id: String, + pub timestamp: u64, + pub data: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum WebhookEvent { + StreamStart, + StreamEnd, + StreamError, + ViewerConnect, + ViewerDisconnect, +} + +pub struct WebhookSender { + config: WebhookConfig, + client: reqwest::Client, +} + +impl WebhookSender { + pub fn new(config: WebhookConfig) -> Self { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(config.timeout_secs)) + .build() + .unwrap_or_default(); + + Self { config, client } + } + + pub fn should_send(&self, event: &WebhookEvent) -> bool { + if !self.config.enabled { + return false; + } + match event { + WebhookEvent::StreamStart => self.config.on_stream_start, + WebhookEvent::StreamEnd => self.config.on_stream_end, + WebhookEvent::StreamError => self.config.on_stream_error, + WebhookEvent::ViewerConnect => self.config.on_viewer_connect, + WebhookEvent::ViewerDisconnect => self.config.on_viewer_disconnect, + } + } + + pub async fn send(&self, payload: &WebhookPayload) -> Result<(), String> { + if !self.should_send(&payload.event) { + return Ok(()); + } + + let mut request = self + .client + .post(&self.config.url) + .json(payload) + .header("Content-Type", "application/json"); + + if let Some(ref secret) = self.config.secret { + request = request.header("X-Webhook-Secret", secret); + } + + match request.send().await { + Ok(response) => { + if response.status().is_success() { + info!( + "Webhook sent successfully: {:?} for stream {}", + payload.event, payload.stream_id + ); + Ok(()) + } else { + let status = response.status(); + warn!( + "Webhook returned non-success status: {} for event {:?}", + status, payload.event + ); + Err(format!("Webhook returned status {status}")) + } + } + Err(e) => { + error!("Webhook send failed: {}", e); + Err(format!("Webhook send failed: {e}")) + } + } + } +} + +pub fn create_payload( + event: WebhookEvent, + stream_id: String, + data: serde_json::Value, +) -> WebhookPayload { + WebhookPayload { + event, + stream_id, + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + data, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_webhook_config_default() { + let config = WebhookConfig::default(); + assert!(!config.enabled); + assert!(config.url.is_empty()); + assert_eq!(config.timeout_secs, 10); + assert!(config.on_stream_start); + assert!(config.on_stream_end); + assert!(config.on_stream_error); + assert!(!config.on_viewer_connect); + assert!(!config.on_viewer_disconnect); + } + + #[test] + fn test_webhook_sender_should_send_disabled() { + let config = WebhookConfig::default(); + let sender = WebhookSender::new(config); + assert!(!sender.should_send(&WebhookEvent::StreamStart)); + } + + #[test] + fn test_webhook_sender_should_send_enabled() { + let config = WebhookConfig { + enabled: true, + url: "http://example.com".into(), + ..Default::default() + }; + let sender = WebhookSender::new(config); + assert!(sender.should_send(&WebhookEvent::StreamStart)); + assert!(sender.should_send(&WebhookEvent::StreamEnd)); + assert!(sender.should_send(&WebhookEvent::StreamError)); + assert!(!sender.should_send(&WebhookEvent::ViewerConnect)); + } + + #[test] + fn test_create_payload() { + let payload = create_payload( + WebhookEvent::StreamStart, + "test-stream".into(), + serde_json::json!({"name": "test"}), + ); + assert_eq!(payload.event, WebhookEvent::StreamStart); + assert_eq!(payload.stream_id, "test-stream"); + assert!(payload.timestamp > 0); + } + + #[test] + fn test_webhook_event_serialize() { + let event = WebhookEvent::StreamStart; + let json = serde_json::to_string(&event).unwrap(); + assert!(json.contains("StreamStart")); + } + + #[test] + fn test_webhook_config_serialize() { + let config = WebhookConfig::default(); + let json = serde_json::to_string(&config).unwrap(); + assert!(json.contains("enabled")); + assert!(json.contains("timeout_secs")); + } + + #[test] + fn test_webhook_payload_serialize() { + let payload = create_payload( + WebhookEvent::StreamEnd, + "stream-1".into(), + serde_json::json!({}), + ); + let json = serde_json::to_string(&payload).unwrap(); + assert!(json.contains("StreamEnd")); + assert!(json.contains("stream-1")); + } +} diff --git a/crates/reestream-server/static/favicon.svg b/crates/reestream-server/static/favicon.svg new file mode 100644 index 0000000..62990f3 --- /dev/null +++ b/crates/reestream-server/static/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/reestream-server/static/icons.svg b/crates/reestream-server/static/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/crates/reestream-server/static/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/reestream-server/static/index.html b/crates/reestream-server/static/index.html new file mode 100644 index 0000000..7847952 --- /dev/null +++ b/crates/reestream-server/static/index.html @@ -0,0 +1,14 @@ + + + + + + + Reestream Dashboard + + + + +
+ + diff --git a/crates/reestream-srt/Cargo.toml b/crates/reestream-srt/Cargo.toml new file mode 100644 index 0000000..e18867c --- /dev/null +++ b/crates/reestream-srt/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "reestream-srt" +version = "0.2.0" +edition = "2024" +authors = ["RustLangES contact@rustlang-es.org"] +description = "SRT protocol support for reestream" +license = "MIT OR Apache-2.0" + +[features] +default = [] + +[dependencies] +bytes = "1.10" +serde = { version = "1", features = ["derive"] } +srt-tokio = "0.4" +tokio = { version = "1", default-features = false, features = [ + "io-util", + "macros", + "net", + "rt-multi-thread", + "sync", + "time", +] } +tracing = { version = "0.1", features = ["log"] } +url = { version = "2.5", features = ["serde"] } +futures = "0.3" + +[dev-dependencies] +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/crates/reestream-srt/src/config.rs b/crates/reestream-srt/src/config.rs new file mode 100644 index 0000000..8d2f0d5 --- /dev/null +++ b/crates/reestream-srt/src/config.rs @@ -0,0 +1,155 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SrtConfig { + pub enabled: bool, + pub listen_addr: String, + pub listen_port: u16, + pub latency_ms: u32, + pub max_bandwidth: i64, + pub passphrase: Option, + pub pbkey_len: Option, +} + +impl Default for SrtConfig { + fn default() -> Self { + Self { + enabled: false, + listen_addr: "0.0.0.0".into(), + listen_port: 3000, + latency_ms: 200, + max_bandwidth: -1, + passphrase: None, + pbkey_len: None, + } + } +} + +impl SrtConfig { + pub fn validate(&self) -> Result<(), String> { + if self.listen_port == 0 { + return Err("SRT listen_port cannot be 0".into()); + } + if self.listen_addr.is_empty() { + return Err("SRT listen_addr cannot be empty".into()); + } + if self.latency_ms == 0 { + return Err("SRT latency_ms cannot be 0".into()); + } + if let Some(ref pass) = self.passphrase + && pass.len() < 10 + { + return Err("SRT passphrase must be at least 10 characters".into()); + } + if let Some(len) = self.pbkey_len + && len != 16 + && len != 24 + && len != 32 + { + return Err("SRT pbkey_len must be 16, 24, or 32".into()); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let config = SrtConfig::default(); + assert!(!config.enabled); + assert_eq!(config.listen_addr, "0.0.0.0"); + assert_eq!(config.listen_port, 3000); + assert_eq!(config.latency_ms, 200); + assert_eq!(config.max_bandwidth, -1); + assert!(config.passphrase.is_none()); + } + + #[test] + fn test_validate_ok() { + let config = SrtConfig::default(); + assert!(config.validate().is_ok()); + } + + #[test] + fn test_validate_zero_port() { + let config = SrtConfig { + listen_port: 0, + ..Default::default() + }; + assert!(config.validate().is_err()); + } + + #[test] + fn test_validate_empty_addr() { + let config = SrtConfig { + listen_addr: "".into(), + ..Default::default() + }; + assert!(config.validate().is_err()); + } + + #[test] + fn test_validate_zero_latency() { + let config = SrtConfig { + latency_ms: 0, + ..Default::default() + }; + assert!(config.validate().is_err()); + } + + #[test] + fn test_validate_short_passphrase() { + let config = SrtConfig { + passphrase: Some("short".into()), + ..Default::default() + }; + assert!(config.validate().is_err()); + } + + #[test] + fn test_validate_valid_passphrase() { + let config = SrtConfig { + passphrase: Some("longenoughpassphrase".into()), + ..Default::default() + }; + assert!(config.validate().is_ok()); + } + + #[test] + fn test_validate_invalid_pbkey_len() { + let config = SrtConfig { + pbkey_len: Some(12), + ..Default::default() + }; + assert!(config.validate().is_err()); + } + + #[test] + fn test_validate_valid_pbkey_len() { + for len in [16, 24, 32] { + let config = SrtConfig { + pbkey_len: Some(len), + ..Default::default() + }; + assert!(config.validate().is_ok(), "pbkey_len={len} should be valid"); + } + } + + #[test] + fn test_config_clone() { + let config = SrtConfig::default(); + let cloned = config.clone(); + assert_eq!(config.listen_port, cloned.listen_port); + } + + #[test] + fn test_config_serialize() { + let config = SrtConfig::default(); + let json = serde_json::to_string(&config).unwrap(); + assert!(json.contains("3000")); + assert!(json.contains("200")); + } +} diff --git a/crates/reestream-srt/src/error.rs b/crates/reestream-srt/src/error.rs new file mode 100644 index 0000000..5d4125e --- /dev/null +++ b/crates/reestream-srt/src/error.rs @@ -0,0 +1,94 @@ +use std::fmt; + +#[derive(Debug)] +pub enum SrtError { + BindFailed(String), + ConnectionFailed(String), + SendFailed(String), + ReceiveFailed(String), + InvalidConfig(String), + IoError(std::io::Error), + Timeout(String), +} + +impl fmt::Display for SrtError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BindFailed(msg) => write!(f, "SRT bind failed: {msg}"), + Self::ConnectionFailed(msg) => write!(f, "SRT connection failed: {msg}"), + Self::SendFailed(msg) => write!(f, "SRT send failed: {msg}"), + Self::ReceiveFailed(msg) => write!(f, "SRT receive failed: {msg}"), + Self::InvalidConfig(msg) => write!(f, "Invalid SRT config: {msg}"), + Self::IoError(e) => write!(f, "IO error: {e}"), + Self::Timeout(msg) => write!(f, "SRT timeout: {msg}"), + } + } +} + +impl std::error::Error for SrtError {} + +impl From for SrtError { + fn from(e: std::io::Error) -> Self { + Self::IoError(e) + } +} + +impl From for SrtError { + fn from(s: String) -> Self { + Self::InvalidConfig(s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_display_bind_failed() { + let err = SrtError::BindFailed("port in use".into()); + assert!(err.to_string().contains("bind failed")); + } + + #[test] + fn test_display_connection_failed() { + let err = SrtError::ConnectionFailed("refused".into()); + assert!(err.to_string().contains("connection failed")); + } + + #[test] + fn test_display_send_failed() { + let err = SrtError::SendFailed("buffer full".into()); + assert!(err.to_string().contains("send failed")); + } + + #[test] + fn test_display_receive_failed() { + let err = SrtError::ReceiveFailed("timeout".into()); + assert!(err.to_string().contains("receive failed")); + } + + #[test] + fn test_display_invalid_config() { + let err = SrtError::InvalidConfig("bad latency".into()); + assert!(err.to_string().contains("Invalid SRT config")); + } + + #[test] + fn test_display_timeout() { + let err = SrtError::Timeout("30s".into()); + assert!(err.to_string().contains("timeout")); + } + + #[test] + fn test_from_io_error() { + let io = std::io::Error::new(std::io::ErrorKind::NotFound, "test"); + let err: SrtError = io.into(); + assert!(matches!(err, SrtError::IoError(_))); + } + + #[test] + fn test_error_trait() { + let err: Box = Box::new(SrtError::BindFailed("test".into())); + assert!(err.to_string().contains("bind failed")); + } +} diff --git a/crates/reestream-srt/src/lib.rs b/crates/reestream-srt/src/lib.rs new file mode 100644 index 0000000..5f69b8e --- /dev/null +++ b/crates/reestream-srt/src/lib.rs @@ -0,0 +1,9 @@ +pub mod config; +pub mod error; +pub mod listener; +pub mod sender; + +pub use config::SrtConfig; +pub use error::SrtError; +pub use listener::SrtListener; +pub use sender::SrtSender; diff --git a/crates/reestream-srt/src/listener.rs b/crates/reestream-srt/src/listener.rs new file mode 100644 index 0000000..b402a6e --- /dev/null +++ b/crates/reestream-srt/src/listener.rs @@ -0,0 +1,84 @@ +use bytes::Bytes; +use futures::prelude::*; +use srt_tokio::SrtSocket; +use std::time::Duration; +use tokio::sync::broadcast; +use tracing::{info, warn}; + +use crate::config::SrtConfig; +use crate::error::SrtError; + +pub struct SrtListener { + config: SrtConfig, + data_tx: broadcast::Sender, +} + +impl SrtListener { + pub fn new(config: SrtConfig) -> Self { + let (data_tx, _) = broadcast::channel(256); + Self { config, data_tx } + } + + pub fn subscribe(&self) -> broadcast::Receiver { + self.data_tx.subscribe() + } + + pub async fn run(&self) -> Result<(), SrtError> { + self.config.validate()?; + + let bind_addr = format!("{}:{}", self.config.listen_addr, self.config.listen_port); + info!("SRT listener starting on {}", bind_addr); + + let mut builder = + SrtSocket::builder().latency(Duration::from_millis(self.config.latency_ms as u64)); + + if let Some(ref pass) = self.config.passphrase { + let key_len = self.config.pbkey_len.unwrap_or(16) as u16; + builder = builder.encryption(key_len, pass.clone()); + } + + let mut socket = builder + .listen_on(bind_addr.as_str()) + .await + .map_err(|e| SrtError::BindFailed(format!("{e}")))?; + + info!("SRT listener bound on {}", bind_addr); + + let data_tx = self.data_tx.clone(); + + while let Some(result) = socket.next().await { + match result { + Ok((_instant, bytes)) => { + let data = Bytes::from(bytes.to_vec()); + let _ = data_tx.send(data); + } + Err(e) => { + warn!("SRT receive error: {}", e); + } + } + } + + info!("SRT listener stopped"); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_srt_listener_creation() { + let config = SrtConfig::default(); + let listener = SrtListener::new(config); + assert_eq!(listener.config.listen_port, 3000); + } + + #[test] + fn test_srt_listener_subscribe() { + let config = SrtConfig::default(); + let listener = SrtListener::new(config); + let _rx = listener.subscribe(); + let _rx2 = listener.subscribe(); + } +} diff --git a/crates/reestream-srt/src/sender.rs b/crates/reestream-srt/src/sender.rs new file mode 100644 index 0000000..b3a72af --- /dev/null +++ b/crates/reestream-srt/src/sender.rs @@ -0,0 +1,99 @@ +use bytes::Bytes; +use futures::prelude::*; +use srt_tokio::SrtSocket; +use std::time::{Duration, Instant}; +use tracing::info; +use url::Url; + +use crate::error::SrtError; + +pub struct SrtSender { + url: Url, + latency_ms: u32, + passphrase: Option, + socket: Option, +} + +impl SrtSender { + pub fn new(url: Url, latency_ms: u32, passphrase: Option) -> Self { + Self { + url, + latency_ms, + passphrase, + socket: None, + } + } + + pub async fn connect(&mut self) -> Result<(), SrtError> { + let host = self + .url + .host_str() + .ok_or_else(|| SrtError::InvalidConfig("No host in SRT URL".into()))?; + let port = self.url.port().unwrap_or(3000); + let addr = format!("{host}:{port}"); + + info!("Connecting SRT sender to {}", addr); + + let mut builder = + SrtSocket::builder().latency(Duration::from_millis(self.latency_ms as u64)); + + if let Some(ref pass) = self.passphrase { + builder = builder.encryption(16, pass.clone()); + } + + let socket = builder + .call(addr.as_str(), None) + .await + .map_err(|e| SrtError::ConnectionFailed(format!("{e}")))?; + + self.socket = Some(socket); + info!("SRT sender connected to {}", addr); + Ok(()) + } + + pub async fn send(&mut self, data: Bytes) -> Result<(), SrtError> { + let socket = self + .socket + .as_mut() + .ok_or_else(|| SrtError::SendFailed("Not connected".into()))?; + + let instant = Instant::now(); + socket + .send((instant, data)) + .await + .map_err(|e| SrtError::SendFailed(format!("{e}")))?; + Ok(()) + } + + pub async fn disconnect(&mut self) { + if let Some(mut socket) = self.socket.take() { + let _ = socket.close().await; + info!("SRT sender disconnected"); + } + } + + pub fn is_connected(&self) -> bool { + self.socket.is_some() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_srt_sender_creation() { + let url: Url = "srt://example.com:3000".parse().unwrap(); + let sender = SrtSender::new(url.clone(), 200, None); + assert_eq!(sender.url, url); + assert_eq!(sender.latency_ms, 200); + assert!(!sender.is_connected()); + } + + #[test] + fn test_srt_sender_with_passphrase() { + let url: Url = "srt://example.com:3000".parse().unwrap(); + let sender = SrtSender::new(url, 200, Some("longenoughpassphrase".into())); + assert!(sender.passphrase.is_some()); + } +} diff --git a/dashboard/.gitignore b/dashboard/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/dashboard/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/dashboard/bun.lock b/dashboard/bun.lock new file mode 100644 index 0000000..4f83d18 --- /dev/null +++ b/dashboard/bun.lock @@ -0,0 +1,324 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "dashboard", + "dependencies": { + "@tailwindcss/vite": "^4.3.0", + "flv.js": "^1.6.2", + "preact": "^10.29.1", + "tailwindcss": "^4.3.0", + }, + "devDependencies": { + "@preact/preset-vite": "^2.10.5", + "@types/node": "^24.12.3", + "typescript": "~6.0.2", + "vite": "^8.0.12", + }, + }, + }, + "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="], + + "@babel/plugin-transform-react-jsx": ["@babel/plugin-transform-react-jsx@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-module-imports": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-syntax-jsx": "^7.29.7", "@babel/types": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A=="], + + "@babel/plugin-transform-react-jsx-development": ["@babel/plugin-transform-react-jsx-development@7.29.7", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + + "@oxc-project/types": ["@oxc-project/types@0.132.0", "", {}, "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ=="], + + "@preact/preset-vite": ["@preact/preset-vite@2.10.5", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.27.1", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@prefresh/vite": "^2.4.11", "@rollup/pluginutils": "^5.0.0", "babel-plugin-transform-hook-names": "^1.0.2", "debug": "^4.4.3", "magic-string": "^0.30.21", "picocolors": "^1.1.1", "vite-prerender-plugin": "^0.5.8", "zimmerframe": "^1.1.4" }, "peerDependencies": { "@babel/core": "7.x", "vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x || 8.x" } }, "sha512-p0vJpxiVO7KWWazWny3LUZ+saXyZKWv6Ju0bYMWNJRp2YveufRPgSUB1C4MTqGJfz07EehMgfN+AJNwQy+w6Iw=="], + + "@prefresh/babel-plugin": ["@prefresh/babel-plugin@0.5.3", "", {}, "sha512-57LX2SHs4BX2s1IwCjNzTE2OJeEepRCNf1VTEpbNcUyHfMO68eeOWGDIt4ob9aYlW6PEWZ1SuwNikuoIXANDtQ=="], + + "@prefresh/core": ["@prefresh/core@1.5.10", "", { "peerDependencies": { "preact": "^10.0.0 || ^11.0.0-0" } }, "sha512-7yPTFbG56sutaFu8krp3B4a200KOFUvrtlllKWRuLjsYXo9UUucHOZRcer+gtgMkFTpv6ob8TGcTwA32bSwa1w=="], + + "@prefresh/utils": ["@prefresh/utils@1.2.1", "", {}, "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw=="], + + "@prefresh/vite": ["@prefresh/vite@2.4.12", "", { "dependencies": { "@babel/core": "^7.22.1", "@prefresh/babel-plugin": "^0.5.2", "@prefresh/core": "^1.5.0", "@prefresh/utils": "^1.2.0", "@rollup/pluginutils": "^4.2.1" }, "peerDependencies": { "preact": "^10.4.0 || ^11.0.0-0", "vite": ">=2.0.0" } }, "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.2", "", { "os": "android", "cpu": "arm64" }, "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.2", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.0", "", { "os": "android", "cpu": "arm64" }, "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.0", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.3.0", "", { "dependencies": { "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "tailwindcss": "4.3.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="], + + "babel-plugin-transform-hook-names": ["babel-plugin-transform-hook-names@1.0.2", "", { "peerDependencies": { "@babel/core": "^7.12.10" } }, "sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.32", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg=="], + + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.363", "", {}, "sha512-VjUKPyWzGnT1fujlkEGC/BvN70Hh70KXtAqcmniXviYlJC/ivcT+BWGPyxWVbJZLfvtKR6dqg1L7T7pgAMBtWA=="], + + "enhanced-resolve": ["enhanced-resolve@5.22.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww=="], + + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "es6-promise": ["es6-promise@4.2.8", "", {}, "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "flv.js": ["flv.js@1.6.2", "", { "dependencies": { "es6-promise": "^4.2.8", "webworkify-webpack": "^2.1.5" } }, "sha512-xre4gUbX1MPtgQRKj2pxJENp/RnaHaxYvy3YToVVCrSmAWUu85b9mug6pTXF6zakUjNP2lFWZ1rkSX7gxhB/2A=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + + "node-html-parser": ["node-html-parser@6.1.13", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg=="], + + "node-releases": ["node-releases@2.0.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="], + + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + + "preact": ["preact@10.29.2", "", {}, "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ=="], + + "rolldown": ["rolldown@1.0.2", "", { "dependencies": { "@oxc-project/types": "=0.132.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.2", "@rolldown/binding-darwin-arm64": "1.0.2", "@rolldown/binding-darwin-x64": "1.0.2", "@rolldown/binding-freebsd-x64": "1.0.2", "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", "@rolldown/binding-linux-arm64-gnu": "1.0.2", "@rolldown/binding-linux-arm64-musl": "1.0.2", "@rolldown/binding-linux-ppc64-gnu": "1.0.2", "@rolldown/binding-linux-s390x-gnu": "1.0.2", "@rolldown/binding-linux-x64-gnu": "1.0.2", "@rolldown/binding-linux-x64-musl": "1.0.2", "@rolldown/binding-openharmony-arm64": "1.0.2", "@rolldown/binding-wasm32-wasi": "1.0.2", "@rolldown/binding-win32-arm64-msvc": "1.0.2", "@rolldown/binding-win32-x64-msvc": "1.0.2" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "simple-code-frame": ["simple-code-frame@1.3.0", "", { "dependencies": { "kolorist": "^1.6.0" } }, "sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w=="], + + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stack-trace": ["stack-trace@1.0.0", "", {}, "sha512-H6D7134xi6qONvh7ZHKgviXf+rd3vhGBSvebPZCaUkd8zvQ+7PtDw6CljPTe4cXWNf2IKZGNqw6VJXSb9IgBpA=="], + + "tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "vite": ["vite@8.0.14", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw=="], + + "vite-prerender-plugin": ["vite-prerender-plugin@0.5.13", "", { "dependencies": { "kolorist": "^1.8.0", "magic-string": "0.x >= 0.26.0", "node-html-parser": "^6.1.12", "simple-code-frame": "^1.3.0", "source-map": "^0.7.4", "stack-trace": "^1.0.0-pre2" }, "peerDependencies": { "vite": "5.x || 6.x || 7.x || 8.x" } }, "sha512-IKSpYkzDBsKAxa05naRbj7GvNVMSdww/Z/E89oO3xndz+gWnOBOKOAbEXv7qDhktY/j3vHgJmoV1pPzqU2tx9g=="], + + "webworkify-webpack": ["webworkify-webpack@2.1.5", "", {}, "sha512-2akF8FIyUvbiBBdD+RoHpoTbHMQF2HwjcxfDvgztAX5YwbZNyrtfUMgvfgFVsgDhDPVTlkbb5vyasqDHfIDPQw=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], + + "@prefresh/vite/@rollup/pluginutils": ["@rollup/pluginutils@4.2.1", "", { "dependencies": { "estree-walker": "^2.0.1", "picomatch": "^2.2.2" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@prefresh/vite/@rollup/pluginutils/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + } +} diff --git a/dashboard/index.html b/dashboard/index.html new file mode 100644 index 0000000..e99e9c5 --- /dev/null +++ b/dashboard/index.html @@ -0,0 +1,13 @@ + + + + + + + Reestream Dashboard + + +
+ + + diff --git a/dashboard/package.json b/dashboard/package.json new file mode 100644 index 0000000..4952185 --- /dev/null +++ b/dashboard/package.json @@ -0,0 +1,23 @@ +{ + "name": "dashboard", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tailwindcss/vite": "^4.3.0", + "flv.js": "^1.6.2", + "preact": "^10.29.1", + "tailwindcss": "^4.3.0" + }, + "devDependencies": { + "@preact/preset-vite": "^2.10.5", + "@types/node": "^24.12.3", + "typescript": "~6.0.2", + "vite": "^8.0.12" + } +} diff --git a/dashboard/public/favicon.svg b/dashboard/public/favicon.svg new file mode 100644 index 0000000..62990f3 --- /dev/null +++ b/dashboard/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/dashboard/public/icons.svg b/dashboard/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/dashboard/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/src/api/client.ts b/dashboard/src/api/client.ts new file mode 100644 index 0000000..efbc9bb --- /dev/null +++ b/dashboard/src/api/client.ts @@ -0,0 +1,70 @@ +import type { + ApiResponse, + ServerStatus, + StreamInfo, + Platform, + AddStreamRequest, + AddPlatformRequest, + ConfigResponse, +} from './types'; + +const BASE = ''; + +async function request(path: string, init?: RequestInit): Promise> { + const res = await fetch(`${BASE}${path}`, { + headers: { 'Content-Type': 'application/json' }, + ...init, + }); + return res.json() as Promise>; +} + +export const api = { + getStatus: () => request('/api/status'), + + getStreams: () => request('/api/streams'), + + addStream: (req: AddStreamRequest) => + request('/api/streams', { + method: 'POST', + body: JSON.stringify(req), + }), + + removeStream: (id: string) => + request(`/api/streams/${id}`, { method: 'DELETE' }), + + getStreamStats: (id: string) => + request(`/api/streams/${id}/stats`), + + getPlatforms: () => request('/api/platforms'), + + addPlatform: (req: AddPlatformRequest) => + request('/api/platforms', { + method: 'POST', + body: JSON.stringify(req), + }), + + removePlatform: (id: string) => + request(`/api/platforms/${id}`, { method: 'DELETE' }), + + togglePlatform: (id: string) => + request(`/api/platforms/${id}/toggle`, { method: 'PUT' }), + + getConfig: () => request('/api/config'), + + reloadConfig: () => + request('/api/config/reload', { method: 'POST' }), + + getRecordings: () => request('/api/recordings'), + + startRecording: (streamId: string, inputUrl: string) => + request('/api/recordings/start', { + method: 'POST', + body: JSON.stringify({ stream_id: streamId, input_url: inputUrl }), + }), + + stopRecording: (id: string) => + request(`/api/recordings/${id}/stop`, { method: 'POST' }), + + deleteRecording: (id: string) => + request(`/api/recordings/${id}`, { method: 'DELETE' }), +}; diff --git a/dashboard/src/api/index.ts b/dashboard/src/api/index.ts new file mode 100644 index 0000000..55d6eb7 --- /dev/null +++ b/dashboard/src/api/index.ts @@ -0,0 +1,11 @@ +export { api } from './client'; +export type { + ApiResponse, + ServerStatus, + StreamInfo, + StreamStatus, + Platform, + AddStreamRequest, + AddPlatformRequest, + ConfigResponse, +} from './types'; diff --git a/dashboard/src/api/types.ts b/dashboard/src/api/types.ts new file mode 100644 index 0000000..2b6339c --- /dev/null +++ b/dashboard/src/api/types.ts @@ -0,0 +1,52 @@ +export interface ApiResponse { + success: boolean; + data?: T; + error?: string; +} + +export interface ServerStatus { + version: string; + uptime_seconds: number; + active_streams: number; + total_viewers: number; +} + +export interface StreamInfo { + id: string; + name: string; + input_url: string; + status: StreamStatus; + started_at: number | null; + viewers: number; + bitrate: number; +} + +export type StreamStatus = + | 'Idle' + | 'Live' + | { Error: string }; + +export interface Platform { + id: string; + name: string; + url: string; + key: string; + enabled: boolean; +} + +export interface AddStreamRequest { + name: string; + input_url: string; +} + +export interface AddPlatformRequest { + name: string; + url: string; + key: string; +} + +export interface ConfigResponse { + rtmp_addr: string; + rtmp_port: number; + platform_count: number; +} diff --git a/dashboard/src/app.tsx b/dashboard/src/app.tsx new file mode 100644 index 0000000..2de651a --- /dev/null +++ b/dashboard/src/app.tsx @@ -0,0 +1,151 @@ +import { useCallback, useState, useEffect } from 'preact/hooks'; +import { api } from './api'; +import type { ServerStatus, StreamInfo, Platform } from './api'; +import { usePolling } from './hooks'; +import { useLogger } from './components/LogViewer'; +import { Header } from './components/Header'; +import { StatsCards } from './components/StatsCards'; +import { VideoPreview } from './components/VideoPreview'; +import { StreamsTable } from './components/StreamsTable'; +import { PlatformsTable } from './components/PlatformsTable'; +import { LogViewer } from './components/LogViewer'; +import { SetupWizard } from './components/SetupWizard'; +import { SettingsPanel } from './components/SettingsPanel'; +import { RecordingControls } from './components/RecordingControls'; + +const STATUS_POLL = 5_000; +const STREAMS_POLL = 10_000; +const PLATFORMS_POLL = 15_000; + +export function App() { + const { logs, addLog, clearLogs } = useLogger(); + const [needsSetup, setNeedsSetup] = useState(null); + const [showSettings, setShowSettings] = useState(false); + + useEffect(() => { + fetch('/api/setup/status') + .then((r) => r.json()) + .then((d) => { + if (d.success) setNeedsSetup(d.data.first_run); + else setNeedsSetup(false); + }) + .catch(() => setNeedsSetup(false)); + }, []); + + const fetchStatus = useCallback(async (): Promise => { + const res = await api.getStatus(); + if (!res.success || !res.data) throw new Error(res.error ?? 'Failed to fetch status'); + return res.data; + }, []); + + const fetchStreams = useCallback(async (): Promise => { + const res = await api.getStreams(); + if (!res.success || !res.data) throw new Error(res.error ?? 'Failed to fetch streams'); + return res.data; + }, []); + + const fetchPlatforms = useCallback(async (): Promise => { + const res = await api.getPlatforms(); + if (!res.success || !res.data) throw new Error(res.error ?? 'Failed to fetch platforms'); + return res.data; + }, []); + + const status = usePolling(fetchStatus, STATUS_POLL); + const streams = usePolling(fetchStreams, STREAMS_POLL); + const platforms = usePolling(fetchPlatforms, PLATFORMS_POLL); + + const handleToggle = useCallback( + async (id: string) => { + const res = await api.togglePlatform(id); + if (res.success) { + addLog('Platform toggled'); + platforms.refresh(); + } else { + addLog(`Toggle failed: ${res.error}`, 'error'); + } + }, + [addLog, platforms], + ); + + const handleAddPlatform = useCallback( + async (name: string, url: string, key: string) => { + const res = await api.addPlatform({ name, url, key }); + if (res.success) { + addLog(`Platform "${name}" added`); + platforms.refresh(); + } else { + throw new Error(res.error ?? 'Failed to add platform'); + } + }, + [addLog, platforms], + ); + + const handleRemovePlatform = useCallback( + async (id: string) => { + const res = await api.removePlatform(id); + if (res.success) { + addLog('Platform removed'); + platforms.refresh(); + } else { + addLog(`Remove failed: ${res.error}`, 'error'); + } + }, + [addLog, platforms], + ); + + if (status.error) addLog(`Status error: ${status.error}`, 'error'); + if (streams.error) addLog(`Streams error: ${streams.error}`, 'error'); + if (platforms.error) addLog(`Platforms error: ${platforms.error}`, 'error'); + + // Show setup wizard on first run + if (needsSetup === true) { + return ; + } + + // Loading state + if (needsSetup === null) { + return ( +
+
Loading…
+
+ ); + } + + const streamNames = (streams.data ?? []).map((s) => ({ + id: s.id, + name: s.name, + status: typeof s.status === 'string' ? s.status : Object.keys(s.status)[0], + })); + + return ( +
+
setShowSettings(true)} + /> +
+ + + + + + +
+ + {showSettings && ( + setShowSettings(false)} addLog={addLog} /> + )} +
+ ); +} diff --git a/dashboard/src/assets/hero.png b/dashboard/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/dashboard/src/assets/hero.png differ diff --git a/dashboard/src/assets/vite.svg b/dashboard/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/dashboard/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/dashboard/src/components/Header.tsx b/dashboard/src/components/Header.tsx new file mode 100644 index 0000000..e7e4766 --- /dev/null +++ b/dashboard/src/components/Header.tsx @@ -0,0 +1,30 @@ +interface Props { + version: string; + onSettings: () => void; +} + +export function Header({ version, onSettings }: Props) { + return ( +
+

Reestream Dashboard

+
+ v{version} + +
+
+ ); +} diff --git a/dashboard/src/components/LogViewer.tsx b/dashboard/src/components/LogViewer.tsx new file mode 100644 index 0000000..df2b909 --- /dev/null +++ b/dashboard/src/components/LogViewer.tsx @@ -0,0 +1,59 @@ +import { useState, useCallback } from 'preact/hooks'; + +interface LogEntry { + time: string; + message: string; + level: 'info' | 'warn' | 'error'; +} + +interface Props { + logs: LogEntry[]; + onClear: () => void; +} + +const levelColor: Record = { + info: 'text-sky-400', + warn: 'text-amber-400', + error: 'text-red-400', +}; + +export function LogViewer({ logs, onClear }: Props) { + return ( +
+
+

Logs

+ +
+
+ {logs.length === 0 ? ( +
No logs
+ ) : ( + logs.map((l, i) => ( +
+ [{l.time}]{' '} + {l.message} +
+ )) + )} +
+
+ ); +} + +export function useLogger() { + const [logs, setLogs] = useState([]); + + const addLog = useCallback((message: string, level: LogEntry['level'] = 'info') => { + const time = new Date().toLocaleTimeString(); + setLogs((prev) => [...prev.slice(-199), { time, message, level }]); + }, []); + + const clearLogs = useCallback(() => setLogs([]), []); + + return { logs, addLog, clearLogs }; +} diff --git a/dashboard/src/components/PlatformsTable.tsx b/dashboard/src/components/PlatformsTable.tsx new file mode 100644 index 0000000..4ad05f0 --- /dev/null +++ b/dashboard/src/components/PlatformsTable.tsx @@ -0,0 +1,188 @@ +import { useState, useCallback } from 'preact/hooks'; +import type { Platform } from '../api'; + +interface Props { + platforms: Platform[]; + loading: boolean; + onRefresh: () => void; + onToggle: (id: string) => void; + onAdd: (name: string, url: string, key: string) => Promise; + onRemove: (id: string) => Promise; +} + +const PRESETS: Array<{ name: string; url: string }> = [ + { name: 'Twitch', url: 'rtmp://live.twitch.tv/app' }, + { name: 'YouTube', url: 'rtmp://a.rtmp.youtube.com/live2' }, + { name: 'Facebook', url: 'rtmps://live-api-s.facebook.com:443/rtmp/' }, + { name: 'Instagram', url: 'rtmps://edge-upload.instagram.com:443/rtmp/' }, + { name: 'Kick', url: 'rtmp://fa723fc1b141.global-contribute.live-video.net/app' }, + { name: 'TikTok', url: 'rtmp://push.tiktok.com/live/' }, +]; + +export function PlatformsTable({ platforms, loading, onRefresh, onToggle, onAdd, onRemove }: Props) { + const [showAdd, setShowAdd] = useState(false); + const [addName, setAddName] = useState(''); + const [addUrl, setAddUrl] = useState(''); + const [addKey, setAddKey] = useState(''); + const [adding, setAdding] = useState(false); + const [removing, setRemoving] = useState(null); + + const handlePreset = useCallback((preset: (typeof PRESETS)[number]) => { + setAddName(preset.name); + setAddUrl(preset.url); + }, []); + + const handleAdd = useCallback(async () => { + if (!addName || !addUrl || !addKey) return; + setAdding(true); + try { + await onAdd(addName, addUrl, addKey); + setAddName(''); + setAddUrl(''); + setAddKey(''); + setShowAdd(false); + } finally { + setAdding(false); + } + }, [addName, addUrl, addKey, onAdd]); + + const handleRemove = useCallback( + async (id: string, name: string) => { + if (!confirm(`Remove platform "${name}"?`)) return; + setRemoving(id); + try { + await onRemove(id); + } finally { + setRemoving(null); + } + }, + [onRemove], + ); + + return ( +
+
+

Platforms

+
+ + +
+
+ + {/* Add form */} + {showAdd && ( +
+
+ {PRESETS.map((p) => ( + + ))} +
+
+ setAddName((e.target as HTMLInputElement).value)} + placeholder="Name" + class="bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-sky-500" + /> + setAddUrl((e.target as HTMLInputElement).value)} + placeholder="rtmp://server/app" + class="bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-sky-500" + /> + setAddKey((e.target as HTMLInputElement).value)} + placeholder="Stream key" + type="password" + class="bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-sky-500" + /> +
+ +
+ )} + +
+ + + + + + + + + + + + {loading && platforms.length === 0 ? ( + + + + ) : platforms.length === 0 ? ( + + + + ) : ( + platforms.map((p) => ( + + + + + + + + )) + )} + +
IDNameURLEnabledActions
Loading…
+ No platforms. Click "+ Add Platform" to add one. +
{p.id.slice(0, 8)}…{p.name}{p.url} + + + +
+
+
+ ); +} diff --git a/dashboard/src/components/RecordingControls.tsx b/dashboard/src/components/RecordingControls.tsx new file mode 100644 index 0000000..7fa4bc3 --- /dev/null +++ b/dashboard/src/components/RecordingControls.tsx @@ -0,0 +1,193 @@ +import { useState, useEffect, useCallback } from 'preact/hooks'; +import { api } from '../api'; + +interface Recording { + id: string; + stream_id: string; + filename: string; + format: string; + started_at: number; + size_bytes: number; + status: string; +} + +interface Props { + addLog: (msg: string, level?: 'info' | 'warn' | 'error') => void; +} + +export function RecordingControls({ addLog }: Props) { + const [recordings, setRecordings] = useState([]); + const [loading, setLoading] = useState(true); + const [recording, setRecording] = useState(false); + + const refresh = useCallback(async () => { + try { + const res = await api.getRecordings(); + if (res.success && res.data) setRecordings(res.data as Recording[]); + } catch { + // ignore + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + refresh(); + const id = setInterval(refresh, 10_000); + return () => clearInterval(id); + }, [refresh]); + + const handleStart = useCallback(async () => { + setRecording(true); + try { + const res = await api.startRecording('live', 'rtmp://0.0.0.0:1935/live'); + if (res.success) { + addLog(`Recording started: ${res.data}`); + refresh(); + } else { + addLog(`Recording failed: ${res.error}`, 'error'); + } + } catch (e) { + addLog(`Recording error: ${e}`, 'error'); + } finally { + setRecording(false); + } + }, [addLog, refresh]); + + const handleStop = useCallback( + async (id: string) => { + const res = await api.stopRecording(id); + if (res.success) { + addLog('Recording stopped'); + refresh(); + } else { + addLog(`Stop failed: ${res.error}`, 'error'); + } + }, + [addLog, refresh], + ); + + const handleDelete = useCallback( + async (id: string) => { + if (!confirm('Delete this recording file?')) return; + const res = await api.deleteRecording(id); + if (res.success) { + addLog('Recording deleted'); + refresh(); + } else { + addLog(`Delete failed: ${res.error}`, 'error'); + } + }, + [addLog, refresh], + ); + + const formatSize = (bytes: number): string => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1048576).toFixed(1)} MB`; + }; + + const formatDuration = (startedAt: number): string => { + const secs = Math.floor(Date.now() / 1000) - startedAt; + if (secs < 60) return `${secs}s`; + if (secs < 3600) return `${Math.floor(secs / 60)}m ${secs % 60}s`; + return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m`; + }; + + const activeRecordings = recordings.filter((r) => r.status === 'recording'); + const pastRecordings = recordings.filter((r) => r.status !== 'recording'); + + return ( +
+
+

Recordings

+
+ + +
+
+ +
+ {/* Active recordings */} + {activeRecordings.length > 0 && ( +
+
Active
+ {activeRecordings.map((r) => ( +
+
+ +
+
{r.filename}
+
+ {formatDuration(r.started_at)} · {r.format.toUpperCase()} +
+
+
+ +
+ ))} +
+ )} + + {/* Past recordings */} + {pastRecordings.length > 0 && ( +
+
History
+
+ {pastRecordings.map((r) => ( +
+
+
{r.filename}
+
+ {r.status} · {r.format.toUpperCase()} · {formatSize(r.size_bytes)} +
+
+ +
+ ))} +
+
+ )} + + {/* Empty state */} + {!loading && recordings.length === 0 && ( +
+ No recordings. Click "Record" to start capturing the stream. +
+ )} + + {loading && ( +
Loading…
+ )} +
+
+ ); +} diff --git a/dashboard/src/components/SettingsPanel.tsx b/dashboard/src/components/SettingsPanel.tsx new file mode 100644 index 0000000..3bf33a1 --- /dev/null +++ b/dashboard/src/components/SettingsPanel.tsx @@ -0,0 +1,250 @@ +import { useState, useEffect, useCallback } from 'preact/hooks'; + +interface ServerInfo { + rtmp_url: string; + rtmps_url: string | null; + srt_url: string | null; + http_url: string; + hls_url: string; + flv_url: string; + dashboard_url: string; + api_url: string; + metrics_url: string; + stream_key_masked: string; + rtmp_port: number; + http_port: number; + srt_port: number; + hostname: string; +} + +interface Props { + onClose: () => void; + addLog: (msg: string, level?: 'info' | 'warn' | 'error') => void; +} + +export function SettingsPanel({ onClose, addLog }: Props) { + const [info, setInfo] = useState(null); + const [streamKey, setStreamKey] = useState(null); + const [showKey, setShowKey] = useState(false); + const [loading, setLoading] = useState(true); + const [resetting, setResetting] = useState(false); + const [copied, setCopied] = useState(null); + + useEffect(() => { + Promise.all([ + fetch('/api/setup/info').then((r) => r.json()), + ]) + .then(([infoRes]) => { + if (infoRes.success) setInfo(infoRes.data); + }) + .catch(() => addLog('Failed to load server info', 'error')) + .finally(() => setLoading(false)); + }, [addLog]); + + const handleRevealKey = useCallback(async () => { + if (streamKey) { + setShowKey(!showKey); + return; + } + try { + const res = await fetch('/api/setup/key'); + const data = await res.json(); + if (data.success) { + setStreamKey(data.data); + setShowKey(true); + } + } catch { + addLog('Failed to reveal stream key', 'error'); + } + }, [streamKey, showKey, addLog]); + + const handleResetKey = useCallback(async () => { + if (!confirm('Generate a new stream key? The old key will stop working immediately.')) return; + setResetting(true); + try { + const res = await fetch('/api/setup/key', { method: 'POST' }); + const data = await res.json(); + if (data.success) { + setStreamKey(data.data); + setShowKey(true); + addLog('Stream key reset successfully'); + } else { + addLog(`Reset failed: ${data.error}`, 'error'); + } + } catch { + addLog('Failed to reset stream key', 'error'); + } finally { + setResetting(false); + } + }, [addLog]); + + const copyToClipboard = useCallback(async (text: string, label: string) => { + try { + await navigator.clipboard.writeText(text); + setCopied(label); + setTimeout(() => setCopied(null), 1500); + } catch { + // Fallback + const ta = document.createElement('textarea'); + ta.value = text; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + setCopied(label); + setTimeout(() => setCopied(null), 1500); + } + }, []); + + if (loading) { + return ( +
+
+
Loading settings…
+
+
+ ); + } + + const endpoints = info + ? [ + { label: 'RTMP Ingest', value: info.rtmp_url, note: 'Primary input' }, + { label: 'RTMPS Ingest', value: info.rtmps_url, note: 'TLS encrypted' }, + { label: 'SRT Ingest', value: info.srt_url, note: 'Low latency' }, + { label: 'HLS Stream', value: info.hls_url, note: 'For playback' }, + { label: 'FLV Stream', value: info.flv_url, note: 'Low latency playback' }, + { label: 'Dashboard', value: info.dashboard_url, note: 'Web UI' }, + { label: 'API', value: info.api_url, note: 'REST API' }, + { label: 'Metrics', value: info.metrics_url, note: 'Prometheus' }, + ] + : []; + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+

Settings

+ +
+ +
+ {/* Stream Key Section */} +
+

Stream Key

+
+
+
+ {showKey && streamKey ? streamKey : info?.stream_key_masked ?? '****'} +
+ + +
+ +

+ Resetting generates a new key. Update your streaming software immediately. +

+
+
+ + {/* Endpoints Section */} +
+

+ Server Endpoints +

+
+ {endpoints.filter((ep) => ep.value != null).map((ep) => ( +
+
+
+ {ep.label} + {ep.note} +
+
{ep.value}
+
+ +
+ ))} +
+
+ + {/* OBS Instructions */} +
+

+ Quick Setup (OBS / Streamlabs) +

+
+
+ 1 +
+
Open OBS → Settings → Stream
+
+
+
+ 2 +
+
+ Service: Custom +
+
+
+
+ 3 +
+
+ Server: {info?.rtmp_url ?? 'rtmp://localhost:1935'} +
+
+
+
+ 4 +
+
+ Stream Key: {showKey && streamKey ? streamKey : info?.stream_key_masked ?? '****'} +
+
+
+
+
+
+
+
+ ); +} diff --git a/dashboard/src/components/SetupWizard.tsx b/dashboard/src/components/SetupWizard.tsx new file mode 100644 index 0000000..d47ecd9 --- /dev/null +++ b/dashboard/src/components/SetupWizard.tsx @@ -0,0 +1,404 @@ +import { useState, useCallback, useEffect } from 'preact/hooks'; + +interface SetupPlatform { + name: string; + url: string; + key: string; + orientation: 'horizontal' | 'vertical'; +} + +interface SetupStatus { + first_run: boolean; + config_exists: boolean; + has_stream_key: boolean; + platform_count: number; +} + +type Step = 'welcome' | 'server' | 'platforms' | 'confirm' | 'done'; + +const PRESETS: Array<{ name: string; url: string; placeholder: string }> = [ + { name: 'Twitch', url: 'rtmp://live.twitch.tv/app', placeholder: 'live_123456789_abc...' }, + { name: 'YouTube', url: 'rtmp://a.rtmp.youtube.com/live2', placeholder: 'xxxx-xxxx-xxxx-xxxx' }, + { name: 'Facebook', url: 'rtmps://live-api-s.facebook.com:443/rtmp/', placeholder: 'FB-1234567890-1234-abcdef' }, + { name: 'Instagram', url: 'rtmps://edge-upload.instagram.com:443/rtmp/', placeholder: 'IG-1234567890' }, + { name: 'Kick', url: 'rtmp://fa723fc1b141.global-contribute.live-video.net/app', placeholder: 'sk_live_...' }, + { name: 'TikTok', url: 'rtmp://push.tiktok.com/live/', placeholder: 'stream-key' }, +]; + +export function SetupWizard() { + const [step, setStep] = useState('welcome'); + const [error, setError] = useState(null); + + const [rtmpPort, setRtmpPort] = useState('1935'); + const [streamKey, setStreamKey] = useState(''); + const [platforms, setPlatforms] = useState([]); + const [saving, setSaving] = useState(false); + + useEffect(() => { + fetch('/api/setup/status') + .then((r: Response) => r.json()) + .then((d: { success: boolean; data?: SetupStatus }) => { + if (d.success && d.data && !d.data.first_run) { + // Already configured, redirect to dashboard + window.location.href = '/'; + } + }) + .catch(() => {}); + }, []); + + const addPlatform = useCallback((preset: (typeof PRESETS)[number]) => { + setPlatforms((prev) => [ + ...prev, + { name: preset.name, url: preset.url, key: '', orientation: 'horizontal' }, + ]); + }, []); + + const addCustomPlatform = useCallback(() => { + setPlatforms((prev) => [ + ...prev, + { name: 'Custom', url: '', key: '', orientation: 'horizontal' }, + ]); + }, []); + + const removePlatform = useCallback((idx: number) => { + setPlatforms((prev) => prev.filter((_, i) => i !== idx)); + }, []); + + const updatePlatform = useCallback( + (idx: number, field: keyof SetupPlatform, value: string) => { + setPlatforms((prev) => + prev.map((p, i) => (i === idx ? { ...p, [field]: value } : p)), + ); + }, + [], + ); + + const handleSave = useCallback(async () => { + setSaving(true); + setError(null); + try { + const res = await fetch('/api/setup/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + rtmp_port: parseInt(rtmpPort, 10), + stream_key: streamKey, + platforms: platforms.map((p) => ({ + name: p.name, + url: p.url, + key: p.key, + orientation: p.orientation, + })), + }), + }); + const data = await res.json(); + if (data.success) { + setStep('done'); + } else { + setError(data.error ?? 'Setup failed'); + } + } catch (e) { + setError(`Network error: ${e}`); + } finally { + setSaving(false); + } + }, [rtmpPort, streamKey, platforms]); + + const validPlatforms = platforms.filter((p) => p.url && p.key); + const canSave = streamKey.length > 0; + + return ( +
+
+ {/* Progress */} +
+ {(['welcome', 'server', 'platforms', 'confirm'] as Step[]).map((s, i) => { + const steps: Step[] = ['welcome', 'server', 'platforms', 'confirm']; + const currentIdx = steps.indexOf(step); + const active = s === step; + const done = i < currentIdx; + return ( +
+
+ {done ? '✓' : i + 1} +
+ {i < 3 &&
} +
+ ); + })} +
+ +
+ {/* Welcome */} + {step === 'welcome' && ( +
+
+ + + +
+

Welcome to Reestream

+

+ Let's set up your streaming relay. This wizard will configure your + RTMP server and output platforms. +

+ +
+ )} + + {/* Server Config */} + {step === 'server' && ( +
+

Server Configuration

+

Configure your RTMP server settings.

+ +
+
+ + setRtmpPort((e.target as HTMLInputElement).value)} + class="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-200 focus:outline-none focus:border-sky-500" + /> +

Default: 1935. Use 1935 for standard RTMP.

+
+ +
+ + setStreamKey((e.target as HTMLInputElement).value)} + placeholder="your-secret-stream-key" + class="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-200 focus:outline-none focus:border-sky-500" + /> +

This key is required to publish streams. Keep it secret.

+
+
+ +
+ + +
+
+ )} + + {/* Platforms */} + {step === 'platforms' && ( +
+

Output Platforms

+

Add streaming destinations. You can skip this and add them later.

+ + {/* Presets */} +
+ {PRESETS.map((p) => ( + + ))} + +
+ + {/* Platform list */} + {platforms.length === 0 ? ( +
+ No platforms added. You can add them later from the dashboard. +
+ ) : ( +
+ {platforms.map((p, i) => ( +
+
+ + +
+ updatePlatform(i, 'url', (e.target as HTMLInputElement).value)} + placeholder="rtmp://server/app" + class="w-full bg-slate-700 border border-slate-600 rounded px-3 py-1.5 text-sm text-slate-200 mb-2 focus:outline-none focus:border-sky-500" + /> + updatePlatform(i, 'key', (e.target as HTMLInputElement).value)} + placeholder={PRESETS.find((pr) => pr.name === p.name)?.placeholder ?? 'stream-key'} + class="w-full bg-slate-700 border border-slate-600 rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-sky-500" + /> +
+ + +
+
+ ))} +
+ )} + +
+ + +
+
+ )} + + {/* Confirm */} + {step === 'confirm' && ( +
+

Review Configuration

+

Confirm your settings before saving.

+ +
+
+
RTMP Port
+
{rtmpPort}
+
+
+
Stream Key
+
{'•'.repeat(Math.min(streamKey.length, 20))}
+
+
+
Platforms ({validPlatforms.length})
+ {validPlatforms.length === 0 ? ( +
None — add later from dashboard
+ ) : ( +
+ {validPlatforms.map((p, i) => ( +
+ {p.name} — {p.orientation} +
+ ))} +
+ )} +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+ )} + + {/* Done */} + {step === 'done' && ( +
+
+ + + +
+

Setup Complete!

+

+ Your Reestream server is configured and ready. +

+

+ Restart the server to apply the new configuration: +

+ + reestream --config config.toml + + + Open Dashboard + +
+ )} +
+
+
+ ); +} diff --git a/dashboard/src/components/StatsCards.tsx b/dashboard/src/components/StatsCards.tsx new file mode 100644 index 0000000..0de0672 --- /dev/null +++ b/dashboard/src/components/StatsCards.tsx @@ -0,0 +1,51 @@ +import type { ServerStatus } from '../api'; + +interface Props { + status: ServerStatus | null; + loading: boolean; +} + +function formatUptime(secs: number): string { + if (secs < 60) return `${secs}s`; + if (secs < 3600) return `${Math.floor(secs / 60)}m ${secs % 60}s`; + const h = Math.floor(secs / 3600); + const m = Math.floor((secs % 3600) / 60); + return `${h}h ${m}m`; +} + +export function StatsCards({ status, loading }: Props) { + if (loading && !status) { + return ( +
+ {Array.from({ length: 4 }, (_, i) => ( +
+
+
+
+ ))} +
+ ); + } + + const cards = [ + { label: 'Uptime', value: status ? formatUptime(status.uptime_seconds) : '--' }, + { label: 'Active Streams', value: status ? String(status.active_streams) : '0' }, + { label: 'Total Viewers', value: status ? String(status.total_viewers) : '0' }, + { + label: 'Status', + value: status ? 'Online' : '--', + color: status ? 'text-emerald-400' : 'text-slate-500', + }, + ]; + + return ( +
+ {cards.map((c) => ( +
+
{c.label}
+
{c.value}
+
+ ))} +
+ ); +} diff --git a/dashboard/src/components/StreamsTable.tsx b/dashboard/src/components/StreamsTable.tsx new file mode 100644 index 0000000..6e18142 --- /dev/null +++ b/dashboard/src/components/StreamsTable.tsx @@ -0,0 +1,80 @@ +import type { StreamInfo, StreamStatus } from '../api'; + +interface Props { + streams: StreamInfo[]; + loading: boolean; + onRefresh: () => void; +} + +function statusBadge(status: StreamStatus): { label: string; cls: string } { + if (typeof status === 'string') { + switch (status) { + case 'Live': + return { label: 'Live', cls: 'bg-emerald-900/60 text-emerald-400' }; + case 'Idle': + return { label: 'Idle', cls: 'bg-slate-800 text-slate-400 border border-slate-700' }; + default: + return { label: status, cls: 'bg-slate-800 text-slate-400' }; + } + } + return { label: `Error: ${status.Error}`, cls: 'bg-red-900/60 text-red-400' }; +} + +export function StreamsTable({ streams, loading, onRefresh }: Props) { + return ( +
+
+

Streams

+ +
+
+ + + + + + + + + + + + + {loading && streams.length === 0 ? ( + + + + ) : streams.length === 0 ? ( + + + + ) : ( + streams.map((s) => { + const badge = statusBadge(s.status); + return ( + + + + + + + + + ); + }) + )} + +
IDNameInputStatusViewersBitrate
Loading…
No streams
{s.id.slice(0, 8)}…{s.name}{s.input_url} + + {badge.label} + + {s.viewers}{s.bitrate} kbps
+
+
+ ); +} diff --git a/dashboard/src/components/VideoPreview.tsx b/dashboard/src/components/VideoPreview.tsx new file mode 100644 index 0000000..f4b5d8f --- /dev/null +++ b/dashboard/src/components/VideoPreview.tsx @@ -0,0 +1,184 @@ +import { useState } from 'preact/hooks'; +import { useVideoPlayer } from '../hooks/useVideoPlayer'; + +interface Props { + streams: Array<{ id: string; name: string; status: string }>; +} + +type StreamSource = 'flv' | 'hls'; + +export function VideoPreview({ streams }: Props) { + const [source, setSource] = useState('flv'); + const [selectedStream, setSelectedStream] = useState(''); + + const liveStream = streams.find( + (s) => s.status === 'Live' || (typeof s.status === 'object' && 'Live' in s.status), + ); + + const streamToWatch = selectedStream || liveStream?.id || ''; + + const url = streamToWatch + ? source === 'flv' + ? '/stream.flv' + : '/stream.m3u8' + : ''; + + const { videoRef, playing, error, latency, playerType, toggle } = useVideoPlayer({ + url, + autoplay: true, + muted: true, + lowLatency: true, + }); + + const hasLive = !!liveStream; + + return ( +
+
+

Stream Preview

+
+
+ + +
+ {streams.length > 1 && ( + + )} +
+
+ +
+ {!hasLive && !url ? ( +
+
+ + + +

No live stream to preview

+

+ Start a stream to see the preview here +

+
+
+ ) : ( +
+
+ )} +
+
+ ); +} diff --git a/dashboard/src/components/index.ts b/dashboard/src/components/index.ts new file mode 100644 index 0000000..da24760 --- /dev/null +++ b/dashboard/src/components/index.ts @@ -0,0 +1,9 @@ +export { Header } from './Header'; +export { StatsCards } from './StatsCards'; +export { StreamsTable } from './StreamsTable'; +export { PlatformsTable } from './PlatformsTable'; +export { LogViewer, useLogger } from './LogViewer'; +export { VideoPreview } from './VideoPreview'; +export { SetupWizard } from './SetupWizard'; +export { SettingsPanel } from './SettingsPanel'; +export { RecordingControls } from './RecordingControls'; diff --git a/dashboard/src/hooks/index.ts b/dashboard/src/hooks/index.ts new file mode 100644 index 0000000..70c871f --- /dev/null +++ b/dashboard/src/hooks/index.ts @@ -0,0 +1,2 @@ +export { usePolling } from './usePolling'; +export { useVideoPlayer } from './useVideoPlayer'; diff --git a/dashboard/src/hooks/usePolling.ts b/dashboard/src/hooks/usePolling.ts new file mode 100644 index 0000000..8b57a6e --- /dev/null +++ b/dashboard/src/hooks/usePolling.ts @@ -0,0 +1,29 @@ +import { useState, useEffect, useCallback } from 'preact/hooks'; + +export function usePolling( + fetcher: () => Promise, + intervalMs: number, +): { data: T | null; loading: boolean; error: string | null; refresh: () => void } { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refresh = useCallback(() => { + setLoading(true); + fetcher() + .then((d) => { + setData(d); + setError(null); + }) + .catch((e: Error) => setError(e.message)) + .finally(() => setLoading(false)); + }, [fetcher]); + + useEffect(() => { + refresh(); + const id = setInterval(refresh, intervalMs); + return () => clearInterval(id); + }, [refresh, intervalMs]); + + return { data, loading, error, refresh }; +} diff --git a/dashboard/src/hooks/useVideoPlayer.ts b/dashboard/src/hooks/useVideoPlayer.ts new file mode 100644 index 0000000..a2aea9d --- /dev/null +++ b/dashboard/src/hooks/useVideoPlayer.ts @@ -0,0 +1,156 @@ +import { useRef, useEffect, useState, useCallback } from 'preact/hooks'; +import type { RefObject } from 'preact'; + +type PlayerType = 'flv' | 'native'; + +interface UsePlayerOptions { + url: string; + autoplay?: boolean; + muted?: boolean; + lowLatency?: boolean; +} + +interface UsePlayerReturn { + videoRef: RefObject; + playing: boolean; + error: string | null; + latency: number; + playerType: PlayerType; + play: () => void; + pause: () => void; + toggle: () => void; +} + +export function useVideoPlayer(opts: UsePlayerOptions): UsePlayerReturn { + const videoRef = useRef(null); + const [playing, setPlaying] = useState(false); + const [error, setError] = useState(null); + const [latency, setLatency] = useState(0); + const [playerType, setPlayerType] = useState('native'); + const flvPlayerRef = useRef<{ destroy?: () => void } | null>(null); + + useEffect(() => { + const video = videoRef.current; + if (!video || !opts.url) return; + + let destroyed = false; + setError(null); + + const isFlv = opts.url.endsWith('.flv'); + + async function initFlv() { + try { + const flvjs = await import('flv.js'); + if (destroyed) return; + + if (!flvjs.default.isSupported()) { + setError('FLV.js not supported in this browser'); + return; + } + + const player = flvjs.default.createPlayer( + { + type: 'flv', + url: opts.url, + isLive: true, + }, + { + enableWorker: true, + enableStashBuffer: false, + stashInitialSize: 128, + lazyLoad: false, + lazyLoadMaxDuration: 0.2, + deferLoadAfterSourceOpen: false, + autoCleanupSourceBuffer: true, + autoCleanupMaxBackwardDuration: 3, + autoCleanupMinBackwardDuration: 1, + fixAudioTimestampGap: true, + seekType: 'range', + }, + ); + + player.attachMediaElement(video!); + player.load(); + + if (opts.autoplay !== false) { + try { + await video!.play(); + setPlaying(true); + } catch { + video!.muted = true; + await video!.play().catch(() => {}); + setPlaying(true); + } + } + + flvPlayerRef.current = player; + setPlayerType('flv'); + } catch (e) { + if (!destroyed) setError(`FLV init failed: ${e}`); + } + } + + function initNative() { + video!.src = opts.url; + video!.load(); + + if (opts.autoplay !== false) { + video!.play().catch(() => { + video!.muted = true; + video!.play().catch(() => {}); + }); + } + setPlayerType('native'); + } + + if (isFlv) { + initFlv(); + } else { + initNative(); + } + + const onPlay = () => setPlaying(true); + const onPause = () => setPlaying(false); + const onError = () => setError(`Video error: ${video!.error?.message ?? 'unknown'}`); + + video!.addEventListener('play', onPlay); + video!.addEventListener('pause', onPause); + video!.addEventListener('error', onError); + + const interval = setInterval(() => { + if (destroyed || !video!.buffered.length) return; + const behind = video!.buffered.end(video!.buffered.length - 1) - video!.currentTime; + setLatency(Math.max(0, behind)); + }, 500); + + return () => { + destroyed = true; + clearInterval(interval); + video!.removeEventListener('play', onPlay); + video!.removeEventListener('pause', onPause); + video!.removeEventListener('error', onError); + video!.pause(); + video!.src = ''; + + if (flvPlayerRef.current && typeof flvPlayerRef.current.destroy === 'function') { + flvPlayerRef.current.destroy(); + flvPlayerRef.current = null; + } + }; + }, [opts.url, opts.autoplay]); + + const play = useCallback(() => { + videoRef.current?.play().catch(() => {}); + }, []); + + const pause = useCallback(() => { + videoRef.current?.pause(); + }, []); + + const toggle = useCallback(() => { + if (playing) pause(); + else play(); + }, [playing, play, pause]); + + return { videoRef, playing, error, latency, playerType, play, pause, toggle }; +} diff --git a/dashboard/src/index.css b/dashboard/src/index.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/dashboard/src/index.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/dashboard/src/main.tsx b/dashboard/src/main.tsx new file mode 100644 index 0000000..51215c8 --- /dev/null +++ b/dashboard/src/main.tsx @@ -0,0 +1,8 @@ +import { render } from 'preact'; +import { App } from './app'; +import './index.css'; + +const root = document.getElementById('app'); +if (root) { + render(, root); +} diff --git a/dashboard/tsconfig.app.json b/dashboard/tsconfig.app.json new file mode 100644 index 0000000..3b144be --- /dev/null +++ b/dashboard/tsconfig.app.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "module": "esnext", + "lib": ["ES2023", "DOM"], + "types": ["vite/client"], + "skipLibCheck": true, + "paths": { + "react": ["./node_modules/preact/compat/"], + "react-dom": ["./node_modules/preact/compat/"] + }, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "jsxImportSource": "preact", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/dashboard/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/dashboard/tsconfig.node.json b/dashboard/tsconfig.node.json new file mode 100644 index 0000000..d3c52ea --- /dev/null +++ b/dashboard/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts new file mode 100644 index 0000000..b99bbb4 --- /dev/null +++ b/dashboard/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite' +import preact from '@preact/preset-vite' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [preact(), tailwindcss()], + build: { + outDir: '../crates/reestream-server/static', + emptyOutDir: true, + }, +}) diff --git a/readme.md b/readme.md deleted file mode 100644 index 745acfe..0000000 --- a/readme.md +++ /dev/null @@ -1,60 +0,0 @@ -# Reestream - RTMP Multistream Demuxer - -## Overview -RTMP relay server that receives a single stream and forwards to multiple platforms (Twitch, Facebook, Instagram, YouTube). - -## Architecture - -### Components -- **main.rs**: TCP listener, connection handling, config loading -- **server.rs**: RTMP handshake, server session setup (low-latency config) -- **client.rs**: Publisher connection handling, stream forwarding to platforms -- **config.rs**: TOML-based configuration parsing -- **provider.rs**: Stream key provider abstraction (OAuth2) -- **error.rs**: Centralized error types - -### Flow -1. Listen on RTMP port (default 1945) -2. Accept publisher connection -3. Perform RTMP handshake -4. Validate stream key -5. Forward packets to all configured platforms (RTMP/RTMPS) - -## Configuration - -### config.toml -```toml -rtmp_addr = "0.0.0.0" -rtmp_port = 1945 -stream_key = "your-key" - -[[platform]] -url = "rtmp://live.twitch.tv/app" -key = "stream-key" -orientation = "horizontal" # or "vertical" -``` - -### CLI -```bash -reestream --config config.toml -``` - -## Specs - -### Low Latency -- Chunk size: 128 bytes -- ACK window: 256KB -- TCP_NODELAY enabled - -### Supported Protocols -- Input: RTMP -- Output: RTMP, RTMPS (via tokio-native-tls) - -### Platforms -Pre-configured for: Twitch, Facebook, Instagram, YouTube. Extensible via config. - -## Dependencies -- rml_rtmp: RTMP protocol -- tokio: Async runtime -- reqwest: HTTP client (OAuth2) -- toml: Config parsing \ No newline at end of file diff --git a/src/client.rs b/src/client.rs deleted file mode 100644 index fcf7ce2..0000000 --- a/src/client.rs +++ /dev/null @@ -1,358 +0,0 @@ -mod push; - -use std::sync::Arc; -use std::time::Duration; - -use bytes::Bytes; -pub use push::PushClient; -use rml_rtmp::handshake::{Handshake, HandshakeProcessResult, PeerType}; -use rml_rtmp::sessions::{ClientSessionResult, ServerSessionEvent, ServerSessionResult}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpStream; -use tokio::sync::RwLock; -use tokio::time::timeout; -use tracing::{debug, error, info, warn}; - -use crate::DynStream; -use crate::config::Platform; -use crate::server::handshake_and_create_server_session; - -async fn perform_client_handshake( - stream: &mut DynStream, -) -> Result<(), Box> { - let mut hs = Handshake::new(PeerType::Client); - let c0_c1 = hs.generate_outbound_p0_and_p1()?; - stream.write_all(&c0_c1).await?; - - let mut buf = [0u8; 4096]; - loop { - let n = stream.read(&mut buf).await?; - if n == 0 { - return Err("EOF during client handshake".into()); - } - - match hs.process_bytes(&buf[..n])? { - HandshakeProcessResult::InProgress { response_bytes } => { - if !response_bytes.is_empty() { - stream.write_all(&response_bytes).await?; - } - } - HandshakeProcessResult::Completed { response_bytes, .. } => { - if !response_bytes.is_empty() { - stream.write_all(&response_bytes).await?; - } - break; - } - } - } - Ok(()) -} - -pub async fn handle_publisher( - mut inbound: TcpStream, - platforms: Arc>>, - stream_key_conf: String, -) -> Result<(), Box> { - info!("Iniciando handshake servidor con client entrante..."); - let (mut server_session, leftover) = handshake_and_create_server_session(&mut inbound).await?; - info!("Handshake completado; esperando publish request..."); - - // Cargamos la lista de plataformas pero NO creamos conexiones push todavía. - let pls = platforms.read().await.clone(); - // Aquí guardaremos los push clients una vez que aceptemos el publish del publisher. - let mut push_clients: Vec = Vec::new(); - - // Si hubo bytes sobrantes tras el handshake, procesarlos. - // Es posible que entre esos bytes ya venga la solicitud de publish; se procesará - // y el branch PublishStreamRequested creará los push clients cuando corresponda. - - if !leftover.is_empty() { - let results = server_session.handle_input(&leftover)?; - for res in results { - match res { - ServerSessionResult::OutboundResponse(packet) => { - if let Err(e) = inbound.write_all(&packet.bytes).await { - error!( - "Error escribiendo respuesta al publisher (leftover): {:?}", - e - ); - } - } - ServerSessionResult::RaisedEvent(ev) => { - debug!("Server session event (leftover): {:?}", ev); - } - _ => {} - } - } - } - - let mut read_buf = [0u8; 8192]; - loop { - let n = inbound.read(&mut read_buf).await?; - if n == 0 { - info!("Publisher cerró la conexión"); - break; - } - - let results = match server_session.handle_input(&read_buf[..n]) { - Ok(r) => r, - Err(e) => { - error!("Error procesando bytes en ServerSession: {:?}", e); - break; - } - }; - - for res in results { - match res { - // Respuestas del ServerSession van sólo al publisher. - ServerSessionResult::OutboundResponse(packet) => { - if let Err(e) = inbound.write_all(&packet.bytes).await { - error!("Error escribiendo al publisher: {:?}", e); - } - } - - ServerSessionResult::RaisedEvent(ev) => match ev { - ServerSessionEvent::ConnectionRequested { - request_id, - app_name: _, - } => { - if let Ok(out) = server_session.accept_request(request_id) { - for r in out { - if let ServerSessionResult::OutboundResponse(packet) = r { - let _ = inbound.write_all(&packet.bytes).await; - } - } - } - } - - ServerSessionEvent::PublishStreamRequested { - request_id, - app_name, - stream_key, - .. - } => { - // Validamos la stream key antes de aceptar y antes de iniciar retransmisiones. - if stream_key == stream_key_conf { - // Aceptamos la petición del publisher - if let Ok(out) = server_session.accept_request(request_id) { - for r in out { - if let ServerSessionResult::OutboundResponse(packet) = r { - let _ = inbound.write_all(&packet.bytes).await; - } - } - } - info!( - "Publisher accepted publish app='{}' stream='{}'", - app_name, stream_key - ); - - // Ahora que el publisher está autorizado, creamos los push clients - // (si no existen ya). Esto evita iniciar retransmisiones para keys inválidas. - if push_clients.is_empty() { - for p in pls.iter() { - if !["rtmp", "rtmps"].contains(&p.url.scheme()) { - info!( - "Ignorando plataforma con esquema distinto a rtmp/rtmps: {}", - p.url - ); - continue; - } - - let host = match p.url.host_str() { - Some(h) => h.to_string(), - None => { - warn!("URL sin host válido: {}", p.url); - continue; - } - }; - let port = p.url.port_or_known_default().unwrap_or(1935); - let addr = format!("{host}:{port}"); - - match timeout( - Duration::from_secs(10), - PushClient::connect_and_publish(&p.url, p.key.clone()), - ) - .await - { - Ok(Ok(push_client)) => { - info!("Push client activo hacia {addr}"); - push_clients.push(push_client); - } - Ok(Err(e)) => { - warn!( - "Push client falló al iniciar hacia {}: {}", - p.url, e - ); - } - Err(_) => { - warn!("Timeout conectando a {addr} para push"); - } - } - } - - if push_clients.is_empty() { - warn!( - "No se pudieron iniciar push clients tras aceptar publish; se continuará pero no habrá retransmisión." - ); - } else { - info!( - "Retransmisiones iniciadas a {} plataformas", - push_clients.len() - ); - } - } else { - debug!("Push clients ya estaban creados; no se recrean."); - } - } else { - // Rechazamos la petición si la stream key no coincide con la configurada. - match server_session.reject_request( - request_id, - "NetStream.Publish.BadName", - "Invalid stream key", - ) { - Ok(out) => { - for r in out { - if let ServerSessionResult::OutboundResponse(packet) = r { - let _ = inbound.write_all(&packet.bytes).await; - } - } - } - Err(e) => { - warn!("Error rejecting publish request: {:?}", e); - } - } - info!( - "Publish rejected for invalid stream key '{}'; cerrando conexión.", - stream_key - ); - // Cerrar la conexión del publisher y terminar la función: no habrá retransmisiones. - return Ok(()); - } - } - - ServerSessionEvent::VideoDataReceived { - data, timestamp, .. - } => { - for pc in push_clients.iter() { - if *pc.publish_ready_rx.borrow() { - let mut state = pc.client_state.write().await; - match state.session.publish_video_data( - data.clone(), - timestamp, - true, - ) { - Ok(ClientSessionResult::OutboundResponse(packet)) => { - if let Err(e) = - pc.tx_feed.try_send(Bytes::from(packet.bytes.clone())) - { - debug!( - "Dropped publish_video_data packet for push client: {}", - e - ); - } - } - Ok(_) => {} - Err(e) => { - error!("Error publish_video_data to push client: {:?}", e); - } - } - drop(state); - } else { - let mut state = pc.client_state.write().await; - if state.prepublish_video_buffer.len() < 128 { - state.prepublish_video_buffer.push_back(data.clone()); - } else { - state.prepublish_video_buffer.pop_front(); - state.prepublish_video_buffer.push_back(data.clone()); - } - drop(state); - } - } - } - - ServerSessionEvent::AudioDataReceived { - data, timestamp, .. - } => { - for pc in push_clients.iter() { - if *pc.publish_ready_rx.borrow() { - let mut state = pc.client_state.write().await; - match state.session.publish_audio_data( - data.clone(), - timestamp, - true, - ) { - Ok(ClientSessionResult::OutboundResponse(packet)) => { - if let Err(e) = - pc.tx_feed.try_send(Bytes::from(packet.bytes.clone())) - { - debug!( - "Dropped publish_audio_data packet for push client: {}", - e - ); - } - } - Ok(_) => {} - Err(e) => { - error!("Error publish_audio_data to push client: {:?}", e); - } - } - drop(state); - } else { - let mut state = pc.client_state.write().await; - if state.prepublish_audio_buffer.len() < 128 { - state.prepublish_audio_buffer.push_back(data.clone()); - } else { - state.prepublish_audio_buffer.pop_front(); - state.prepublish_audio_buffer.push_back(data.clone()); - } - drop(state); - } - } - } - - ServerSessionEvent::StreamMetadataChanged { metadata, .. } => { - for pc in push_clients.iter() { - if *pc.publish_ready_rx.borrow() { - let mut state = pc.client_state.write().await; - match state.session.publish_metadata(&metadata) { - Ok(client_res) => { - if let ClientSessionResult::OutboundResponse(packet) = - client_res - && let Err(e) = pc - .tx_feed - .try_send(Bytes::from(packet.bytes.clone())) - { - debug!( - "Dropped publish_metadata packet for push client: {}", - e - ); - } - } - Err(e) => { - error!("Error publish_metadata to push client: {:?}", e); - } - } - drop(state); - } else { - let mut state = pc.client_state.write().await; - state.prepublish_metadata = Some(metadata.clone()); - drop(state); - } - } - } - - _ => {} - }, - - other => { - debug!("Other server result: {:?}", other); - } - } - } - - // NOTE: Do NOT feed publisher bytes into push clients' client sessions. - // Each PushClient reads its own remote socket and advances its ClientSession there. - } - - Ok(()) -} diff --git a/src/client/push.rs b/src/client/push.rs deleted file mode 100644 index 24c695c..0000000 --- a/src/client/push.rs +++ /dev/null @@ -1,292 +0,0 @@ -use std::collections::VecDeque; -use std::panic::{AssertUnwindSafe, catch_unwind}; -use std::sync::Arc; - -use bytes::Bytes; -use rml_rtmp::sessions::{ - ClientSession, ClientSessionConfig, ClientSessionEvent, ClientSessionResult, - PublishRequestType, StreamMetadata, -}; -use rml_rtmp::time::RtmpTimestamp; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpStream; -use tokio::sync::{RwLock, mpsc, watch}; -use tokio_native_tls::{TlsConnector, native_tls}; -use tracing::{debug, error, info, trace, warn}; -use url::Url; - -use crate::DynStream; -use crate::client::perform_client_handshake; - -pub struct PushClient { - pub(crate) tx_feed: mpsc::Sender, // bounded to avoid unbounded memory growth - pub(crate) client_state: Arc>, - pub(crate) publish_ready_rx: watch::Receiver, -} - -pub struct ClientStateWrapper { - pub session: ClientSession, - pub target_stream: String, - pub prepublish_video_buffer: VecDeque, - pub prepublish_audio_buffer: VecDeque, - pub prepublish_metadata: Option, -} - -impl PushClient { - pub async fn connect_and_publish( - url: &Url, - stream_key: String, - ) -> Result> { - let host = url - .host_str() - .ok_or_else(|| format!("URL sin host válido: {url}"))? - .to_string(); - - let port = if url.scheme() == "rtmps" { - 443 - } else { - url.port_or_known_default().unwrap_or(1935) - }; - - let addr = format!("{host}:{port}"); - - info!("Conectando push client a {addr}"); - - // Connect TCP - let tcp_stream = TcpStream::connect(&addr).await?; - // reduce latency on TCP - if let Err(e) = tcp_stream.set_nodelay(true) { - warn!( - "No se pudo set_nodelay al tcp de push client {}: {}", - addr, e - ); - } - - // Wrap TLS if needed - let mut boxed: DynStream = if url.scheme() == "rtmps" { - let native = native_tls::TlsConnector::builder() - .danger_accept_invalid_certs(true) - .build()?; - let connector = TlsConnector::from(native); - let tls_stream = connector.connect(&host, tcp_stream).await?; - Box::new(tls_stream) - } else { - Box::new(tcp_stream) - }; - - perform_client_handshake(&mut boxed).await?; - info!("Handshake cliente completo hacia {}", addr); - - // split reader/writer - let (mut rd_half, mut wr_half) = tokio::io::split(boxed); - - // client config with lower chunk size - let mut client_cfg = ClientSessionConfig::new(); - client_cfg.chunk_size = 128; - let path = url.path().trim_start_matches('/'); - let app_segment = path.split('/').next().unwrap_or(""); - let tcurl = if app_segment.is_empty() { - format!("rtmp://{}:{}/", host, port) - } else { - format!("rtmp://{}:{}/{}", host, port, app_segment) - }; - client_cfg.tc_url = Some(tcurl.clone()); - - let (mut client_session, initial_results) = ClientSession::new(client_cfg)?; - - // bounded channel: capacity 256 to avoid unbounded growth when a remote is slow - let (tx, mut rx) = mpsc::channel::(256); - let writer_addr = addr.clone(); - tokio::spawn(async move { - while let Some(bytes) = rx.recv().await { - if let Err(e) = wr_half.write_all(&bytes).await { - error!("Error escribiendo a push target {}: {}", writer_addr, e); - break; - } - } - info!("Writer task terminado para push target {}", writer_addr); - }); - - // send initial results (use try_send, drop if full) - for r in initial_results { - if let ClientSessionResult::OutboundResponse(packet) = r - && let Err(e) = tx.try_send(Bytes::from(packet.bytes.clone())) - { - debug!("Dropped initial packet for {}: {}", addr, e); - } - } - - // request connection (app extracted from URL) - let app = app_segment.to_string(); - match client_session.request_connection(app.clone()) { - Ok(ClientSessionResult::OutboundResponse(packet)) => { - if let Err(e) = tx.try_send(Bytes::from(packet.bytes.clone())) { - debug!("Dropped connect packet for {}: {}", addr, e); - } - } - Ok(_) => {} - Err(e) => { - return Err(format!("request_connection error: {:?}", e).into()); - } - } - - let client_state = ClientStateWrapper { - session: client_session, - target_stream: stream_key.clone(), - prepublish_video_buffer: VecDeque::new(), - prepublish_audio_buffer: VecDeque::new(), - prepublish_metadata: None, - }; - - let client_state = Arc::new(RwLock::new(client_state)); - let (publish_ready_tx, publish_ready_rx) = watch::channel(false); - - // reader task: advance client session based on remote responses - { - let client_state_reader = client_state.clone(); - let tx_clone = tx.clone(); - let addr_clone = addr.clone(); - let publish_ready_tx = publish_ready_tx.clone(); - - tokio::spawn(async move { - let mut buf = [0u8; 8192]; - loop { - match rd_half.read(&mut buf).await { - Ok(0) => { - info!("Push target {} cerró la conexión (reader)", addr_clone); - break; - } - Ok(n) => { - let mut state = client_state_reader.write().await; - - // Protect handle_input from unwinding panics inside the library - let res = catch_unwind(AssertUnwindSafe(|| { - state.session.handle_input(&buf[..n]) - })); - match res { - Ok(Ok(results)) => { - for r in results { - match r { - ClientSessionResult::OutboundResponse(packet) => { - if let Err(e) = tx_clone - .try_send(Bytes::from(packet.bytes.clone())) - { - debug!( - "Dropped outbound packet to {}: {}", - addr_clone, e - ); - } - } - ClientSessionResult::RaisedEvent(ev) => { - trace!("Push client evento: {:?}", ev); - match ev { - ClientSessionEvent::ConnectionRequestAccepted => { - let stream_key = state.target_stream.clone(); - match state.session.request_publishing(stream_key, PublishRequestType::Live) { - Ok(ClientSessionResult::OutboundResponse(pub_packet)) => { - if let Err(e) = tx_clone.try_send(Bytes::from(pub_packet.bytes.clone())) { - debug!("Dropped publish request packet for {}: {}", addr_clone, e); - } - } - Ok(_) => {} - Err(e) => { - error!("request_publishing fallo: {:?}", e); - } - } - } - ClientSessionEvent::PublishRequestAccepted => { - info!("Push client publish accepted for {}", state.target_stream); - // mark ready - let _ = publish_ready_tx.send(true); - - // send buffered metadata if any - if let Some(meta) = state.prepublish_metadata.take() { - match state.session.publish_metadata(&meta) { - Ok(ClientSessionResult::OutboundResponse(packet)) => { - if let Err(e) = tx_clone.try_send(Bytes::from(packet.bytes.clone())) { - debug!("Dropped buffered metadata packet for {}: {}", addr_clone, e); - } - } - Ok(_) => {} - Err(e) => { - error!("Error sending buffered metadata: {:?}", e); - } - } - } - - // drain buffered video - while let Some(vframe) = state.prepublish_video_buffer.pop_front() { - match state.session.publish_video_data(vframe.clone(), RtmpTimestamp::new(0), true) { - Ok(ClientSessionResult::OutboundResponse(packet)) => { - if let Err(e) = tx_clone.try_send(Bytes::from(packet.bytes.clone())) { - debug!("Dropped buffered video packet for {}: {}", addr_clone, e); - } - } - Ok(_) => {} - Err(e) => { - error!("Error sending buffered video frame: {:?}", e); - break; - } - } - } - - // drain buffered audio - while let Some(aframe) = state.prepublish_audio_buffer.pop_front() { - match state.session.publish_audio_data(aframe.clone(), RtmpTimestamp::new(0), true) { - Ok(ClientSessionResult::OutboundResponse(packet)) => { - if let Err(e) = tx_clone.try_send(Bytes::from(packet.bytes.clone())) { - debug!("Dropped buffered audio packet for {}: {}", addr_clone, e); - } - } - Ok(_) => {} - Err(e) => { - error!("Error sending buffered audio frame: {:?}", e); - break; - } - } - } - } - _ => {} - } - } - other => { - debug!("Push client other result: {:?}", other); - } - } - } - } - Ok(Err(e)) => { - error!( - "Error manejando input en ClientSession (push): {:?}", - e - ); - break; - } - Err(panic_err) => { - error!( - "panic al procesar ClientSession::handle_input (push): {:?}", - panic_err - ); - break; - } - } - - drop(state); - } - Err(e) => { - error!("Error leyendo desde push target {}: {}", addr_clone, e); - break; - } - } - } - info!("Reader task terminado para push target {}", addr_clone); - }); - } - - Ok(PushClient { - tx_feed: tx, - client_state, - publish_ready_rx, - }) - } -} diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index ea48592..0000000 --- a/src/config.rs +++ /dev/null @@ -1,35 +0,0 @@ -use serde::Deserialize; -use std::fs; -use std::path::Path; -use url::Url; - -#[derive(Clone, Debug, Deserialize)] -pub struct Config { - pub rtmp_addr: String, - pub rtmp_port: u16, - pub stream_key: String, - pub platform: Option>, -} - -#[derive(Debug, Deserialize, Clone)] -pub struct Platform { - pub url: Url, - pub key: String, - pub _orientation: Orientation, -} - -#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq)] -#[serde(rename_all = "lowercase")] -pub enum Orientation { - #[default] - Horizontal, - Vertical, -} - -impl Config { - pub fn from_file>(path: P) -> Result> { - let contents = fs::read_to_string(path)?; - let config: Config = toml::from_str(&contents)?; - Ok(config) - } -} diff --git a/src/error.rs b/src/error.rs deleted file mode 100644 index 91a3555..0000000 --- a/src/error.rs +++ /dev/null @@ -1,46 +0,0 @@ -use std::fmt; - -#[derive(Debug)] -#[allow(dead_code)] -pub enum RelayError { - Io(std::io::Error), - Tls(tokio_native_tls::native_tls::Error), - Handshake(String), - Session(String), - Connection(String), - Timeout(String), - InvalidConfig(String), - PublishRejected(String), -} - -impl fmt::Display for RelayError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Io(e) => write!(f, "IO error: {e}"), - Self::Handshake(msg) => write!(f, "Handshake error: {msg}"), - Self::Session(msg) => write!(f, "Session error: {msg}"), - Self::Connection(msg) => write!(f, "Connection error: {msg}"), - Self::Timeout(msg) => write!(f, "Timeout: {msg}"), - Self::InvalidConfig(msg) => write!(f, "Invalid config: {msg}"), - Self::PublishRejected(msg) => write!(f, "Publish rejected: {msg}"), - Self::Tls(error) => write!(f, "Tls on rtmps: {error}"), - } - } -} - -impl std::error::Error for RelayError {} - -impl From for RelayError { - fn from(e: std::io::Error) -> Self { - Self::Io(e) - } -} - -impl From for RelayError { - fn from(e: tokio_native_tls::native_tls::Error) -> Self { - Self::Tls(e) - } -} - -#[allow(dead_code)] -pub type Result = std::result::Result; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..e67d8a2 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,11 @@ +#[cfg(feature = "core")] +pub use reestream_core::*; + +#[cfg(feature = "ffmpeg")] +pub use reestream_ffmpeg as ffmpeg; + +#[cfg(any(feature = "hls", feature = "api"))] +pub use reestream_server as http_server; + +#[cfg(feature = "srt")] +pub use reestream_srt as srt; diff --git a/src/main.rs b/src/main.rs index fa065e1..676a798 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,43 +2,80 @@ use clap::Parser; use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Arc; -use tokio::io::{AsyncRead, AsyncWrite}; use tokio::net::TcpListener; use tokio::sync::RwLock; use tracing::{error, info, warn}; -use tracing_subscriber::filter::LevelFilter; +use tracing_subscriber::EnvFilter; -mod client; -mod config; -mod error; -mod provider; -mod server; - -use crate::client::handle_publisher; -use crate::config::Config; - -pub trait AsyncReadWrite: AsyncRead + AsyncWrite + Send + Unpin {} - -impl AsyncReadWrite for T {} - -pub type DynStream = Box; +use reestream::client::handle_publisher; +use reestream::config::Config; #[derive(clap::Parser)] struct Args { /// Define config.toml path #[clap(long, short, default_value = "config.toml")] config: PathBuf, + + /// Enable JSON structured logging + #[clap(long)] + json_log: bool, + + /// Log level (trace, debug, info, warn, error) + #[clap(long, default_value = "info")] + log_level: String, + + /// Run interactive first-time setup wizard + #[clap(long)] + setup: bool, } #[tokio::main] async fn main() -> Result<(), Box> { - tracing_subscriber::fmt() - .with_line_number(true) - .with_max_level(LevelFilter::DEBUG) - .init(); - let args = Args::parse(); - let config = Config::from_file(args.config)?; + + if args.setup { + return run_setup(&args.config); + } + + if reestream::setup::is_first_run(&args.config) { + eprintln!("No config file found at '{}'.", args.config.display()); + eprintln!( + "Run with --setup to create one, or open http://localhost:8080 for the web setup." + ); + eprintln!(); + eprintln!(" reestream --setup"); + eprintln!(); + + // Create minimal config so the server can start and serve the dashboard + let default_config = reestream::config::ConfigBuilder::new() + .stream_key("") + .build(); + let toml = default_config.to_toml()?; + std::fs::write(&args.config, toml)?; + eprintln!( + "Created minimal config at '{}' — starting server for web setup.", + args.config.display() + ); + eprintln!(); + } + + let env_filter = + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&args.log_level)); + + if args.json_log { + tracing_subscriber::fmt() + .json() + .with_env_filter(env_filter) + .with_line_number(true) + .init(); + } else { + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .with_line_number(true) + .init(); + } + + let config = Config::from_file(&args.config)?; let Config { rtmp_addr, rtmp_port, @@ -47,55 +84,212 @@ async fn main() -> Result<(), Box> { .. } = &config; - println!("Configuración cargada:"); - println!(" Listener: {rtmp_addr}:{rtmp_port}",); - println!(" Stream key: {stream_key}"); - println!( - " Plataformas configuradas: {}", - platform.clone().unwrap_or_default().len() + info!( + addr = %rtmp_addr, + port = %rtmp_port, + platforms = %platform.clone().unwrap_or_default().len(), + "Configuration loaded" ); let addr: SocketAddr = format!("{rtmp_addr}:{rtmp_port}").parse()?; let listener = TcpListener::bind(addr).await?; - info!("RTMP relay escuchando en {}", addr); + info!("RTMP relay listening on {}", addr); let platforms = Arc::new(RwLock::new(platform.clone().unwrap_or_default())); + let shutdown = Arc::new(reestream::hardening::GracefulShutdown::new()); + reestream::hardening::setup_signal_handlers(shutdown.clone()).await; + + #[cfg(feature = "srt")] + { + let srt_config = reestream::srt::SrtConfig { + enabled: true, + listen_port: 3000, + ..Default::default() + }; + if srt_config.enabled { + let srt_listener = Arc::new(reestream::srt::SrtListener::new(srt_config)); + let srt_l = srt_listener.clone(); + tokio::spawn(async move { + if let Err(e) = srt_l.run().await { + error!("SRT listener error: {}", e); + } + }); + info!("SRT listener started on port 3000"); + } + } + + let connection_pool = Arc::new(reestream::hardening::ConnectionPool::new(1000)); + let rate_limiter = Arc::new(reestream::hardening::RateLimiter::new(100)); + + #[cfg(any(feature = "hls", feature = "api"))] + { + let hls_config = reestream::http_server::hls::HlsConfig::default(); + let recording_config = reestream::http_server::recording::RecordingConfig { + enabled: true, + output_dir: std::path::PathBuf::from("/tmp/reestream/recordings"), + ..Default::default() + }; + let app_state = reestream::http_server::http::AppState { + stream_manager: Arc::new(reestream::http_server::stream::StreamManager::new()), + hls_segmenter: Arc::new(reestream::http_server::hls::HlsSegmenter::new(hls_config)), + flv_state: reestream::http_server::flv::FlvState::default(), + recording_manager: Arc::new(reestream::http_server::recording::RecordingManager::new( + recording_config, + )), + start_time: std::time::Instant::now(), + config_path: args.config.clone(), + }; + tokio::spawn(async move { + if let Err(e) = + reestream::http_server::http::start_http_server("0.0.0.0", 8080, app_state).await + { + error!("HTTP server error: {}", e); + } + }); + info!("HTTP server starting on 0.0.0.0:8080"); + } + + if !stream_key.is_empty() { + info!("Open http://localhost:8080 for the dashboard"); + } else { + warn!("No stream key configured — open http://localhost:8080/setup to complete setup"); + } + loop { tokio::select! { biased; - _ = tokio::signal::ctrl_c() => { - info!("Recibida señal Ctrl+C, cerrando servidor..."); + _ = shutdown.wait_for_shutdown() => { + info!("Graceful shutdown initiated, draining connections..."); + let drained = shutdown.drain_timeout(std::time::Duration::from_secs(30)).await; + if drained { + info!("All connections drained successfully"); + } else { + warn!("Shutdown timeout reached, forcing exit"); + } break; } accept = listener.accept() => { match accept { Ok((socket, peer_addr)) => { - // reduce latency: disable Nagle on incoming socket + if !rate_limiter.try_acquire().await { + warn!("Rate limit exceeded, rejecting connection from {}", peer_addr); + continue; + } + + let _guard = match connection_pool.try_acquire().await { + Some(g) => g, + None => { + warn!("Connection pool full, rejecting connection from {}", peer_addr); + continue; + } + }; + if let Err(e) = socket.set_nodelay(true) { - warn!("No se pudo set_nodelay al socket entrante: {}", e); + warn!("Failed to set_nodelay on incoming socket: {}", e); } - info!("Nueva conexión entrante desde {}", peer_addr); + info!("New incoming connection from {}", peer_addr); let platforms = platforms.clone(); let stream_key = stream_key.clone(); tokio::spawn(async move { if let Err(e) = handle_publisher(socket, platforms, stream_key).await { - error!("Error en conexión desde {}: {:#}", peer_addr, e); + error!("Error in connection from {}: {:#}", peer_addr, e); } else { - info!("Conexión desde {} finalizada correctamente", peer_addr); + info!("Connection from {} ended correctly", peer_addr); } }); } Err(e) => { - warn!("Error aceptando conexión: {}", e); + warn!("Error accepting connection: {}", e); } } } } } + info!("Reestream shutdown complete"); + Ok(()) +} + +fn run_setup(config_path: &std::path::Path) -> Result<(), Box> { + reestream::setup::run_cli_wizard(config_path)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use reestream::AsyncReadWrite; + + fn parse_socket_addr(addr: &str, port: u16) -> Result> { + let addr: SocketAddr = format!("{addr}:{port}").parse()?; + Ok(addr) + } + + #[test] + fn test_parse_socket_addr_valid() { + let addr = parse_socket_addr("0.0.0.0", 1935).unwrap(); + assert_eq!(addr, "0.0.0.0:1935".parse::().unwrap()); + } + + #[test] + fn test_parse_socket_addr_localhost() { + let addr = parse_socket_addr("127.0.0.1", 8080).unwrap(); + assert_eq!(addr, "127.0.0.1:8080".parse::().unwrap()); + } + + #[test] + fn test_parse_socket_addr_invalid() { + let result = parse_socket_addr("not-an-address", 1935); + assert!(result.is_err()); + } + + #[test] + fn test_args_default_config() { + let args = Args::try_parse_from(["reestream"]).unwrap(); + assert_eq!(args.config, PathBuf::from("config.toml")); + assert!(!args.json_log); + assert_eq!(args.log_level, "info"); + assert!(!args.setup); + } + + #[test] + fn test_args_setup_flag() { + let args = Args::try_parse_from(["reestream", "--setup"]).unwrap(); + assert!(args.setup); + } + + #[test] + fn test_args_custom_config_short() { + let args = Args::try_parse_from(["reestream", "-c", "/tmp/myconfig.toml"]).unwrap(); + assert_eq!(args.config, PathBuf::from("/tmp/myconfig.toml")); + } + + #[test] + fn test_args_custom_config_long() { + let args = + Args::try_parse_from(["reestream", "--config", "/etc/reestream/config.toml"]).unwrap(); + assert_eq!(args.config, PathBuf::from("/etc/reestream/config.toml")); + } + + #[test] + fn test_args_json_log() { + let args = Args::try_parse_from(["reestream", "--json-log"]).unwrap(); + assert!(args.json_log); + } + + #[test] + fn test_args_log_level() { + let args = Args::try_parse_from(["reestream", "--log-level", "debug"]).unwrap(); + assert_eq!(args.log_level, "debug"); + } + + #[test] + fn test_async_read_write_trait_bounds() { + fn _assert_impl() {} + _assert_impl::(); + } +} diff --git a/src/provider.rs b/src/provider.rs deleted file mode 100644 index 49f99bc..0000000 --- a/src/provider.rs +++ /dev/null @@ -1,53 +0,0 @@ -use std::error::Error; -use std::fmt; - -use serde::{Deserialize, Serialize}; - -#[derive(Debug)] -#[allow(dead_code)] -#[allow(clippy::enum_variant_names)] -pub enum StreamKeyError { - OAuthError(String), - ApiError(String), - ParseError(String), - NetworkError(String), -} - -impl fmt::Display for StreamKeyError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match self { - StreamKeyError::OAuthError(msg) => write!(f, "OAuth Error: {msg}"), - StreamKeyError::ApiError(msg) => write!(f, "API Error: {msg}"), - StreamKeyError::ParseError(msg) => write!(f, "Parse Error: {msg}"), - StreamKeyError::NetworkError(msg) => write!(f, "Network Error: {msg}"), - } - } -} - -impl Error for StreamKeyError {} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[allow(dead_code)] -pub struct StreamKey { - pub key: String, - pub rtmp_url: String, -} - -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub struct OAuth2Config { - pub client_id: String, - pub client_secret: String, - pub redirect_uri: String, - pub access_token: Option, -} - -#[allow(dead_code)] -pub trait StreamKeyProvider: Send + Sync { - const NAME: &str; - - fn get_auth_url(&self, state: &str, scopes: &[&str]) -> String; - async fn exchange_code(&mut self, code: &str) -> Result; - async fn get_stream_key(&self) -> Result; - async fn refresh_token(&mut self, refresh_token: &str) -> Result; -} diff --git a/src/server.rs b/src/server.rs deleted file mode 100644 index 14e89cd..0000000 --- a/src/server.rs +++ /dev/null @@ -1,52 +0,0 @@ -use rml_rtmp::handshake::{Handshake, HandshakeProcessResult, PeerType}; -use rml_rtmp::sessions::{ServerSession, ServerSessionConfig, ServerSessionResult}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpStream; - -/// Handshake server side and create ServerSession with lower-latency config -pub async fn handshake_and_create_server_session( - stream: &mut TcpStream, -) -> Result<(ServerSession, Vec), Box> { - let mut hs = Handshake::new(PeerType::Server); - let mut buf = [0u8; 4096]; - - loop { - let n = stream.read(&mut buf).await?; - if n == 0 { - return Err("EOF durante handshake (no se recibieron datos de cliente)".into()); - } - - match hs.process_bytes(&buf[..n])? { - HandshakeProcessResult::InProgress { response_bytes } => { - if !response_bytes.is_empty() { - stream.write_all(&response_bytes).await?; - } - } - HandshakeProcessResult::Completed { - response_bytes, - remaining_bytes, - } => { - if !response_bytes.is_empty() { - stream.write_all(&response_bytes).await?; - } - return Ok(( - { - // Reduce latency: use smaller chunk size and smaller ack window to have quicker acks - let mut config = ServerSessionConfig::new(); - config.chunk_size = 128; // smaller chunks -> lower per-chunk latency (tradeoff CPU) - config.window_ack_size = 262_144; // 256KB ack window to get more frequent acks - - let (server_session, initial_results) = ServerSession::new(config)?; - for res in initial_results { - if let ServerSessionResult::OutboundResponse(packet) = res { - stream.write_all(&packet.bytes).await?; - } - } - server_session - }, - remaining_bytes, - )); - } - } - } -} diff --git a/tests/common/mock_rtmp.rs b/tests/common/mock_rtmp.rs new file mode 100644 index 0000000..83c21f8 --- /dev/null +++ b/tests/common/mock_rtmp.rs @@ -0,0 +1,219 @@ +#![allow(dead_code)] + +use bytes::Bytes; +use rml_rtmp::handshake::{Handshake, HandshakeProcessResult, PeerType}; +use rml_rtmp::sessions::{ + ClientSession, ClientSessionConfig, ClientSessionResult, PublishRequestType, ServerSession, + ServerSessionConfig, ServerSessionResult, +}; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +pub struct MockRtmpServer { + pub addr: std::net::SocketAddr, + listener: TcpListener, +} + +impl MockRtmpServer { + pub async fn bind() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + Self { addr, listener } + } + + pub async fn accept(&self) -> MockServerSession { + let (stream, _) = self.listener.accept().await.unwrap(); + MockServerSession { stream } + } + + pub async fn accept_with_timeout(&self, duration: Duration) -> Option { + match tokio::time::timeout(duration, self.listener.accept()).await { + Ok(Ok((stream, _))) => Some(MockServerSession { stream }), + _ => None, + } + } +} + +pub struct MockServerSession { + stream: TcpStream, +} + +impl MockServerSession { + pub async fn perform_handshake( + &mut self, + ) -> Result<(ServerSession, Vec), Box> { + let mut hs = Handshake::new(PeerType::Server); + let mut buf = [0u8; 4096]; + + loop { + let n = self.stream.read(&mut buf).await?; + if n == 0 { + return Err("EOF during mock handshake".into()); + } + + match hs.process_bytes(&buf[..n])? { + HandshakeProcessResult::InProgress { response_bytes } => { + if !response_bytes.is_empty() { + self.stream.write_all(&response_bytes).await?; + } + } + HandshakeProcessResult::Completed { + response_bytes, + remaining_bytes, + } => { + if !response_bytes.is_empty() { + self.stream.write_all(&response_bytes).await?; + } + + let mut config = ServerSessionConfig::new(); + config.chunk_size = 128; + config.window_ack_size = 262_144; + let (session, initial_results) = ServerSession::new(config)?; + for res in initial_results { + if let ServerSessionResult::OutboundResponse(packet) = res { + self.stream.write_all(&packet.bytes).await?; + } + } + return Ok((session, remaining_bytes)); + } + } + } + } + + pub async fn read_packet(&mut self) -> Result, std::io::Error> { + let mut buf = [0u8; 8192]; + let n = self.stream.read(&mut buf).await?; + Ok(buf[..n].to_vec()) + } + + pub async fn write_all(&mut self, data: &[u8]) -> Result<(), std::io::Error> { + self.stream.write_all(data).await + } +} + +pub struct MockRtmpClient { + stream: TcpStream, + session: Option, +} + +impl MockRtmpClient { + pub async fn connect( + addr: std::net::SocketAddr, + ) -> Result> { + let stream = TcpStream::connect(addr).await?; + stream.set_nodelay(true)?; + Ok(Self { + stream, + session: None, + }) + } + + pub async fn perform_handshake( + &mut self, + ) -> Result<(), Box> { + let mut hs = Handshake::new(PeerType::Client); + let c0_c1 = hs.generate_outbound_p0_and_p1()?; + self.stream.write_all(&c0_c1).await?; + + let mut buf = [0u8; 4096]; + loop { + let n = self.stream.read(&mut buf).await?; + if n == 0 { + return Err("EOF during client handshake".into()); + } + + match hs.process_bytes(&buf[..n])? { + HandshakeProcessResult::InProgress { response_bytes } => { + if !response_bytes.is_empty() { + self.stream.write_all(&response_bytes).await?; + } + } + HandshakeProcessResult::Completed { response_bytes, .. } => { + if !response_bytes.is_empty() { + self.stream.write_all(&response_bytes).await?; + } + break; + } + } + } + + let mut config = ClientSessionConfig::new(); + config.tc_url = Some("rtmp://127.0.0.1/app".to_string()); + let (session, initial_results) = ClientSession::new(config)?; + + for res in initial_results { + if let ClientSessionResult::OutboundResponse(packet) = res { + self.stream.write_all(&packet.bytes).await?; + } + } + + self.session = Some(session); + Ok(()) + } + + pub async fn request_connection( + &mut self, + app: &str, + ) -> Result<(), Box> { + if let Some(session) = &mut self.session { + let result = session.request_connection(app.to_string())?; + if let ClientSessionResult::OutboundResponse(packet) = result { + self.stream.write_all(&packet.bytes).await?; + } + } + Ok(()) + } + + pub async fn request_publish( + &mut self, + stream_key: &str, + ) -> Result<(), Box> { + if let Some(session) = &mut self.session { + let result = + session.request_publishing(stream_key.to_string(), PublishRequestType::Live)?; + if let ClientSessionResult::OutboundResponse(packet) = result { + self.stream.write_all(&packet.bytes).await?; + } + } + Ok(()) + } + + pub async fn send_video_data( + &mut self, + data: Bytes, + ) -> Result<(), Box> { + if let Some(session) = &mut self.session { + let result = + session.publish_video_data(data, rml_rtmp::time::RtmpTimestamp::new(0), true)?; + if let ClientSessionResult::OutboundResponse(packet) = result { + self.stream.write_all(&packet.bytes).await?; + } + } + Ok(()) + } + + pub async fn send_audio_data( + &mut self, + data: Bytes, + ) -> Result<(), Box> { + if let Some(session) = &mut self.session { + let result = + session.publish_audio_data(data, rml_rtmp::time::RtmpTimestamp::new(0), true)?; + if let ClientSessionResult::OutboundResponse(packet) = result { + self.stream.write_all(&packet.bytes).await?; + } + } + Ok(()) + } + + pub async fn read_response(&mut self) -> Result, std::io::Error> { + let mut buf = [0u8; 8192]; + let n = self.stream.read(&mut buf).await?; + Ok(buf[..n].to_vec()) + } + + pub async fn disconnect(self) { + drop(self.stream); + } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..9bec0c0 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,19 @@ +#![allow(dead_code)] + +pub mod mock_rtmp; + +use std::path::PathBuf; + +pub fn fixture_path(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join(name) +} + +pub fn init_tracing() { + let _ = tracing_subscriber::fmt() + .with_test_writer() + .with_max_level(tracing::Level::DEBUG) + .try_init(); +} diff --git a/tests/config_integration.rs b/tests/config_integration.rs new file mode 100644 index 0000000..cba5697 --- /dev/null +++ b/tests/config_integration.rs @@ -0,0 +1,63 @@ +use std::path::PathBuf; + +fn fixture_path(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join(name) +} + +#[test] +fn test_load_valid_config_from_file() { + let path = fixture_path("config_valid.toml"); + let config = reestream::config::Config::from_file(&path).unwrap(); + assert_eq!(config.rtmp_addr, "127.0.0.1"); + assert_eq!(config.rtmp_port, 1935); + assert_eq!(config.stream_key, "test-stream-key"); + let platforms = config.platform.unwrap(); + assert_eq!(platforms.len(), 2); + assert_eq!(platforms[0].key, "local-key-1"); + assert_eq!(platforms[1].key, "local-key-2"); +} + +#[test] +fn test_load_minimal_config_from_file() { + let path = fixture_path("config_minimal.toml"); + let config = reestream::config::Config::from_file(&path).unwrap(); + assert_eq!(config.rtmp_addr, "127.0.0.1"); + assert_eq!(config.rtmp_port, 1935); + assert_eq!(config.stream_key, "minimal-key"); + assert!(config.platform.is_none()); +} + +#[test] +fn test_load_empty_platforms_config_from_file() { + let path = fixture_path("config_empty_platforms.toml"); + let config = reestream::config::Config::from_file(&path).unwrap(); + let platforms = config.platform.unwrap(); + assert!(platforms.is_empty()); +} + +#[test] +fn test_load_invalid_config_fails() { + let path = fixture_path("config_invalid.toml"); + let result = reestream::config::Config::from_file(&path); + assert!(result.is_err()); +} + +#[test] +fn test_load_nonexistent_config_fails() { + let path = PathBuf::from("/nonexistent/path/to/config.toml"); + let result = reestream::config::Config::from_file(&path); + assert!(result.is_err()); +} + +#[test] +fn test_config_platform_url_schemes() { + let path = fixture_path("config_valid.toml"); + let config = reestream::config::Config::from_file(&path).unwrap(); + let platforms = config.platform.unwrap(); + assert_eq!(platforms[0].url.scheme(), "rtmp"); + assert_eq!(platforms[0].url.host_str(), Some("127.0.0.1")); + assert_eq!(platforms[0].url.port(), Some(1936)); +} diff --git a/tests/fixtures/config_empty_platforms.toml b/tests/fixtures/config_empty_platforms.toml new file mode 100644 index 0000000..5a73d5a --- /dev/null +++ b/tests/fixtures/config_empty_platforms.toml @@ -0,0 +1,4 @@ +rtmp_addr = "127.0.0.1" +rtmp_port = 1935 +stream_key = "empty-platforms-key" +platform = [] diff --git a/tests/fixtures/config_invalid.toml b/tests/fixtures/config_invalid.toml new file mode 100644 index 0000000..8db59f8 --- /dev/null +++ b/tests/fixtures/config_invalid.toml @@ -0,0 +1 @@ +this is not valid toml [[[ \ No newline at end of file diff --git a/tests/fixtures/config_minimal.toml b/tests/fixtures/config_minimal.toml new file mode 100644 index 0000000..c64a1ad --- /dev/null +++ b/tests/fixtures/config_minimal.toml @@ -0,0 +1,3 @@ +rtmp_addr = "127.0.0.1" +rtmp_port = 1935 +stream_key = "minimal-key" diff --git a/tests/fixtures/config_valid.toml b/tests/fixtures/config_valid.toml new file mode 100644 index 0000000..59dab42 --- /dev/null +++ b/tests/fixtures/config_valid.toml @@ -0,0 +1,13 @@ +rtmp_addr = "127.0.0.1" +rtmp_port = 1935 +stream_key = "test-stream-key" + +[[platform]] +url = "rtmp://127.0.0.1:1936/app" +key = "local-key-1" +orientation = "horizontal" + +[[platform]] +url = "rtmp://127.0.0.1:1937/app" +key = "local-key-2" +orientation = "vertical" diff --git a/tests/handshake_integration.rs b/tests/handshake_integration.rs new file mode 100644 index 0000000..9bc912a --- /dev/null +++ b/tests/handshake_integration.rs @@ -0,0 +1,164 @@ +use reestream::server::handshake_and_create_server_session; +use rml_rtmp::handshake::{Handshake, HandshakeProcessResult, PeerType}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +async fn create_server_client_pair() -> (TcpStream, TcpStream, tokio::net::TcpListener) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let client = TcpStream::connect(addr).await.unwrap(); + let (server, _) = listener.accept().await.unwrap(); + (server, client, listener) +} + +#[tokio::test] +async fn test_full_rtmp_handshake() { + let (mut server_stream, mut client_stream, _listener) = create_server_client_pair().await; + + // Client initiates handshake + let mut client_hs = Handshake::new(PeerType::Client); + let c0_c1 = client_hs.generate_outbound_p0_and_p1().unwrap(); + client_stream.write_all(&c0_c1).await.unwrap(); + + // Server processes handshake in background + let server_handle = + tokio::spawn(async move { handshake_and_create_server_session(&mut server_stream).await }); + + // Client reads server response and completes handshake + let mut buf = [0u8; 4096]; + let n = client_stream.read(&mut buf).await.unwrap(); + assert!(n > 0, "Server should send response bytes"); + + let result = client_hs.process_bytes(&buf[..n]).unwrap(); + match result { + HandshakeProcessResult::Completed { response_bytes, .. } => { + if !response_bytes.is_empty() { + client_stream.write_all(&response_bytes).await.unwrap(); + } + } + HandshakeProcessResult::InProgress { response_bytes } => { + if !response_bytes.is_empty() { + client_stream.write_all(&response_bytes).await.unwrap(); + } + // Read final server response + let n = client_stream.read(&mut buf).await.unwrap(); + let result2 = client_hs.process_bytes(&buf[..n]).unwrap(); + match result2 { + HandshakeProcessResult::Completed { response_bytes, .. } => { + if !response_bytes.is_empty() { + client_stream.write_all(&response_bytes).await.unwrap(); + } + } + _ => panic!("Expected handshake completion on second round"), + } + } + } + + // Verify server completed handshake successfully + let server_result = server_handle.await.unwrap(); + assert!(server_result.is_ok(), "Server handshake should succeed"); + let (_session, _leftover) = match server_result { + Ok((s, l)) => (s, l), + Err(e) => panic!("Server handshake failed: {}", e), + }; +} + +#[tokio::test] +async fn test_handshake_eof_on_disconnect() { + let (mut server_stream, client_stream, _listener) = create_server_client_pair().await; + + // Drop client immediately + drop(client_stream); + + let result = handshake_and_create_server_session(&mut server_stream).await; + assert!(result.is_err()); + let err = result.err().unwrap().to_string(); + assert!( + err.contains("EOF") || err.contains("eof") || err.contains("os error"), + "Error should indicate EOF: {}", + err + ); +} + +#[tokio::test] +async fn test_handshake_with_garbage_data() { + let (mut server_stream, mut client_stream, _listener) = create_server_client_pair().await; + + // Send garbage instead of valid RTMP handshake + client_stream.write_all(&[0xFF; 1537]).await.unwrap(); + + // Server should handle gracefully (error or hang, but not crash) + let server_handle = tokio::spawn(async move { + tokio::time::timeout( + std::time::Duration::from_secs(2), + handshake_and_create_server_session(&mut server_stream), + ) + .await + }); + + // Read whatever server sends back + let mut buf = [0u8; 4096]; + let _ = tokio::time::timeout( + std::time::Duration::from_secs(1), + client_stream.read(&mut buf), + ) + .await; + + let result = server_handle.await.unwrap(); + // Server should either error or timeout (both acceptable) + match result { + Ok(inner) => { + // If timeout didn't fire, the function should have errored + assert!(inner.is_err(), "Garbage data should cause error"); + } + Err(_) => { + // Timeout is acceptable - server hung up waiting for valid data + } + } +} + +#[tokio::test] +async fn test_handshake_preserves_remaining_bytes() { + let (mut server_stream, mut client_stream, _listener) = create_server_client_pair().await; + + // Client initiates handshake + let mut client_hs = Handshake::new(PeerType::Client); + let c0_c1 = client_hs.generate_outbound_p0_and_p1().unwrap(); + client_stream.write_all(&c0_c1).await.unwrap(); + + let server_handle = + tokio::spawn(async move { handshake_and_create_server_session(&mut server_stream).await }); + + // Complete handshake from client side + let mut buf = [0u8; 4096]; + let n = client_stream.read(&mut buf).await.unwrap(); + let result = client_hs.process_bytes(&buf[..n]).unwrap(); + match result { + HandshakeProcessResult::Completed { response_bytes, .. } => { + if !response_bytes.is_empty() { + client_stream.write_all(&response_bytes).await.unwrap(); + } + } + HandshakeProcessResult::InProgress { response_bytes } => { + if !response_bytes.is_empty() { + client_stream.write_all(&response_bytes).await.unwrap(); + } + let n = client_stream.read(&mut buf).await.unwrap(); + let result2 = client_hs.process_bytes(&buf[..n]).unwrap(); + match result2 { + HandshakeProcessResult::Completed { response_bytes, .. } => { + if !response_bytes.is_empty() { + client_stream.write_all(&response_bytes).await.unwrap(); + } + } + _ => panic!("Expected completion"), + } + } + } + + let server_result = server_handle.await.unwrap().unwrap(); + let (_session, leftover) = server_result; + // leftover is the bytes that came after the handshake in the same read + // In a clean handshake, there should be no leftover + let _ = leftover; // May or may not be empty +} diff --git a/tests/mock_integration.rs b/tests/mock_integration.rs new file mode 100644 index 0000000..cd826c2 --- /dev/null +++ b/tests/mock_integration.rs @@ -0,0 +1,88 @@ +mod common; + +use common::mock_rtmp::{MockRtmpClient, MockRtmpServer}; +use std::time::Duration; + +#[tokio::test] +async fn test_mock_server_bind() { + let server = MockRtmpServer::bind().await; + assert!(server.addr.port() > 0); +} + +#[tokio::test] +async fn test_mock_client_connect() { + let server = MockRtmpServer::bind().await; + let client = MockRtmpClient::connect(server.addr).await; + assert!(client.is_ok()); +} + +#[tokio::test] +async fn test_mock_handshake_roundtrip() { + let server = MockRtmpServer::bind().await; + let addr = server.addr; + + let server_handle = tokio::spawn(async move { + let mut session = server.accept().await; + session.perform_handshake().await + }); + + let mut client = MockRtmpClient::connect(addr).await.unwrap(); + let result = client.perform_handshake().await; + assert!(result.is_ok(), "Client handshake should succeed"); + + let server_result = server_handle.await.unwrap(); + assert!(server_result.is_ok(), "Server handshake should succeed"); +} + +#[tokio::test] +async fn test_mock_server_accept_timeout() { + let server = MockRtmpServer::bind().await; + let result = server.accept_with_timeout(Duration::from_millis(50)).await; + assert!(result.is_none(), "Should timeout when no client connects"); +} + +#[tokio::test] +async fn test_mock_multiple_clients() { + let server = MockRtmpServer::bind().await; + let addr = server.addr; + + let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(3); + + // Accept all connections in background + let server_handle = tokio::spawn(async move { + for _ in 0..3 { + let mut session = server.accept().await; + session.perform_handshake().await.unwrap(); + tx.send(()).await.unwrap(); + } + }); + + // Connect clients + for _ in 0..3 { + let mut client = MockRtmpClient::connect(addr).await.unwrap(); + client.perform_handshake().await.unwrap(); + rx.recv().await.unwrap(); + } + + server_handle.await.unwrap(); +} + +#[tokio::test] +async fn test_mock_client_disconnect() { + let server = MockRtmpServer::bind().await; + let addr = server.addr; + + let server_handle = tokio::spawn(async move { + let mut session = server + .accept_with_timeout(Duration::from_secs(2)) + .await + .unwrap(); + session.perform_handshake().await + }); + + let mut client = MockRtmpClient::connect(addr).await.unwrap(); + client.perform_handshake().await.unwrap(); + client.disconnect().await; + + let _ = server_handle.await; +} diff --git a/tests/proptest.rs b/tests/proptest.rs new file mode 100644 index 0000000..8636805 --- /dev/null +++ b/tests/proptest.rs @@ -0,0 +1,127 @@ +use bytes::Bytes; +use proptest::prelude::*; + +fn is_video_sequence_header(data: &[u8]) -> bool { + data.len() > 1 && data[0] == 0x17 && data[1] == 0x00 +} + +fn is_audio_sequence_header(data: &[u8]) -> bool { + data.len() > 1 && (data[0] & 0xF0) == 0xA0 && data[1] == 0x00 +} + +proptest! { + #[test] + fn test_video_header_never_panics(data in any::>()) { + let bytes = Bytes::from(data.clone()); + let _ = is_video_sequence_header(&data); + let _ = is_video_sequence_header(&bytes); + } + + #[test] + fn test_audio_header_never_panics(data in any::>()) { + let bytes = Bytes::from(data.clone()); + let _ = is_audio_sequence_header(&data); + let _ = is_audio_sequence_header(&bytes); + } + + #[test] + fn test_video_header_requires_minimum_length( + byte0 in 0x00u8..=0xFF, + byte1 in 0x00u8..=0xFF, + ) { + // Single byte should always be false + assert!(!is_video_sequence_header(&[byte0])); + // Two bytes with correct pattern should be true + let result = is_video_sequence_header(&[byte0, byte1]); + assert_eq!(result, byte0 == 0x17 && byte1 == 0x00); + } + + #[test] + fn test_audio_header_requires_minimum_length( + byte0 in 0x00u8..=0xFF, + byte1 in 0x00u8..=0xFF, + ) { + assert!(!is_audio_sequence_header(&[byte0])); + let result = is_audio_sequence_header(&[byte0, byte1]); + assert_eq!(result, (byte0 & 0xF0) == 0xA0 && byte1 == 0x00); + } + + #[test] + fn test_buffer_overflow_protection( + items in prop::collection::vec(any::(), 0..1024), + ) { + use std::collections::VecDeque; + let max_size = 256usize; + let mut buffer: VecDeque = VecDeque::new(); + + for item in &items { + if buffer.len() >= max_size { + buffer.pop_front(); + } + buffer.push_back(Bytes::from(vec![*item])); + } + + assert!(buffer.len() <= max_size); + } + + #[test] + fn test_config_parse_never_panics(input in ".*") { + let _: Result = input.parse(); + } + + #[test] + fn test_url_parsing_never_panics(input in ".*") { + let _ = url::Url::parse(&input); + } + + #[test] + fn test_rtmp_timestamp_values( + val in 0u32..=u32::MAX, + ) { + let ts = rml_rtmp::time::RtmpTimestamp::new(val); + assert_eq!(ts.value, val); + } + + #[test] + fn test_channel_buffer_capacity( + capacity in 1usize..1024, + count in 0usize..2048, + ) { + use tokio::sync::mpsc; + let (tx, mut rx) = mpsc::channel::(capacity); + + let mut sent = 0; + for i in 0..count { + if tx.try_send(Bytes::from(vec![i as u8])).is_ok() { + sent += 1; + } + } + + assert!(sent <= capacity); + assert!(sent <= count); + + // Drain + let mut received = 0; + while rx.try_recv().is_ok() { + received += 1; + } + assert_eq!(received, sent); + } + + #[test] + fn test_error_display_never_panics(msg in ".*") { + use reestream::error::RelayError; + let errors = vec![ + RelayError::Handshake(msg.clone()), + RelayError::Session(msg.clone()), + RelayError::Connection(msg.clone()), + RelayError::Timeout(msg.clone()), + RelayError::InvalidConfig(msg.clone()), + RelayError::PublishRejected(msg.clone()), + ]; + for err in errors { + let _ = err.to_string(); + let _ = format!("{:?}", err); + } + } +} diff --git a/tests/reconnect_integration.rs b/tests/reconnect_integration.rs new file mode 100644 index 0000000..3c419c4 --- /dev/null +++ b/tests/reconnect_integration.rs @@ -0,0 +1,142 @@ +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::mpsc; + +#[tokio::test] +async fn test_reconnection_channel_send_receive() { + let (tx, mut rx) = mpsc::channel::<(usize, String)>(10); + + // Simulate reconnection event + tx.send((0, "reconnected".to_string())).await.unwrap(); + let (index, msg) = rx.recv().await.unwrap(); + assert_eq!(index, 0); + assert_eq!(msg, "reconnected"); +} + +#[tokio::test] +async fn test_reconnection_channel_multiple_platforms() { + let (tx, mut rx) = mpsc::channel::<(usize, String)>(10); + + // Simulate reconnection for multiple platforms + for i in 0..3 { + tx.send((i, format!("platform-{}", i))).await.unwrap(); + } + + for i in 0..3 { + let (index, msg) = rx.recv().await.unwrap(); + assert_eq!(index, i); + assert_eq!(msg, format!("platform-{}", i)); + } +} + +#[tokio::test] +async fn test_reconnection_channel_closed_sender() { + let (tx, mut rx) = mpsc::channel::<(usize, String)>(10); + + tx.send((0, "before-close".to_string())).await.unwrap(); + drop(tx); + + let (index, msg) = rx.recv().await.unwrap(); + assert_eq!(index, 0); + assert_eq!(msg, "before-close"); + + // Channel should be closed now + assert!(rx.recv().await.is_none()); +} + +#[tokio::test] +async fn test_tcp_reconnection_pattern() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + // First connection + let mut client1 = TcpStream::connect(addr).await.unwrap(); + let (mut server1, _) = listener.accept().await.unwrap(); + + // Send data on first connection + client1.write_all(b"hello").await.unwrap(); + let mut buf = [0u8; 64]; + let n = server1.read(&mut buf).await.unwrap(); + assert_eq!(&buf[..n], b"hello"); + + // Drop first connection (simulate disconnect) + drop(client1); + drop(server1); + + // Second connection (simulate reconnect) + let mut client2 = TcpStream::connect(addr).await.unwrap(); + let (mut server2, _) = listener.accept().await.unwrap(); + + // Send data on second connection + client2.write_all(b"reconnected").await.unwrap(); + let n = server2.read(&mut buf).await.unwrap(); + assert_eq!(&buf[..n], b"reconnected"); + + drop(client2); + drop(server2); +} + +#[tokio::test] +async fn test_reconnection_with_data_buffering() { + use bytes::Bytes; + use std::collections::VecDeque; + + // Simulate buffering during reconnection + let mut buffer: VecDeque = VecDeque::new(); + let max_buffer = 256; + + // Buffer data while disconnected + for i in 0..300u16 { + if buffer.len() >= max_buffer { + buffer.pop_front(); + } + buffer.push_back(Bytes::from(vec![i as u8])); + } + + assert_eq!(buffer.len(), max_buffer); + // First 44 items should have been evicted (300 - 256 = 44) + assert_eq!(buffer.front().unwrap()[0], 44); + assert_eq!(buffer.back().unwrap()[0], 43); // 299 % 256 = 43 +} + +#[tokio::test] +async fn test_reconnection_timeout() { + let (tx, mut rx) = mpsc::channel::<()>(1); + + // Simulate timeout waiting for reconnection + let result = tokio::time::timeout(Duration::from_millis(100), rx.recv()).await; + assert!( + result.is_err(), + "Should timeout when no reconnection happens" + ); + + // Now send reconnection + tx.send(()).await.unwrap(); + let result = tokio::time::timeout(Duration::from_millis(100), rx.recv()).await; + assert!(result.is_ok(), "Should receive reconnection event"); +} + +#[tokio::test] +async fn test_multiple_reconnection_attempts() { + let (tx, mut rx) = mpsc::channel::(10); + + // Simulate multiple failed reconnection attempts followed by success + let handle = tokio::spawn(async move { + for attempt in 0..5 { + tokio::time::sleep(Duration::from_millis(20)).await; + // Simulate reconnection attempt + if attempt == 4 { + // Success on 5th attempt + tx.send(attempt).await.unwrap(); + break; + } + } + }); + + let result = tokio::time::timeout(Duration::from_secs(2), rx.recv()).await; + assert!(result.is_ok()); + assert_eq!(result.unwrap().unwrap(), 4); + + handle.await.unwrap(); +} diff --git a/tests/rtmp_packets.rs b/tests/rtmp_packets.rs new file mode 100644 index 0000000..99597a0 --- /dev/null +++ b/tests/rtmp_packets.rs @@ -0,0 +1,133 @@ +use bytes::Bytes; + +// RTMP packet type constants +const RTMP_TYPE_AUDIO: u8 = 0x08; +const RTMP_TYPE_VIDEO: u8 = 0x09; +const RTMP_TYPE_DATA: u8 = 0x12; + +// FLV video frame types +const FLV_KEYFRAME: u8 = 0x10; +const FLV_INTERFRAME: u8 = 0x20; +const FLV_CODEC_AVC: u8 = 0x07; + +// FLV AVC packet types +const AVC_SEQUENCE_HEADER: u8 = 0x00; +const AVC_NALU: u8 = 0x01; + +fn make_video_header(is_keyframe: bool, avc_packet_type: u8) -> Bytes { + let frame_type = if is_keyframe { + FLV_KEYFRAME + } else { + FLV_INTERFRAME + }; + Bytes::from(vec![ + frame_type | FLV_CODEC_AVC, + avc_packet_type, + 0x00, + 0x00, + 0x00, + ]) +} + +fn make_audio_header() -> Bytes { + // AAC, 44kHz, 16-bit, stereo + Bytes::from(vec![0xAF, AVC_SEQUENCE_HEADER, 0x12, 0x10]) +} + +#[test] +fn test_rtmp_video_keyframe_header() { + let data = make_video_header(true, AVC_SEQUENCE_HEADER); + assert_eq!(data[0], 0x17); // keyframe + AVC + assert_eq!(data[1], 0x00); // sequence header +} + +#[test] +fn test_rtmp_video_interframe() { + let data = make_video_header(false, AVC_NALU); + assert_eq!(data[0], 0x27); // interframe + AVC + assert_eq!(data[1], 0x01); // NALU +} + +#[test] +fn test_rtmp_audio_aac_header() { + let data = make_audio_header(); + assert_eq!(data[0], 0xAF); // AAC, 44kHz, 16-bit, stereo + assert_eq!(data[1], 0x00); // sequence header +} + +#[test] +fn test_flv_video_packet_structure() { + // Simulate a complete FLV video tag + // FLV tag: [type(1)][datasize(3)][timestamp(3)][ts_ext(1)][streamid(3)][data(N)] + let mut tag = Vec::new(); + tag.push(RTMP_TYPE_VIDEO); // byte 0: tag type + tag.extend_from_slice(&[0x00, 0x00, 0x05]); // bytes 1-3: data size (5) + tag.extend_from_slice(&[0x00, 0x00, 0x00]); // bytes 4-6: timestamp + tag.push(0x00); // byte 7: timestamp extended + tag.extend_from_slice(&[0x00, 0x00, 0x00]); // bytes 8-10: stream ID + // Video data starts at byte 11 + tag.extend_from_slice(&[0x17, 0x00, 0x00, 0x00, 0x00]); // bytes 11-15: video data + + assert_eq!(tag[0], RTMP_TYPE_VIDEO); + assert_eq!(tag[11], 0x17); // keyframe + AVC + assert_eq!(tag[12], 0x00); // sequence header +} + +#[test] +fn test_flv_audio_packet_structure() { + let mut tag = Vec::new(); + tag.push(RTMP_TYPE_AUDIO); // byte 0: tag type + tag.extend_from_slice(&[0x00, 0x00, 0x04]); // bytes 1-3: data size (4) + tag.extend_from_slice(&[0x00, 0x00, 0x00]); // bytes 4-6: timestamp + tag.push(0x00); // byte 7: timestamp extended + tag.extend_from_slice(&[0x00, 0x00, 0x00]); // bytes 8-10: stream ID + // Audio data starts at byte 11 + tag.extend_from_slice(&[0xAF, 0x00, 0x12, 0x10]); // bytes 11-14: audio data + + assert_eq!(tag[0], RTMP_TYPE_AUDIO); + assert_eq!(tag[11], 0xAF); + assert_eq!(tag[12], 0x00); // AAC sequence header +} + +#[test] +fn test_rtmp_types() { + assert_eq!(RTMP_TYPE_AUDIO, 8); + assert_eq!(RTMP_TYPE_VIDEO, 9); + assert_eq!(RTMP_TYPE_DATA, 18); +} + +#[test] +fn test_video_sequence_header_detection_from_real_data() { + // Real AVC decoder configuration record + let mut data = vec![0x17, 0x00, 0x00, 0x00, 0x00]; + // AVCDecoderConfigurationRecord + data.push(0x01); // version + data.push(0x64); // profile (High) + data.push(0x00); // compatibility + data.push(0x1E); // level (3.0) + data.push(0xFF); // NALU length size - 1 + data.push(0xE1); // num SPS + data.extend_from_slice(&[0x00, 0x19]); // SPS length (25 bytes) + data.extend_from_slice(&[0x67; 25]); // SPS data (placeholder) + data.push(0x01); // num PPS + data.extend_from_slice(&[0x00, 0x09]); // PPS length (9 bytes) + data.extend_from_slice(&[0x68; 9]); // PPS data (placeholder) + + let bytes = Bytes::from(data); + assert!(bytes.len() > 1); + assert_eq!(bytes[0], 0x17); + assert_eq!(bytes[1], 0x00); +} + +#[test] +fn test_audio_sequence_header_aac_specific_config() { + // AAC AudioSpecificConfig + let mut data = vec![0xAF, 0x00]; + // AudioSpecificConfig (2 bytes for AAC-LC) + data.push(0x12); // 5 bits audioObjectType (2=AAC-LC) + 3 bits samplingFreqIndex (4=44100) + data.push(0x10); // 4 bits samplingFreqIndex cont + 3 bits channelConfig (2=stereo) + padding + + let bytes = Bytes::from(data); + assert_eq!(bytes[0] & 0xF0, 0xA0); // audio flag + assert_eq!(bytes[1], 0x00); // sequence header +} diff --git a/tests/server_integration.rs b/tests/server_integration.rs new file mode 100644 index 0000000..e346637 --- /dev/null +++ b/tests/server_integration.rs @@ -0,0 +1,144 @@ +use std::sync::Arc; +use std::time::Duration; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::RwLock; + +#[tokio::test] +async fn test_tcp_listener_bind_and_accept() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + // Connect a client + let client = TcpStream::connect(addr).await.unwrap(); + let (server_stream, peer) = listener.accept().await.unwrap(); + + assert_eq!(peer.ip().to_string(), "127.0.0.1"); + assert!(client.peer_addr().is_ok()); + assert!(server_stream.peer_addr().is_ok()); + + drop(client); + drop(server_stream); +} + +#[tokio::test] +async fn test_multiple_concurrent_connections() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let mut handles = Vec::new(); + for _ in 0..5 { + handles.push(tokio::spawn(async move { + TcpStream::connect(addr).await.unwrap() + })); + } + + // Accept all connections + for _ in 0..5 { + let (stream, _) = listener.accept().await.unwrap(); + drop(stream); + } + + // All clients should have connected successfully + for handle in handles { + let client = handle.await.unwrap(); + assert!(client.peer_addr().is_ok()); + } +} + +#[tokio::test] +async fn test_graceful_shutdown_with_ctrl_c_simulation() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let platforms = Arc::new(RwLock::new(vec![])); + + let server_handle = tokio::spawn(async move { + let platforms = platforms; + // Simulate the main loop with a timeout instead of ctrl_c + let shutdown = tokio::time::sleep(Duration::from_millis(100)); + tokio::pin!(shutdown); + + loop { + tokio::select! { + _ = &mut shutdown => { + break; + } + accept = listener.accept() => { + if let Ok((socket, _)) = accept { + let _ = socket.set_nodelay(true); + let platforms = platforms.clone(); + let stream_key = "test-key".to_string(); + tokio::spawn(async move { + let _ = reestream::client::handle_publisher( + socket, + platforms, + stream_key, + ) + .await; + }); + } + } + } + } + }); + + // Connect a client before shutdown + let _client = TcpStream::connect(addr).await.unwrap(); + + // Wait for server to shut down + let result = tokio::time::timeout(Duration::from_secs(5), server_handle).await; + assert!(result.is_ok(), "Server should shut down within timeout"); + assert!(result.unwrap().is_ok()); +} + +#[tokio::test] +async fn test_platform_list_shared_across_connections() { + use reestream::config::Platform; + use url::Url; + + let platform = Platform { + url: Url::parse("rtmp://127.0.0.1:1999/app").unwrap(), + key: "test-key".to_string(), + orientation: reestream::config::Orientation::Horizontal, + }; + + let platforms = Arc::new(RwLock::new(vec![platform])); + let platforms_clone = platforms.clone(); + + // Verify shared state + { + let guard = platforms.read().await; + assert_eq!(guard.len(), 1); + assert_eq!(guard[0].key, "test-key"); + } + + // Modify through clone + { + let mut guard = platforms_clone.write().await; + guard.push(Platform { + url: Url::parse("rtmp://127.0.0.1:2000/app").unwrap(), + key: "key2".to_string(), + orientation: reestream::config::Orientation::Vertical, + }); + } + + // Verify both see the change + { + let guard = platforms.read().await; + assert_eq!(guard.len(), 2); + } +} + +#[tokio::test] +async fn test_socket_nodelay() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let client = TcpStream::connect(addr).await.unwrap(); + let (server_stream, _) = listener.accept().await.unwrap(); + + // set_nodelay should succeed on valid sockets + assert!(client.set_nodelay(true).is_ok()); + assert!(server_stream.set_nodelay(true).is_ok()); + + drop(client); + drop(server_stream); +} diff --git a/tests/stress.rs b/tests/stress.rs new file mode 100644 index 0000000..5a45208 --- /dev/null +++ b/tests/stress.rs @@ -0,0 +1,228 @@ +use std::sync::Arc; +use std::time::Duration; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::{Barrier, RwLock}; + +#[tokio::test] +async fn test_stress_concurrent_connections_10() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let barrier = Arc::new(Barrier::new(10)); + + let server_handle = tokio::spawn(async move { + let mut handles = Vec::new(); + for _ in 0..10 { + let (stream, _) = listener.accept().await.unwrap(); + handles.push(stream); + } + handles + }); + + let mut client_handles = Vec::new(); + for _ in 0..10 { + let barrier = barrier.clone(); + client_handles.push(tokio::spawn(async move { + let client = TcpStream::connect(addr).await.unwrap(); + barrier.wait().await; + client + })); + } + + let server_streams = server_handle.await.unwrap(); + assert_eq!(server_streams.len(), 10); + + for handle in client_handles { + let client = handle.await.unwrap(); + assert!(client.peer_addr().is_ok()); + } +} + +#[tokio::test] +async fn test_stress_concurrent_connections_50() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let server_handle = tokio::spawn(async move { + let mut count = 0; + while count < 50 { + if let Ok((_, _)) = listener.accept().await { + count += 1; + } + } + count + }); + + let mut client_handles = Vec::new(); + for _ in 0..50 { + client_handles.push(tokio::spawn(async move { TcpStream::connect(addr).await })); + } + + for handle in client_handles { + assert!(handle.await.unwrap().is_ok()); + } + + let count = server_handle.await.unwrap(); + assert_eq!(count, 50); +} + +#[tokio::test] +async fn test_stress_rapid_connect_disconnect() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let server_handle = tokio::spawn(async move { + let mut count = 0; + loop { + tokio::select! { + result = listener.accept() => { + if result.is_ok() { + count += 1; + if count >= 20 { + break; + } + } + } + _ = tokio::time::sleep(Duration::from_secs(5)) => break, + } + } + count + }); + + for _ in 0..20 { + let client = TcpStream::connect(addr).await.unwrap(); + drop(client); + } + + let count = server_handle.await.unwrap(); + assert_eq!(count, 20); +} + +#[tokio::test] +async fn test_stress_shared_platform_list_concurrent_access() { + use reestream::config::{Orientation, Platform}; + use url::Url; + + let platform = Platform { + url: Url::parse("rtmp://127.0.0.1:1935/app").unwrap(), + key: "key".to_string(), + orientation: Orientation::Horizontal, + }; + let platforms = Arc::new(RwLock::new(vec![platform])); + let barrier = Arc::new(Barrier::new(10)); + + let mut handles = Vec::new(); + for i in 0..10 { + let platforms = platforms.clone(); + let barrier = barrier.clone(); + handles.push(tokio::spawn(async move { + barrier.wait().await; + for _ in 0..100 { + if i % 2 == 0 { + let guard = platforms.read().await; + let _ = guard.len(); + } else { + let mut guard = platforms.write().await; + guard.push(Platform { + url: Url::parse("rtmp://127.0.0.1/app").unwrap(), + key: format!("key-{}", i), + orientation: Orientation::Horizontal, + }); + } + } + })); + } + + for handle in handles { + handle.await.unwrap(); + } + + let guard = platforms.read().await; + assert!(guard.len() >= 10); // At least the initial + some writes +} + +#[tokio::test] +async fn test_stress_channel_message_flood() { + use bytes::Bytes; + use tokio::sync::mpsc; + + let (tx, mut rx) = mpsc::channel::(100); + let mut handles = Vec::new(); + + // 5 producers, each sending 100 messages + for producer_id in 0..5 { + let tx = tx.clone(); + handles.push(tokio::spawn(async move { + for i in 0..100 { + let data = Bytes::from(vec![producer_id, i as u8]); + let _ = tx.try_send(data); + } + })); + } + + drop(tx); + + // Consumer + let consumer = tokio::spawn(async move { + let mut count = 0; + while rx.recv().await.is_some() { + count += 1; + } + count + }); + + for handle in handles { + handle.await.unwrap(); + } + + let received = consumer.await.unwrap(); + assert!(received > 0); + assert!(received <= 500); // 5 * 100 +} + +#[tokio::test] +async fn test_stress_concurrent_handshake_attempts() { + // Simulate 5 rapid sequential connections with handshake + for _ in 0..5 { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let server_handle = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + reestream::server::handshake_and_create_server_session(&mut stream).await + }); + + let mut client = TcpStream::connect(addr).await.unwrap(); + use rml_rtmp::handshake::{Handshake, PeerType}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let mut hs = Handshake::new(PeerType::Client); + let c0_c1 = hs.generate_outbound_p0_and_p1().unwrap(); + client.write_all(&c0_c1).await.unwrap(); + + let mut buf = [0u8; 4096]; + loop { + let n = client.read(&mut buf).await.unwrap(); + if n == 0 { + break; + } + match hs.process_bytes(&buf[..n]).unwrap() { + rml_rtmp::handshake::HandshakeProcessResult::Completed { + response_bytes, .. + } => { + if !response_bytes.is_empty() { + client.write_all(&response_bytes).await.unwrap(); + } + break; + } + rml_rtmp::handshake::HandshakeProcessResult::InProgress { response_bytes } => { + if !response_bytes.is_empty() { + client.write_all(&response_bytes).await.unwrap(); + } + } + } + } + + let result = server_handle.await.unwrap(); + assert!(result.is_ok()); + } +} diff --git a/tests/timeouts.rs b/tests/timeouts.rs new file mode 100644 index 0000000..19fa486 --- /dev/null +++ b/tests/timeouts.rs @@ -0,0 +1,165 @@ +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::time::timeout; + +#[tokio::test] +async fn test_read_timeout_on_idle_connection() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let server_handle = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + // Server accepts but never sends anything + tokio::time::sleep(Duration::from_secs(5)).await; + let _ = stream.write_all(b"late response").await; + }); + + let mut client = TcpStream::connect(addr).await.unwrap(); + let mut buf = [0u8; 64]; + + // Should timeout waiting for data + let result = timeout(Duration::from_millis(200), client.read(&mut buf)).await; + assert!(result.is_err(), "Should timeout on idle connection"); + + server_handle.abort(); +} + +#[tokio::test] +async fn test_connect_timeout_to_unreachable() { + // Try connecting to a non-routable address + let result = timeout( + Duration::from_millis(500), + TcpStream::connect("192.0.2.1:12345"), // RFC 5737 TEST-NET + ) + .await; + + // Should either timeout or connection refused + match result { + Ok(Ok(_)) => panic!("Should not connect to TEST-NET"), + Ok(Err(_)) => {} // Connection refused or similar + Err(_) => {} // Timeout + } +} + +#[tokio::test] +async fn test_write_timeout_on_full_buffer() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let server_handle = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + // Accept but never read - this will cause the send buffer to fill + tokio::time::sleep(Duration::from_secs(5)).await; + let mut buf = [0u8; 1024]; + let _ = stream.read(&mut buf).await; + }); + + let mut client = TcpStream::connect(addr).await.unwrap(); + client.set_nodelay(true).unwrap(); + + // Write until buffer fills + let data = vec![0u8; 65536]; + let mut total_written = 0; + loop { + match timeout(Duration::from_millis(100), client.write_all(&data)).await { + Ok(Ok(())) => total_written += data.len(), + Ok(Err(_)) => break, + Err(_) => break, // Timeout - buffer full + } + if total_written > 10 * 1024 * 1024 { + break; // Safety limit + } + } + + assert!(total_written > 0); + server_handle.abort(); +} + +#[tokio::test] +async fn test_partial_read_handling() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let server_handle = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + // Send data in small chunks with delays + for i in 0..5 { + tokio::time::sleep(Duration::from_millis(50)).await; + let _ = stream.write_all(&[i]).await; + } + }); + + let mut client = TcpStream::connect(addr).await.unwrap(); + let mut received = Vec::new(); + + // Read with timeout - should get partial data + loop { + let mut buf = [0u8; 64]; + match timeout(Duration::from_millis(300), client.read(&mut buf)).await { + Ok(Ok(0)) => break, + Ok(Ok(n)) => received.extend_from_slice(&buf[..n]), + Ok(Err(_)) => break, + Err(_) => break, // Timeout + } + } + + assert!(!received.is_empty()); + server_handle.await.unwrap(); +} + +#[tokio::test] +async fn test_graceful_close_detection() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let server_handle = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + drop(stream); // Close immediately + }); + + let mut client = TcpStream::connect(addr).await.unwrap(); + let mut buf = [0u8; 64]; + + // Should detect EOF quickly + let result = timeout(Duration::from_secs(1), client.read(&mut buf)).await; + assert!(result.is_ok()); + assert_eq!(result.unwrap().unwrap(), 0); // EOF + + server_handle.await.unwrap(); +} + +#[tokio::test] +async fn test_concurrent_timeout_handling() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let server_handle = tokio::spawn(async move { + for _ in 0..3 { + let (stream, _) = listener.accept().await.unwrap(); + // Drop immediately + drop(stream); + } + }); + + let mut handles = Vec::new(); + for _ in 0..3 { + handles.push(tokio::spawn(async move { + let mut client = TcpStream::connect(addr).await.unwrap(); + let mut buf = [0u8; 64]; + let result = timeout(Duration::from_millis(500), client.read(&mut buf)).await; + match result { + Ok(Ok(0)) => true, // EOF detected + Ok(Ok(_)) => false, // Unexpected data + Ok(Err(_)) => true, // Error (connection reset) + Err(_) => true, // Timeout is acceptable + } + })); + } + + for handle in handles { + assert!(handle.await.unwrap()); + } + + server_handle.await.unwrap(); +}