diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 0aa3f8205..9197f4e53 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -56,7 +56,7 @@ jobs: - name: Install trunk run: | if ! command -v trunk &> /dev/null; then - cargo install trunk + RUSTFLAGS="" cargo install trunk --locked fi - name: Install wasm-bindgen-cli diff --git a/CHANGELOG.md b/CHANGELOG.md index 863142acb..c4f5b6770 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog -# 3.1.8 (2025-11-18) +# 3.2.0 (2025-11-14) +- Added `name` attribute to Staged Input. +- Real-time active provider connection monitoring (dashboard + websocket) +- Source editor: block selection, batch-mode UI and automatic layout +- Fixed SSL certificate field binding in configuration view +- More robust connection-state and provider-handle management +- Streamlined event notifications and provider-count reporting +- Added configurable `reverse_proxy.resource_retry` (UI + server) to tune max attempts, base delay, and exponential backoff multiplier for proxied resources. +- Multi Strm outputs with same type is now allowed. +- Added new mapper function `pad(text | number, number, char, optional position: "<" | ">" | "^")` +- Added new mapper function `format` for simple in-text replacement like `format("Hello {}! Hello {}!", "Bob", "World")` +- Added `reverse_proxy.stream.shared_burst_buffer_mb` to control shared-stream burst buffer size (default 12 MB). +- Added `movie` as alias for `vod` for type filter. You can now use `Type = movie` as an alternative to `Type = vod`. +- Fixed file locks to avoid race conditions on file operations + +# 3.1.8 (2025-11-06) - Fixed HLS streaming issues caused by session eviction and incorrect headers. - Catchup stream fix cycling through multiple providers on play. - Custom streams fix and update webui stream info diff --git a/Cargo.lock b/Cargo.lock index 82fac5c18..e0b020e01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,9 +10,9 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] @@ -129,9 +129,9 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "async-compression" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a89bce6054c720275ac2432fbba080a66a2106a44a1b804553930ca6909f4e0" +checksum = "93c1f86859c1af3d514fa19e8323147ff10ea98684e6c7b307912509f50e67b2" dependencies = [ "compression-codecs", "compression-core", @@ -148,7 +148,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] @@ -227,7 +227,7 @@ checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] @@ -285,9 +285,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.4" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[package]] name = "blake2b_simd" @@ -355,12 +355,6 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" version = "1.10.1" @@ -369,9 +363,9 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "cc" -version = "1.2.41" +version = "1.2.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7" +checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe" dependencies = [ "find-msvc-tools", "jobserver", @@ -381,9 +375,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" @@ -443,9 +437,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.48" +version = "4.5.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2134bb3ea021b78629caa971416385309e0131b351b25e01dc16fb54e1b5fae" +checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" dependencies = [ "clap_builder", "clap_derive", @@ -453,9 +447,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.48" +version = "4.5.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2ba64afa3c0a6df7fa517765e31314e983f51dda798ffba27b988194fb65dc9" +checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" dependencies = [ "anstream", "anstyle", @@ -465,21 +459,21 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.47" +version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] name = "clap_lex" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "colorchoice" @@ -489,9 +483,9 @@ checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "compression-codecs" -version = "0.4.31" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef8a506ec4b81c460798f572caead636d57d3d7e940f998160f52bd254bf2d23" +checksum = "680dc087785c5230f8e8843e2e57ac7c1c90488b6a91b88caa265410568f441b" dependencies = [ "brotli", "compression-core", @@ -503,9 +497,9 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.29" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e47641d3deaf41fb1538ac1f54735925e275eaf3bf4d55c81b137fba797e5cbb" +checksum = "3a9b614a5787ef0c8802a55766480563cb3a93b435898c422ed2a359cf811582" [[package]] name = "console_error_panic_hook" @@ -653,7 +647,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] @@ -680,7 +674,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] @@ -704,7 +698,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] @@ -715,7 +709,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] @@ -751,9 +745,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ "powerfmt", ] @@ -776,7 +770,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] @@ -786,7 +780,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] @@ -806,7 +800,7 @@ checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] @@ -841,14 +835,14 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] name = "document-features" -version = "0.2.11" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" dependencies = [ "litrs", ] @@ -959,14 +953,14 @@ checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] name = "env_filter" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" dependencies = [ "log", "regex", @@ -1046,9 +1040,9 @@ checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" [[package]] name = "flate2" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc5a4e564e38c699f2880d3fda590bedc2e69f3f84cd48b457bd892ce61d0aa9" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" dependencies = [ "crc32fast", "miniz_oxide", @@ -1108,7 +1102,7 @@ dependencies = [ [[package]] name = "frontend" -version = "3.1.8" +version = "3.2.0" dependencies = [ "anyhow", "base64", @@ -1208,7 +1202,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] @@ -1265,9 +1259,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.7" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", @@ -1282,7 +1276,7 @@ checksum = "913dce4c5f06c2ea40fc178c06f777ac89fc6b1383e90c254fafb1abe4ba3c82" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", "uuid", ] @@ -1295,21 +1289,21 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi", - "wasi 0.14.7+wasi-0.2.4", + "wasip2", "wasm-bindgen", ] @@ -1741,7 +1735,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] @@ -1755,7 +1749,7 @@ dependencies = [ "futures-sink", "futures-timer", "futures-util", - "getrandom 0.3.3", + "getrandom 0.3.4", "hashbrown 0.15.5", "nonzero_ext", "parking_lot", @@ -1799,9 +1793,9 @@ dependencies = [ [[package]] name = "half" -version = "2.7.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e54c115d4f30f52c67202f079c5f9d8b49db4691f460fdb0b4c2e838261b2ba5" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", @@ -1931,9 +1925,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +checksum = "1744436df46f0bde35af3eda22aeaba453aada65d8f1c171cd8a5f59030bd69f" dependencies = [ "atomic-waker", "bytes", @@ -2050,9 +2044,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", "potential_utf", @@ -2063,9 +2057,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", @@ -2076,11 +2070,10 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" dependencies = [ - "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", @@ -2091,42 +2084,38 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" dependencies = [ - "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", - "potential_utf", "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" [[package]] name = "icu_provider" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", "icu_locale_core", - "stable_deref_trait", - "tinystr", "writeable", "yoke", "zerofrom", @@ -2178,14 +2167,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "699c1b6d335e63d0ba5c1e1c7f647371ce989c3bcbe1f7ed2b85fa56e3bd1a21" dependencies = [ "quote", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] name = "indexmap" -version = "2.11.4" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" dependencies = [ "equivalent", "hashbrown 0.16.0", @@ -2197,7 +2186,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "inotify-sys", "libc", ] @@ -2219,9 +2208,9 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "iri-string" -version = "0.7.8" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" dependencies = [ "memchr", "serde", @@ -2229,9 +2218,9 @@ dependencies = [ [[package]] name = "is_terminal_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" @@ -2250,26 +2239,26 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "jiff" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" +checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35" dependencies = [ "jiff-static", "log", "portable-atomic", "portable-atomic-util", - "serde", + "serde_core", ] [[package]] name = "jiff-static" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" +checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] @@ -2278,7 +2267,7 @@ version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", "libc", ] @@ -2294,9 +2283,9 @@ dependencies = [ [[package]] name = "jsonwebtoken" -version = "10.1.0" +version = "10.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d119c6924272d16f0ab9ce41f7aa0bfef9340c00b0bb7ca3dd3b263d4a9150b" +checksum = "c76e1c7d7df3e34443b3621b459b066a7b79644f059fc8b2db7070c825fd417e" dependencies = [ "base64", "ed25519-dalek", @@ -2362,7 +2351,7 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "libc", "redox_syscall", ] @@ -2375,15 +2364,15 @@ checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "litrs" -version = "0.4.2" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "lock_api" @@ -2406,7 +2395,7 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a7ce8821eadcb5cb5c64dd0c9876a90f2676424020b41272e36c1dd04d20c59" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "cfg-if", "cssparser", "encoding_rs", @@ -2474,14 +2463,14 @@ dependencies = [ [[package]] name = "mio" -version = "1.0.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" dependencies = [ "libc", "log", - "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.59.0", + "wasi", + "windows-sys 0.61.2", ] [[package]] @@ -2534,7 +2523,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "fsevent-sys", "inotify", "kqueue", @@ -2564,11 +2553,10 @@ dependencies = [ [[package]] name = "num-bigint-dig" -version = "0.8.4" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" dependencies = [ - "byteorder", "lazy_static", "libm", "num-integer", @@ -2642,17 +2630,17 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "once_cell_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openssl" -version = "0.10.73" +version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "cfg-if", "foreign-types", "libc", @@ -2669,7 +2657,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] @@ -2680,18 +2668,18 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-src" -version = "300.5.3+3.5.4" +version = "300.5.4+3.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6bad8cd0233b63971e232cc9c5e83039375b8586d2312f31fda85db8f888c2" +checksum = "a507b3792995dae9b0df8a1c1e3771e8418b7c2d9f0baeba32e6fe8b06c7cb72" dependencies = [ "cc", ] [[package]] name = "openssl-sys" -version = "0.9.109" +version = "0.9.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" dependencies = [ "cc", "libc", @@ -2814,7 +2802,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] @@ -2876,7 +2864,7 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] @@ -2914,7 +2902,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] @@ -2984,9 +2972,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" dependencies = [ "zerovec", ] @@ -3019,7 +3007,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] @@ -3067,9 +3055,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.101" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" dependencies = [ "unicode-ident", ] @@ -3111,7 +3099,7 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] @@ -3124,16 +3112,16 @@ dependencies = [ "libc", "once_cell", "raw-cpuid", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "web-sys", "winapi", ] [[package]] name = "quick-xml" -version = "0.38.3" +version = "0.38.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" dependencies = [ "memchr", "tokio", @@ -3166,7 +3154,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" dependencies = [ "bytes", - "getrandom 0.3.3", + "getrandom 0.3.4", "lru-slab", "rand 0.9.2", "ring", @@ -3196,9 +3184,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.41" +version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" dependencies = [ "proc-macro2", ] @@ -3265,7 +3253,7 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", ] [[package]] @@ -3274,7 +3262,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", ] [[package]] @@ -3303,7 +3291,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", ] [[package]] @@ -3320,9 +3308,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "722166aa0d7438abbaa4d5cc2c649dac844e8c56d82fb3d33e9c34b5cd268fc6" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", @@ -3331,9 +3319,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3160422bbd54dd5ecfdca71e5fd59b7b8fe2b1697ab2baf64f6d05dcc66d298" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "reqwasm" @@ -3346,9 +3334,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.23" +version = "0.12.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" dependencies = [ "base64", "bytes", @@ -3511,7 +3499,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys", @@ -3520,9 +3508,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.32" +version = "0.23.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ "once_cell", "ring", @@ -3534,9 +3522,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.12.0" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" dependencies = [ "web-time", "zeroize", @@ -3544,9 +3532,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.7" +version = "0.103.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" dependencies = [ "ring", "rustls-pki-types", @@ -3561,9 +3549,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ruzstd" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3640bec8aad418d7d03c72ea2de10d5c646a598f9883c7babc160d91e3c1b26c" +checksum = "e5ff0cc5e135c8870a775d3320910cd9b564ec036b4dc0b8741629020be63f01" dependencies = [ "twox-hash", ] @@ -3618,7 +3606,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "core-foundation", "core-foundation-sys", "libc", @@ -3641,7 +3629,7 @@ version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09975d3195f34dce9c7b381cb0f00c3c13381d4d3735c0f1a9c894b283b302ab" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "cssparser", "derive_more", "log", @@ -3709,7 +3697,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] @@ -3763,9 +3751,9 @@ dependencies = [ [[package]] name = "servo_arc" -version = "0.4.1" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "204ea332803bd95a0b60388590d59cf6468ec9becf626e2451f1d26a1d972de4" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" dependencies = [ "stable_deref_trait", ] @@ -3794,10 +3782,10 @@ dependencies = [ [[package]] name = "shared" -version = "3.1.8" +version = "3.2.0" dependencies = [ "base64", - "bitflags 2.9.4", + "bitflags 2.10.0", "blake3", "bytes", "chrono", @@ -3871,12 +3859,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -3934,9 +3922,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.106" +version = "2.0.110" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" dependencies = [ "proc-macro2", "quote", @@ -3960,7 +3948,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] @@ -3969,7 +3957,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "core-foundation", "system-configuration-sys", ] @@ -3991,7 +3979,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand", - "getrandom 0.3.3", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", @@ -4023,7 +4011,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] @@ -4034,7 +4022,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] @@ -4072,9 +4060,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", "zerovec", @@ -4119,7 +4107,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] @@ -4168,9 +4156,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.16" +version = "0.7.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" dependencies = [ "bytes", "futures-core", @@ -4252,7 +4240,7 @@ checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ "async-compression", "base64", - "bitflags 2.9.4", + "bitflags 2.10.0", "bytes", "futures-core", "futures-util", @@ -4323,7 +4311,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] @@ -4343,7 +4331,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tuliprox" -version = "3.1.8" +version = "3.2.0" dependencies = [ "arc-swap", "async-compression", @@ -4449,9 +4437,9 @@ checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" [[package]] name = "unicode-ident" -version = "1.0.19" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "unsafe-libyaml" @@ -4513,7 +4501,7 @@ version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", "js-sys", "wasm-bindgen", ] @@ -4585,15 +4573,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasi" -version = "0.14.7+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" -dependencies = [ - "wasip2", -] - [[package]] name = "wasip2" version = "1.0.1+wasi-0.2.4" @@ -4650,7 +4629,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", "wasm-bindgen-shared", ] @@ -4684,7 +4663,7 @@ checksum = "085b2df989e1e6f9620c1311df6c996e83fe16f57792b272ce1e024ac16a90f1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] @@ -4733,9 +4712,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32b130c0d2d49f8b6889abc456e795e82525204f27c42cf767cf0d7734e089b8" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" dependencies = [ "rustls-pki-types", ] @@ -4792,7 +4771,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] @@ -4803,7 +4782,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] @@ -5056,9 +5035,9 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "writeable" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "yew" @@ -5123,7 +5102,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] @@ -5153,16 +5132,15 @@ checksum = "42bfd190a07ca8cfde7cd4c52b3ac463803dc07323db8c34daa697e86365978c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] name = "yoke" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" dependencies = [ - "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -5170,13 +5148,13 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", "synstructure", ] @@ -5197,7 +5175,7 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] @@ -5217,7 +5195,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", "synstructure", ] @@ -5229,9 +5207,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" dependencies = [ "displaydoc", "yoke", @@ -5240,9 +5218,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ "yoke", "zerofrom", @@ -5251,13 +5229,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.1" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.110", ] [[package]] diff --git a/README.md b/README.md index d0eaeb425..41733f69d 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,7 @@ Attributes: - `throttle` Allowed units are `KB/s`,`MB/s`,`KiB/s`,`MiB/s`,`kbps`,`mbps`,`Mibps`. Default unit is `kbps` - `grace_period_millis` default set to 300 milliseconds. - `grace_period_timeout_secs` default set to 2 seconds. +- `shared_burst_buffer_mb` optional (default `12`). Minimum burst buffer size (in MB) used for shared streams. ##### 1.6.1.1 `retry` If set to `true` on connection loss to provider, the stream will be reconnected. @@ -317,6 +318,22 @@ reverse_proxy: - my-custom-header ``` +#### 1.6.6 `resource_retry` +Controls how aggressively tuliprox retries upstream resource (logo, EPG, stream) downloads whenever it proxies requests for clients. +It has three attributes: + +- `max_attempts`: How many times a failing request should be retried. Defaults to `3`, minimum `1`. +- `backoff_millis`: The wait time between attempts (unless the upstream responds with a `Retry-After` header). Defaults to `250`. +- `backoff_multiplier`: Multiplies the base backoff after each failed attempt. Values `<= 1.0` result in constant (linear) delay; values `> 1.0` produce exponential backoff. + +```yaml +reverse_proxy: + resource_retry: + max_attempts: 5 + backoff_millis: 500 + backoff_multiplier: 1.5 +``` + ### 1.6.7 `geoip` `geoip` is for resolving IP addresses to country names. @@ -1252,8 +1269,12 @@ It is whitespace-tolerant and uses familiar programming concepts with a custom s - first(a) - template(a) - replace(text, match, replacement) + - pad(text | number, number, char, optional position: "<" | ">" | "^") + - format(fmt_text, ...args) Field names are: `name`, `title"`, `caption"`, `group"`, `id"`, `chno"`, `logo"`, `logo_small"`, `parent_code"`, `audio_track"`, `time_shift" | "url"`, `epg_channel_id"`, `epg_id`. -When you use Regular expressions it could be that your match contains multiple results. The builtin function `first` returns the first match. +Format is very simple and only supports in text replacement like `format("Hello {}! Hello {}!", "Bob", "World")` +When you use Regular expressions it could be that your match contains multiple results. +The builtin function `first` returns the first match. Example `print(uppercase("hello"))`. output is only visible in `trace` log level you can enable it like `log_level: debug,tuliprox::foundation::mapper=trace` in config - Assignment assigns an expression result. variable or field. ```dsl @@ -2217,4 +2238,4 @@ Regex extraction: Pulls the 4-digit year from the caption. Mapping: If the year is ≤ 2019, it maps to " < 2020". Otherwise, the group is named by the actual year (e.g., "2021"). -Assignment: Constructs a new group label like "FR | MOVIES 2021" and assigns it to @Group \ No newline at end of file +Assignment: Constructs a new group label like "FR | MOVIES 2021" and assigns it to @Group diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 05a4b916a..730506f06 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tuliprox" -version = "3.1.8" +version = "3.2.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -22,7 +22,7 @@ axum = { version = "0.8" , features = ["macros", "default", "ws"]} tower = "0.5" tower-http = { version = "0.6", features = ["cors", "auth", "fs", "compression-full", "trace"] } tower_governor = { version = "0.8", features = ["axum"] } -jsonwebtoken = { version = "10.1", features = ["rust_crypto"] } +jsonwebtoken = { version = "10.2", features = ["rust_crypto"] } rust-argon2 = "3" futures = "0.3" path-clean = "1.0" @@ -60,7 +60,7 @@ indexmap = "2" notify = "8.2" arc-swap = "1.7" dashmap = "6.1" -hyper = "1.7" +hyper = "1.8" hyper-util = "0.1" socket2 = { version = "0.6", features = ["all"] } async-compression = { version = "0.4", features = ["tokio"] } diff --git a/backend/src/api/api_utils.rs b/backend/src/api/api_utils.rs index aaab59285..fbdd7f9a0 100644 --- a/backend/src/api/api_utils.rs +++ b/backend/src/api/api_utils.rs @@ -1,5 +1,4 @@ use crate::api::endpoints::xtream_api::{get_xtream_player_api_stream_url, ApiStreamContext}; -use crate::api::model::{ProviderAllocation, ProviderConfig, ProviderStreamState, StreamDetails, StreamingStrategy}; use crate::api::model::UserSession; use crate::api::model::{ create_channel_unavailable_stream, create_custom_video_stream_response, @@ -9,7 +8,8 @@ use crate::api::model::{ ProviderStreamFactoryOptions, SharedStreamManager, StreamError, ThrottledStream, UserApiRequest, }; -use crate::model::ConfigInput; +use crate::api::model::{ProviderAllocation, ProviderConfig, ProviderStreamState, StreamDetails, StreamingStrategy}; +use crate::model::{ConfigInput, ResourceRetryConfig}; use crate::model::{ConfigTarget, ProxyUserCredentials}; use crate::tools::lru_cache::LRUResourceCache; use crate::utils::create_new_file_for_write; @@ -24,6 +24,7 @@ use futures::{StreamExt, TryStreamExt}; use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; use log::{debug, error, log_enabled, trace}; use reqwest::header::RETRY_AFTER; +use serde::Serialize; use shared::model::{Claims, InputFetchMethod, PlaylistEntry, PlaylistItemType, StreamChannel, TargetType, UserConnectionPermission, XtreamCluster}; use shared::utils::{bin_serialize, default_grace_period_millis, human_readable_byte_size, trim_slash}; use shared::utils::{ @@ -35,7 +36,6 @@ use std::io::BufWriter; use std::path::Path; use std::sync::Arc; use std::time::Duration; -use serde::Serialize; use tokio::sync::Mutex; use url::Url; @@ -381,11 +381,11 @@ async fn resolve_streaming_strategy( } } } - } else { - debug!("Input {} is exhausted. No connections allowed.", input.name); - let stream = create_provider_connections_exhausted_stream(&app_state.app_config, &[]); - ProviderStreamState::Custom(stream) - }; + } else { + debug!("Input {} is exhausted. No connections allowed.", input.name); + let stream = create_provider_connections_exhausted_stream(&app_state.app_config, &[]); + ProviderStreamState::Custom(stream) + }; StreamingStrategy { @@ -402,10 +402,10 @@ fn get_grace_period_millis( ) -> u64 { if config_grace_period_millis > 0 && ( - matches!(stream_response_params, ProviderStreamState::GracePeriod(_, _)) // provider grace period + matches!(stream_response_params, ProviderStreamState::GracePeriod(_, _)) // provider grace period || connection_permission == UserConnectionPermission::GracePeriod - // user grace period - ) + // user grace period + ) { config_grace_period_millis } else { @@ -415,7 +415,7 @@ fn get_grace_period_millis( #[allow(clippy::too_many_arguments)] async fn create_stream_response_details( - app_state: &AppState, + app_state: &Arc, stream_options: &StreamOptions, stream_url: &str, fingerprint: &Fingerprint, @@ -470,6 +470,7 @@ async fn create_stream_response_details( .as_ref() .and_then(|r| r.disabled_header.clone()); let provider_stream_factory_options = ProviderStreamFactoryOptions::new( + fingerprint.addr, item_type, share_stream, stream_options, @@ -480,11 +481,11 @@ async fn create_stream_response_details( ); let reconnect_flag = provider_stream_factory_options.get_reconnect_flag_clone(); let provider_stream = match create_provider_stream( - Arc::clone(&app_state.app_config), - Arc::clone(&app_state.http_client.load()), + app_state, + &app_state.http_client.load(), provider_stream_factory_options, ) - .await + .await { None => (None, None), Some((stream, info)) => (Some(stream), info), @@ -495,7 +496,7 @@ async fn create_stream_response_details( }; if log_enabled!(log::Level::Debug) { - if let Some((headers, status_code, response_url)) = stream_info.as_ref() { + if let Some((headers, status_code, response_url, _custom_video_type)) = stream_info.as_ref() { debug!( "Responding stream request {} with status {}, headers {:?}", sanitize_sensitive_info( @@ -508,12 +509,14 @@ async fn create_stream_response_details( } // if we have no stream, we should release the provider - if stream.is_none() { - if let Some(guard) = streaming_strategy.provider_handle.take() { - drop(guard); - } + let provider_handle = if stream.is_none() { + let provider_handle = streaming_strategy.provider_handle.take(); + app_state.connection_manager.release_provider_handle(provider_handle).await; error!("Cant open stream {}", sanitize_sensitive_info(&request_url)); - } + None + } else { + streaming_strategy.provider_handle.take() + }; StreamDetails { stream, @@ -521,7 +524,7 @@ async fn create_stream_response_details( provider_name: guard_provider_name.clone(), grace_period_millis, reconnect_flag, - provider_handle: streaming_strategy.provider_handle.take(), + provider_handle, } } } @@ -702,7 +705,7 @@ fn prepare_body_stream( /// # Panics pub async fn force_provider_stream_response( fingerprint: &Fingerprint, - app_state: &AppState, + app_state: &Arc, user_session: &UserSession, mut stream_channel: StreamChannel, req_headers: &HeaderMap, @@ -714,7 +717,7 @@ pub async fn force_provider_stream_response( let connection_permission = UserConnectionPermission::Allowed; let item_type = stream_channel.item_type; - let mut stream_details = create_stream_response_details( + let stream_details = create_stream_response_details( app_state, &stream_options, &user_session.stream_url, @@ -726,13 +729,13 @@ pub async fn force_provider_stream_response( connection_permission, Some(&user_session.provider), ) - .await; + .await; if stream_details.has_stream() { let provider_response = stream_details .stream_info .as_ref() - .map(|(h, sc, url)| (h.clone(), *sc, url.clone())); + .map(|(h, sc, url, cvt)| (h.clone(), *sc, url.clone(), *cvt)); app_state .active_users .update_session_addr(&user.username, &user_session.token, &fingerprint.addr) @@ -743,7 +746,7 @@ pub async fn force_provider_stream_response( .await; let (status_code, header_map) = - get_stream_response_with_headers(provider_response.map(|(h, s, _)| (h, s))); + get_stream_response_with_headers(provider_response.map(|(h, s, _, _)| (h, s))); let mut response = axum::response::Response::builder().status(status_code); for (key, value) in &header_map { response = response.header(key, value); @@ -756,13 +759,14 @@ pub async fn force_provider_stream_response( ); return try_unwrap_body!(response.body(body_stream)); } - drop(stream_details.provider_handle.take()); + + app_state.connection_manager.release_provider_handle(stream_details.provider_handle).await; if let (Some(stream), _stream_info) = create_channel_unavailable_stream( &app_state.app_config, &[], axum::http::StatusCode::SERVICE_UNAVAILABLE, ) { - app_state.connection_manager.update_stream_detail(&user.username, fingerprint, CustomVideoStreamType::ChannelUnavailable).await; + app_state.connection_manager.update_stream_detail(&fingerprint.addr, CustomVideoStreamType::ChannelUnavailable).await; debug!("Streaming custom stream"); try_unwrap_body!(axum::response::Response::builder() .status(axum::http::StatusCode::OK) @@ -776,7 +780,7 @@ pub async fn force_provider_stream_response( #[allow(clippy::too_many_arguments, clippy::too_many_lines)] pub async fn stream_response( fingerprint: &Fingerprint, - app_state: &AppState, + app_state: &Arc, session_token: &str, mut stream_channel: StreamChannel, stream_url: &str, @@ -792,10 +796,11 @@ pub async fn stream_response( if connection_permission == UserConnectionPermission::Exhausted { return create_custom_video_stream_response( - &app_state.app_config, + app_state, + &fingerprint.addr, CustomVideoStreamType::UserConnectionsExhausted, - ) - .into_response(); + ).await + .into_response(); } let virtual_id = stream_channel.virtual_id; @@ -823,14 +828,14 @@ pub async fn stream_response( connection_permission, None, ) - .await; + .await; if stream_details.has_stream() { // let content_length = get_stream_content_length(provider_response.as_ref()); let provider_response = stream_details .stream_info .as_ref() - .map(|(h, sc, response_url)| (h.clone(), *sc, response_url.clone())); + .map(|(h, sc, response_url, cvt)| (h.clone(), *sc, response_url.clone(), *cvt)); let provider_name = stream_details.provider_name.clone(); let provider_handle = if share_stream { @@ -850,7 +855,7 @@ pub async fn stream_response( // Shared Stream response let shared_headers = provider_response .as_ref() - .map_or_else(Vec::new, |(h, _, _)| h.clone()); + .map_or_else(Vec::new, |(h, _, _, _)| h.clone()); if let Some((broadcast_stream, _shared_provider)) = SharedStreamManager::register_shared_stream( app_state, @@ -861,10 +866,10 @@ pub async fn stream_response( stream_options.buffer_size, provider_handle, ) - .await + .await { let (status_code, header_map) = - get_stream_response_with_headers(provider_response.map(|(h, s, _)| (h, s))); + get_stream_response_with_headers(provider_response.map(|(h, s, _, _)| (h, s))); let mut response = axum::response::Response::builder().status(status_code); for (key, value) in &header_map { response = response.header(key, value); @@ -876,7 +881,7 @@ pub async fn stream_response( } else { let session_url = provider_response .as_ref() - .and_then(|(_, _, u)| u.as_ref()) + .and_then(|(_, _, u, _)| u.as_ref()) .map_or_else( || Cow::Borrowed(stream_url), |url| Cow::Owned(url.to_string()), @@ -896,7 +901,7 @@ pub async fn stream_response( } } let (status_code, header_map) = - get_stream_response_with_headers(provider_response.map(|(h, s, _)| (h, s))); + get_stream_response_with_headers(provider_response.map(|(h, s, _, _)| (h, s))); let mut response = axum::response::Response::builder().status(status_code); for (key, value) in &header_map { response = response.header(key, value); @@ -932,7 +937,7 @@ pub async fn stream_response( return stream_resp.into_response(); } - drop(stream_details.provider_handle.take()); + app_state.connection_manager.release_provider_handle(stream_details.provider_handle).await; axum::http::StatusCode::BAD_REQUEST.into_response() } @@ -993,9 +998,9 @@ async fn try_shared_stream_response_if_any( pub fn is_stream_share_enabled(item_type: PlaylistItemType, target: &ConfigTarget) -> bool { (item_type == PlaylistItemType::Live/* || item_type == PlaylistItemType::LiveHls */) && target - .options - .as_ref() - .is_some_and(|opt| opt.share_live_streams) + .options + .as_ref() + .is_some_and(|opt| opt.share_live_streams) } pub type HeaderFilter = Option bool + Send>>; @@ -1090,10 +1095,11 @@ async fn fetch_resource_with_retry( req_headers: &HashMap>, input: Option<&ConfigInput>, ) -> Option { - // TODO: add max_attempts to config - let max_attempts: u32 = 3; //&app_state.app_config.config.load().max_attempts; - let backoff_ms: u64 = 250; let config = app_state.app_config.config.load(); + let (max_attempts, backoff_ms, backoff_multiplier) = config + .reverse_proxy + .as_ref() + .map_or_else(ResourceRetryConfig::get_default_retry_values, |rp| rp.resource_retry.get_retry_values()); let disabled_headers = config .reverse_proxy .as_ref() @@ -1125,13 +1131,19 @@ async fn fetch_resource_with_retry( | reqwest::StatusCode::TOO_MANY_REQUESTS ); - if attempt < max_attempts - 1 && should_retry { + if attempt < max_attempts - 1 && should_retry { let wait_dur = response .headers() .get(RETRY_AFTER) .and_then(|h| h.to_str().ok()) .and_then(|s| s.parse::().ok()) - .map_or(Duration::from_millis(backoff_ms), Duration::from_secs); + .map_or_else( + || { + let delay = calculate_retry_backoff(backoff_ms, backoff_multiplier, attempt); + Duration::from_millis(delay) + }, + Duration::from_secs, + ); tokio::time::sleep(wait_dur).await; continue; } @@ -1154,8 +1166,9 @@ async fn fetch_resource_with_retry( } Err(err) => { if attempt < max_attempts - 1 { - tokio::time::sleep(Duration::from_millis(backoff_ms)).await; - continue; + let delay = calculate_retry_backoff(backoff_ms, backoff_multiplier, attempt); + tokio::time::sleep(Duration::from_millis(delay)).await; + continue; } error!("Received failure from server {}: {err}", sanitize_sensitive_info(resource_url)); } @@ -1165,6 +1178,22 @@ async fn fetch_resource_with_retry( None } +#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss)] +fn calculate_retry_backoff(base_delay_ms: u64, multiplier: f64, attempt: u32) -> u64 { + let base = base_delay_ms.max(1); + if multiplier <= 1.0 { + return base; + } + let delay = (base as f64) * multiplier.powi(i32::try_from(attempt).unwrap_or(i32::MAX)); + if !delay.is_finite() || delay < 1.0 { + base + } else if delay >= u64::MAX as f64 { + u64::MAX + } else { + delay as u64 + } +} + /// # Panics pub async fn resource_response( app_state: &AppState, @@ -1301,4 +1330,4 @@ pub fn json_or_bin_response(accept: Option<&String>, data: &T) -> pub fn create_session_fingerprint(fingerprint: &str, username: &str, virtual_id: u32) -> String { format!("{fingerprint}|{username}|{virtual_id}") -} \ No newline at end of file +} diff --git a/backend/src/api/config_watch.rs b/backend/src/api/config_watch.rs index 4d88d2759..7d90a8c9d 100644 --- a/backend/src/api/config_watch.rs +++ b/backend/src/api/config_watch.rs @@ -43,8 +43,8 @@ impl ConfigFile { Ok(()) } - fn load_api_proxy(app_state: &Arc) -> Result<(), TuliproxError> { - match utils::read_api_proxy_config(&app_state.app_config, true) { + async fn load_api_proxy(app_state: &Arc) -> Result<(), TuliproxError> { + match utils::read_api_proxy_config(&app_state.app_config, true).await { Ok(Some(api_proxy)) => { app_state.app_config.set_api_proxy(api_proxy)?; let paths = > as Access>::load(&app_state.app_config.paths); @@ -101,7 +101,7 @@ impl ConfigFile { match self { ConfigFile::ApiProxy => { app_state.event_manager.send_event(EventMessage::ConfigChange(ConfigType::ApiProxy)); - ConfigFile::load_api_proxy(app_state) + ConfigFile::load_api_proxy(app_state).await } ConfigFile::Mapping => { app_state.event_manager.send_event(EventMessage::ConfigChange(ConfigType::Mapping)); diff --git a/backend/src/api/endpoints/custom_video_stream_api.rs b/backend/src/api/endpoints/custom_video_stream_api.rs index ae1cc0a75..68a34f623 100644 --- a/backend/src/api/endpoints/custom_video_stream_api.rs +++ b/backend/src/api/endpoints/custom_video_stream_api.rs @@ -28,11 +28,11 @@ async fn cvs_api( return axum::http::StatusCode::FORBIDDEN.into_response(); } - app_state.connection_manager.update_stream_detail(&user.username, &fingerprint, custom_video_type).await; create_custom_video_stream_response( - &app_state.app_config, + &app_state, + &fingerprint.addr, custom_video_type - ).into_response() + ).await.into_response() } pub fn cvs_api_register() -> axum::Router> { diff --git a/backend/src/api/endpoints/hls_api.rs b/backend/src/api/endpoints/hls_api.rs index 98e2ad89a..6aac833fe 100644 --- a/backend/src/api/endpoints/hls_api.rs +++ b/backend/src/api/endpoints/hls_api.rs @@ -3,15 +3,17 @@ use crate::api::api_utils::{ force_provider_stream_response, get_stream_alternative_url, is_seek_request, }; use crate::api::api_utils::{get_headers_from_request, try_option_bad_request, HeaderFilter}; -use crate::api::model::{ProviderAllocation, UserSession}; -use crate::api::model::{create_custom_video_stream_response, CustomVideoStreamType}; use crate::api::model::AppState; +use crate::api::model::{create_custom_video_stream_response, CustomVideoStreamType}; +use crate::api::model::{ProviderAllocation, UserSession}; use crate::auth::Fingerprint; -use crate::model::{ConfigTarget, ProxyUserCredentials}; use crate::model::{ConfigInput, InputSource}; +use crate::model::{ConfigTarget, ProxyUserCredentials}; use crate::processing::parser::hls::{ get_hls_session_token_and_url_from_token, rewrite_hls, RewriteHlsProps, }; +use crate::repository::m3u_repository::m3u_get_item_for_stream_id; +use crate::repository::xtream_repository; use crate::utils::request; use axum::http::HeaderMap; use axum::response::IntoResponse; @@ -20,8 +22,6 @@ use serde::Deserialize; use shared::model::{PlaylistItemType, StreamChannel, TargetType, UserConnectionPermission, XtreamCluster}; use shared::utils::{is_hls_url, replace_url_extension, sanitize_sensitive_info, CUSTOM_VIDEO_PREFIX, HLS_EXT}; use std::sync::Arc; -use crate::repository::m3u_repository::m3u_get_item_for_stream_id; -use crate::repository::xtream_repository; #[derive(Debug, Deserialize)] struct HlsApiPathParams { @@ -63,7 +63,7 @@ pub(in crate::api) async fn handle_hls_stream_request( match handle { Some(provider_handle) => { match provider_handle.allocation { - ProviderAllocation::Exhausted => (url, None), + ProviderAllocation::Exhausted => (url, None), ProviderAllocation::Available(cfg) | ProviderAllocation::GracePeriod(cfg) => { let stream_url = get_stream_alternative_url(&url, input, &cfg); @@ -169,7 +169,7 @@ async fn get_stream_channel(app_state: &Arc, target: &Arc { + if !auth_required || verify_auth_admin_token(&auth_token, secret_key) { + mem.role = UserRole::Admin; + mem.token = Some(auth_token); + let connections = app_state.active_provider.get_provider_connections_count().await; + Some(ProtocolMessage::ActiveProviderCountResponse(connections)) + } else { + Some(ProtocolMessage::Unauthorized) + } + }, Ok(_) => { trace!("Unexpected protocol message after handshake"); None diff --git a/backend/src/api/endpoints/xtream_api.rs b/backend/src/api/endpoints/xtream_api.rs index 44121fccb..f45cefe4d 100644 --- a/backend/src/api/endpoints/xtream_api.rs +++ b/backend/src/api/endpoints/xtream_api.rs @@ -236,8 +236,7 @@ async fn xtream_player_api_stream( format!("Could not find any user for xc stream {}", stream_req.username) ); if user.permission_denied(app_state) { - app_state.connection_manager.update_stream_detail(&user.username, fingerprint, CustomVideoStreamType::UserAccountExpired).await; - return create_custom_video_stream_response(&app_state.app_config, CustomVideoStreamType::UserAccountExpired,).into_response(); + return create_custom_video_stream_response(app_state, &fingerprint.addr, CustomVideoStreamType::UserAccountExpired).await.into_response(); } let target_name = &target.name; @@ -273,11 +272,10 @@ async fn xtream_player_api_stream( let session_url = if let Some(session) = &user_session { if session.permission == UserConnectionPermission::Exhausted { - app_state.connection_manager.update_stream_detail(&user.username, fingerprint, CustomVideoStreamType::UserConnectionsExhausted).await; return create_custom_video_stream_response( - &app_state.app_config, + app_state, &fingerprint.addr, CustomVideoStreamType::UserConnectionsExhausted, - ) + ).await .into_response(); } @@ -286,11 +284,10 @@ async fn xtream_player_api_stream( .is_over_limit(&session.provider) .await { - app_state.connection_manager.update_stream_detail(&user.username, fingerprint, CustomVideoStreamType::ProviderConnectionsExhausted).await; return create_custom_video_stream_response( - &app_state.app_config, + app_state, &fingerprint.addr, CustomVideoStreamType::ProviderConnectionsExhausted, - ) + ).await .into_response(); } @@ -318,11 +315,10 @@ async fn xtream_player_api_stream( let connection_permission = user.connection_permission(app_state).await; if connection_permission == UserConnectionPermission::Exhausted { - app_state.connection_manager.update_stream_detail(&user.username, fingerprint, CustomVideoStreamType::UserConnectionsExhausted).await; return create_custom_video_stream_response( - &app_state.app_config, + app_state, &fingerprint.addr, CustomVideoStreamType::UserConnectionsExhausted, - ) + ).await .into_response(); } @@ -563,7 +559,7 @@ fn get_doc_resource_field_value<'a>( None } -fn xtream_get_info_resource_url<'a>( +async fn xtream_get_info_resource_url<'a>( config: &'a AppConfig, pli: &'a XtreamPlaylistItem, target: &'a ConfigTarget, @@ -574,12 +570,12 @@ fn xtream_get_info_resource_url<'a>( config, target.name.as_str(), pli.get_virtual_id(), - ), + ).await, XtreamCluster::Series => xtream_repository::xtream_load_series_info( config, target.name.as_str(), pli.get_virtual_id(), - ), + ).await, XtreamCluster::Live => None, }; if let Some(content) = info_content { @@ -653,7 +649,7 @@ fn get_season_info_doc(doc: &Vec, season_id: u32) -> Option<&Value> { None } -fn xtream_get_season_resource_url<'a>( +async fn xtream_get_season_resource_url<'a>( config: &'a AppConfig, pli: &'a XtreamPlaylistItem, target: &'a ConfigTarget, @@ -664,7 +660,7 @@ fn xtream_get_season_resource_url<'a>( config, target.name.as_str(), pli.get_virtual_id(), - ), + ).await, XtreamCluster::Video | XtreamCluster::Live => None, }; if let Some(content) = info_content { @@ -737,14 +733,14 @@ async fn xtream_player_api_resource( &pli, &target, resource - )) + ).await) } else if resource.starts_with(crate::model::XC_SEASON_RESOURCE_PREFIX) { try_result_bad_request!(xtream_get_season_resource_url( &app_state.app_config, &pli, &target, resource - )) + ).await) } else { pli.get_field(resource) }; diff --git a/backend/src/api/main_api.rs b/backend/src/api/main_api.rs index fd8fc8849..2eed2389b 100644 --- a/backend/src/api/main_api.rs +++ b/backend/src/api/main_api.rs @@ -59,7 +59,7 @@ async fn healthcheck() -> impl axum::response::IntoResponse { axum::Json(create_healthcheck()) } -fn create_shared_data( +async fn create_shared_data( app_config: &Arc, forced_targets: &Arc, ) -> AppState { @@ -68,7 +68,7 @@ fn create_shared_data( let use_geoip = config.is_geoip_enabled(); let geoip = if use_geoip { let path = get_geoip_path(&config.working_dir); - let _file_lock = app_config.file_locks.read_lock(&path); + let _file_lock = app_config.file_locks.read_lock(&path).await; match GeoIp::load(&path) { Ok(db) => { info!("GeoIp db loaded"); @@ -84,9 +84,8 @@ fn create_shared_data( }; let cache = create_cache(&config); - let (provider_change_tx, provider_change_rx) = tokio::sync::mpsc::unbounded_channel(); - let event_manager = Arc::new(EventManager::new(provider_change_rx, )); - let active_provider = Arc::new(ActiveProviderManager::new(app_config, provider_change_tx)); + let event_manager = Arc::new(EventManager::new()); + let active_provider = Arc::new(ActiveProviderManager::new(app_config, &event_manager)); let shared_stream_manager = Arc::new(SharedStreamManager::new(Arc::clone(&active_provider))); let active_users = Arc::new(ActiveUserManager::new(&config,&geoip, &event_manager)); let connection_manager = Arc::new(ConnectionManager::new(&active_users, &active_provider, &shared_stream_manager, &event_manager)); @@ -250,7 +249,7 @@ pub async fn start_server( if web_ui_enabled { infos.push(format!("Web root: {}", web_dir_path.display())); } - let app_shared_data = create_shared_data(&app_config, &targets); + let app_shared_data = create_shared_data(&app_config, &targets).await; let app_state = Arc::new(app_shared_data); let shared_data = Arc::clone(&app_state); diff --git a/backend/src/api/model/active_provider_manager.rs b/backend/src/api/model/active_provider_manager.rs index 0714d1d55..26e6a2912 100644 --- a/backend/src/api/model/active_provider_manager.rs +++ b/backend/src/api/model/active_provider_manager.rs @@ -1,5 +1,5 @@ use crate::api::model::provider_lineup_manager::{ProviderAllocation, ProviderLineupManager}; -use crate::api::model::{ProviderConfig, ProviderConnectionChangeSender}; +use crate::api::model::{EventManager, ProviderConfig}; use crate::model::{AppConfig, ConfigInput}; use log::{debug, error}; use shared::utils::{default_grace_period_millis, default_grace_period_timeout_secs, sanitize_sensitive_info}; @@ -46,11 +46,11 @@ pub struct ActiveProviderManager { } impl ActiveProviderManager { - pub fn new(cfg: &AppConfig, connection_change_sender: ProviderConnectionChangeSender) -> Self { + pub fn new(cfg: &AppConfig, event_manager: &Arc) -> Self { let (grace_period_millis, grace_period_timeout_secs) = Self::get_grace_options(cfg); let inputs = Self::get_config_inputs(cfg); Self { - providers: ProviderLineupManager::new(inputs, grace_period_millis, grace_period_timeout_secs, connection_change_sender), + providers: ProviderLineupManager::new(inputs, grace_period_millis, grace_period_timeout_secs, event_manager), connections: RwLock::new(Connections::default()), } } @@ -179,4 +179,8 @@ impl ActiveProviderManager { shared_allocation.connections.insert(*addr); } } + + pub async fn get_provider_connections_count(&self) -> usize { + self.providers.active_connection_count().await + } } \ No newline at end of file diff --git a/backend/src/api/model/active_user_manager.rs b/backend/src/api/model/active_user_manager.rs index 0211cb5ef..203aa39de 100644 --- a/backend/src/api/model/active_user_manager.rs +++ b/backend/src/api/model/active_user_manager.rs @@ -220,11 +220,17 @@ impl ActiveUserManager { }) } - pub async fn update_stream_detail(&self, username: &str, fingerprint: &Fingerprint, video_type: CustomVideoStreamType) -> Option { + pub async fn update_stream_detail(&self, addr: &SocketAddr, video_type: CustomVideoStreamType) -> Option { let mut user_connections = self.connections.write().await; - if let Some(connection_data) = user_connections.by_key.get_mut(username) { + let username = { + match user_connections.key_by_addr.get(addr) { + Some(username) => username.clone(), + None => return None, + } + }; + if let Some(connection_data) = user_connections.by_key.get_mut(&username) { for stream in &mut connection_data.streams { - if stream.addr == fingerprint.addr { + if &stream.addr == addr { stream.provider = String::from("tuliprox"); stream.channel.title = video_type.to_string(); stream.channel.group = String::new(); @@ -236,33 +242,44 @@ impl ActiveUserManager { } #[allow(clippy::too_many_arguments)] - pub async fn add_connection(&self, username: &str, max_connections: u32, fingerprint: &Fingerprint, - provider: &str, stream_channel: StreamChannel, user_agent: Cow<'_, str>) -> StreamInfo { + pub async fn add_connection(&self, addr: &SocketAddr) { + let mut user_connections = self.connections.write().await; + if !user_connections.key_by_addr.contains_key(addr) { + user_connections.key_by_addr.insert(*addr, String::new()); + } + } + + #[allow(clippy::too_many_arguments)] + pub async fn update_connection(&self, username: &str, max_connections: u32, fingerprint: &Fingerprint, + provider: &str, stream_channel: StreamChannel, user_agent: Cow<'_, str>) -> Option { let stream_info = { let mut user_connections = self.connections.write().await; - let already_counted = user_connections.key_by_addr.get(&fingerprint.addr).is_some_and(|u| u == username); + // needs to be registered through socket connection to avoid race time conditions through short disconnect + if !user_connections + .key_by_addr + .contains_key(&fingerprint.addr) { + return None; + } + + user_connections + .key_by_addr.insert(fingerprint.addr, username.to_string()); let connection_data = user_connections.by_key .entry(username.to_string()) .or_insert_with(|| UserConnectionData::new(0, max_connections)); connection_data.max_connections = max_connections; - let existing_stream_info = if already_counted { - if let Some(existing) = connection_data - .streams - .iter_mut() - .find(|s| s.addr == fingerprint.addr) - { - existing.channel = stream_channel.clone(); - existing.provider = provider.to_string(); - Some(existing.clone()) - } else { - None - } - } else { - None - }; + let existing_stream_info = connection_data + .streams + .iter_mut() + .find(|s| s.addr == fingerprint.addr) + .map(|stream_info| { + stream_info.channel = stream_channel.clone(); + stream_info.provider = provider.to_string(); + stream_info.clone() + }); + if let Some(stream_info) = existing_stream_info { stream_info } else { let country = { let geoip = self.geo_ip.load(); @@ -295,7 +312,7 @@ impl ActiveUserManager { self.log_active_user().await; - stream_info + Some(stream_info) } fn is_log_user_enabled(&self) -> bool { diff --git a/backend/src/api/model/app_state.rs b/backend/src/api/model/app_state.rs index c2bd74f10..7287862d7 100644 --- a/backend/src/api/model/app_state.rs +++ b/backend/src/api/model/app_state.rs @@ -282,7 +282,7 @@ impl AppState { if changes.geoip { let new_geoip = if use_geoip { let path = get_geoip_path(&working_dir); - let _file_lock = self.app_config.file_locks.read_lock(&path); + let _file_lock = self.app_config.file_locks.read_lock(&path).await; GeoIp::load(&path).ok().map(Arc::new) } else { None diff --git a/backend/src/api/model/connection_manager.rs b/backend/src/api/model/connection_manager.rs index 481e03e24..91d6e8d2c 100644 --- a/backend/src/api/model/connection_manager.rs +++ b/backend/src/api/model/connection_manager.rs @@ -1,8 +1,8 @@ use std::borrow::Cow; use std::net::SocketAddr; -use crate::api::model::{ActiveProviderManager, ActiveUserManager, CustomVideoStreamType, EventManager, EventMessage, SharedStreamManager}; +use crate::api::model::{ActiveProviderManager, ActiveUserManager, CustomVideoStreamType, EventManager, EventMessage, ProviderHandle, SharedStreamManager}; use std::sync::Arc; -use log::debug; +use log::{debug}; use shared::model::{ActiveUserConnectionChange, StreamChannel}; use crate::auth::Fingerprint; @@ -56,21 +56,35 @@ impl ConnectionManager { self.shared_stream_manager.release_connection(addr, false).await; } + pub async fn release_provider_handle(&self, provider_handle: Option) { + if let Some(handle) = provider_handle { + self.release_provider_connection(&handle.id).await; + } + } #[allow(clippy::too_many_arguments)] - pub async fn add_connection(&self, username: &str, max_connections: u32, fingerprint: &Fingerprint, + pub async fn add_connection(&self, addr: &SocketAddr) { + self.user_manager.add_connection(addr).await; + } + + #[allow(clippy::too_many_arguments)] + pub async fn update_connection(&self, username: &str, max_connections: u32, fingerprint: &Fingerprint, provider: &str, stream_channel: StreamChannel, user_agent: Cow<'_, str>) { - let stream_info = self.user_manager.add_connection(username, max_connections, fingerprint, provider, stream_channel, user_agent).await; - self.event_manager.send_event(EventMessage::ActiveUser(ActiveUserConnectionChange::Connected(stream_info))); + if let Some(stream_info) = self.user_manager.update_connection(username, max_connections, fingerprint, provider, stream_channel, user_agent).await { + self.event_manager.send_event(EventMessage::ActiveUser(ActiveUserConnectionChange::Updated(stream_info))); + } else { + // TODO what do we do here ? + } } // pub fn send_active_user_stats(&self, user_count: usize, user_connection_count: usize) { // self.event_manager.send_event(EventMessage::ActiveUser(ActiveUserConnectionChange::Connections(user_count, user_connection_count))); // } - pub async fn update_stream_detail(&self, username: &str, fingerprint: &Fingerprint, video_type: CustomVideoStreamType) { - if let Some(stream_info) = self.user_manager.update_stream_detail(username, fingerprint, video_type).await { + pub async fn update_stream_detail(&self, addr: &SocketAddr, video_type: CustomVideoStreamType) { + if let Some(stream_info) = self.user_manager.update_stream_detail(addr, video_type).await { self.event_manager.send_event(EventMessage::ActiveUser(ActiveUserConnectionChange::Updated(stream_info))); } } + } \ No newline at end of file diff --git a/backend/src/api/model/event_manager.rs b/backend/src/api/model/event_manager.rs index ee4d524a7..6e84eeb25 100644 --- a/backend/src/api/model/event_manager.rs +++ b/backend/src/api/model/event_manager.rs @@ -1,6 +1,5 @@ -use log::{info, trace}; +use log::{trace}; use shared::model::{ActiveUserConnectionChange, ConfigType, PlaylistUpdateState}; -use crate::api::model::{ProviderConnectionChangeReceiver}; #[allow(clippy::large_enum_variant)] #[derive(Clone, PartialEq)] @@ -20,29 +19,9 @@ pub struct EventManager { } impl EventManager { - pub fn new(mut provider_change_rx: ProviderConnectionChangeReceiver, - ) -> Self { + pub fn new() -> Self { let (channel_tx, _channel_rx) = tokio::sync::broadcast::channel(10); - let channel_tx_clone = channel_tx.clone(); - tokio::spawn(async move { - loop { - tokio::select! { - - Some((provider, connection_count)) = provider_change_rx.recv() => { - if let Err(e) = channel_tx_clone.send(EventMessage::ActiveProvider(provider, connection_count)) { - trace!("Failed to send active provider change event: {e}"); - } - } - else => { - // Both channels are closed, exit gracefully - info!("All input channels closed, terminating event manager task"); - break; - } - } - } - }); - Self { channel_tx, //channel_rx, @@ -53,11 +32,24 @@ impl EventManager { self.channel_tx.subscribe() } - pub fn send_event(&self, event: EventMessage) { + pub fn send_event(&self, event: EventMessage) -> bool { if let Err(err) = self.channel_tx.send(event) { - trace!("Failed to send event: {err}"); + trace!("Failed to send event: {err}"); + false + } else { + true } } + pub fn send_provider_event(&self, provider: &str, connection_count: usize) { + if !self.send_event(EventMessage::ActiveProvider(String::from(provider), connection_count)) { + trace!("Failed to send connection change: {provider}: {connection_count}"); + } + } } +impl Default for EventManager { + fn default() -> Self { + Self::new() + } +} diff --git a/backend/src/api/model/provider_config.rs b/backend/src/api/model/provider_config.rs index 02176ca92..09e604626 100644 --- a/backend/src/api/model/provider_config.rs +++ b/backend/src/api/model/provider_config.rs @@ -9,8 +9,6 @@ use shared::model::InputType; use shared::write_if_some; use crate::api::model::ProviderAllocation; -pub type ProviderConnectionChangeSender = tokio::sync::mpsc::UnboundedSender<(String, usize)>; -pub type ProviderConnectionChangeReceiver = tokio::sync::mpsc::UnboundedReceiver<(String, usize)>; pub type ProviderConnectionChangeCallback = Arc; diff --git a/backend/src/api/model/provider_lineup_manager.rs b/backend/src/api/model/provider_lineup_manager.rs index c7be1f513..88834306a 100644 --- a/backend/src/api/model/provider_lineup_manager.rs +++ b/backend/src/api/model/provider_lineup_manager.rs @@ -1,9 +1,9 @@ use crate::api::model::provider_config::ProviderConfigWrapper; -use crate::api::model::{ProviderConfig, ProviderConfigConnection, ProviderConnectionChangeCallback, ProviderConnectionChangeSender}; +use crate::api::model::{EventManager, ProviderConfig, ProviderConfigConnection, ProviderConnectionChangeCallback}; use crate::utils::debug_if_enabled; use crate::model::ConfigInput; use arc_swap::ArcSwap; -use log::{debug, error, log_enabled}; +use log::{debug, log_enabled}; use shared::utils::{display_vec, sanitize_sensitive_info}; use std::collections::HashMap; use std::fmt; @@ -516,35 +516,30 @@ pub(in crate::api::model) struct ProviderLineupManager { grace_period_timeout_secs: AtomicU64, inputs: Arc>>>, providers: Arc>>, - connection_change_tx: ProviderConnectionChangeSender, + event_manager: Arc, } impl ProviderLineupManager { - pub fn new(inputs: Vec>, grace_period_millis: u64, grace_period_timeout_secs: u64, connection_change_tx: ProviderConnectionChangeSender) -> Self { - let lineups = inputs.iter().map(|i| Self::create_lineup(i, None, connection_change_tx.clone())).collect(); + pub fn new(inputs: Vec>, grace_period_millis: u64, grace_period_timeout_secs: u64, event_manager: &Arc) -> Self { + let lineups = inputs.iter().map(|i| Self::create_lineup(i, None, event_manager)).collect(); Self { grace_period_millis: AtomicU64::new(grace_period_millis), grace_period_timeout_secs: AtomicU64::new(grace_period_timeout_secs), inputs: Arc::new(ArcSwap::from_pointee(inputs)), providers: Arc::new(ArcSwap::from_pointee(lineups)), - connection_change_tx, + event_manager: Arc::clone(event_manager), } } - fn create_lineup(cfg_input: &ConfigInput, provider_connections: Option<&HashMap<&str, ProviderConfigConnection>>, connection_change_sender: ProviderConnectionChangeSender) -> ProviderLineup { + fn create_lineup(cfg_input: &ConfigInput, provider_connections: Option<&HashMap<&str, ProviderConfigConnection>>, event_manager: &Arc) -> ProviderLineup { let get_connections = provider_connections.map(|c| |name: &str| c.get(name)); - let cfg_name = cfg_input.name.clone(); - let on_connection_change: ProviderConnectionChangeCallback = Arc::new(move |_name: &str, connections: usize| { - let connection_change_sender = connection_change_sender.clone(); - let provider_cfg_name = cfg_name.clone(); - if let Err(err) = connection_change_sender.send((provider_cfg_name.clone(), connections)) { - error!("Failed to send connection change: {provider_cfg_name}: {connections}, {err}"); - } + //let cfg_name = cfg_input.name.clone(); + let event_manager = Arc::clone(event_manager); + let on_connection_change: ProviderConnectionChangeCallback = Arc::new(move |name: &str, connections: usize| { + event_manager.send_provider_event(name, connections); }); - let on_connection_change = Arc::new(on_connection_change); - if cfg_input.aliases.as_ref().is_some_and(|a| !a.is_empty()) { ProviderLineup::Multi(MultiProviderLineup::new(cfg_input, get_connections, &on_connection_change)) } else { @@ -645,7 +640,7 @@ impl ProviderLineupManager { let mut new_lineups: Vec = Vec::with_capacity(new_inputs.len()); let connections = Some(provider_connections); for input in &new_inputs { - new_lineups.push(Self::create_lineup(input, connections.as_ref(), self.connection_change_tx.clone())); + new_lineups.push(Self::create_lineup(input, connections.as_ref(), &self.event_manager)); } debug_if_enabled!("inputs {}", sanitize_sensitive_info(&display_vec(&new_inputs))); @@ -737,6 +732,22 @@ impl ProviderLineupManager { } } + pub async fn active_connection_count(&self) -> usize { + let mut count = 0; + let providers = self.providers.load(); + for lineup in providers.iter() { + match lineup { + ProviderLineup::Single(provider_lineup) => { + count += provider_lineup.provider.get_current_connections().await; + } + ProviderLineup::Multi(provider_lineup) => { + count += provider_lineup.get_total_connections().await; + } + } + } + count + } + pub async fn is_over_limit(&self, provider_name: &str) -> bool { let providers = self.providers.load(); if let Some((_, config)) = Self::get_provider_config_by_name(provider_name, &providers) { diff --git a/backend/src/api/model/stream.rs b/backend/src/api/model/stream.rs index 0ca2b7048..d16c1080a 100644 --- a/backend/src/api/model/stream.rs +++ b/backend/src/api/model/stream.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; use std::sync::Arc; -use crate::api::model::{ProviderHandle, StreamError}; +use crate::api::model::{CustomVideoStreamType, ProviderHandle, StreamError}; use axum::http::StatusCode; use bytes::Bytes; use futures::stream::BoxStream; @@ -10,7 +10,7 @@ use crate::tools::atomic_once_flag::AtomicOnceFlag; pub type BoxedProviderStream = BoxStream<'static, Result>; pub type ProviderStreamHeader = Vec<(String, String)>; -pub type ProviderStreamInfo = Option<(ProviderStreamHeader, StatusCode, Option)>; +pub type ProviderStreamInfo = Option<(ProviderStreamHeader, StatusCode, Option, Option)>; pub type ProviderStreamResponse = (Option, ProviderStreamInfo); diff --git a/backend/src/api/model/streams/active_client_stream.rs b/backend/src/api/model/streams/active_client_stream.rs index 059379e6d..12ef62b99 100644 --- a/backend/src/api/model/streams/active_client_stream.rs +++ b/backend/src/api/model/streams/active_client_stream.rs @@ -48,7 +48,11 @@ impl ActiveClientStream { let provider_name = stream_details.provider_name.as_ref().map_or_else(String::new, ToString::to_string); let user_agent = req_headers.get(USER_AGENT).map(|h| String::from_utf8_lossy(h.as_bytes())).unwrap_or_default(); - app_state.connection_manager.add_connection(username, user.max_connections, fingerprint, &provider_name, stream_channel, user_agent).await; + + app_state.connection_manager.update_connection(username, user.max_connections, fingerprint, &provider_name, stream_channel, user_agent).await; + if let Some((_,_,_m_, Some(cvt))) = stream_details.stream_info.as_ref() { + app_state.connection_manager.update_stream_detail(&fingerprint.addr, *cvt).await; + } let cfg = &app_state.app_config; let waker = Some(Arc::new(AtomicWaker::new())); let waker_clone = waker.clone(); @@ -63,9 +67,8 @@ impl ActiveClientStream { let stream = match stream_details.stream.take() { None => { - if let Some(guard) = stream_details.provider_handle.as_ref() { - app_state.active_provider.release_handle(guard).await; - } + let provider_handle = stream_details.provider_handle.take(); + app_state.connection_manager.release_provider_handle(provider_handle).await; futures::stream::empty::>().boxed() } Some(stream) => { @@ -128,7 +131,6 @@ impl ActiveClientStream { let connection_manager = Arc::clone(&connection_manager); let reconnect_flag = stream_details.reconnect_flag.clone(); let fingerprint = fingerprint.clone(); - let username = user.username.clone(); tokio::spawn(async move { tokio::time::sleep(tokio::time::Duration::from_millis(grace_period_millis)).await; @@ -137,7 +139,7 @@ impl ActiveClientStream { let active_connections = user_manager.user_connections(&username).await; if active_connections > max_connections { stream_strategy_flag_copy.store(USER_EXHAUSTED_STREAM, std::sync::atomic::Ordering::Release); - connection_manager.update_stream_detail(&username, &fingerprint, CustomVideoStreamType::UserConnectionsExhausted).await; + connection_manager.update_stream_detail(&fingerprint.addr, CustomVideoStreamType::UserConnectionsExhausted).await; info!("User connections exhausted for active clients: {username}"); updated = true; } @@ -147,7 +149,7 @@ impl ActiveClientStream { if let Some(provider_name) = provider_grace_check { if provider_manager.is_over_limit(&provider_name).await { stream_strategy_flag_copy.store(PROVIDER_EXHAUSTED_STREAM, std::sync::atomic::Ordering::Release); - connection_manager.update_stream_detail(&username, &fingerprint, CustomVideoStreamType::ProviderConnectionsExhausted).await; + connection_manager.update_stream_detail(&fingerprint.addr, CustomVideoStreamType::ProviderConnectionsExhausted).await; info!("Provider connections exhausted for active clients: {provider_name}"); updated = true; } diff --git a/backend/src/api/model/streams/provider_stream.rs b/backend/src/api/model/streams/provider_stream.rs index f43d8a10d..386be8e55 100644 --- a/backend/src/api/model/streams/provider_stream.rs +++ b/backend/src/api/model/streams/provider_stream.rs @@ -1,5 +1,5 @@ use crate::api::api_utils::{HeaderFilter}; -use crate::api::model::{CustomVideoStream, ThrottledStream}; +use crate::api::model::{AppState, CustomVideoStream, ThrottledStream}; use crate::model::{AppConfig}; use shared::model::PlaylistItemType; use log::{trace}; @@ -10,6 +10,8 @@ use crate::api::model::TransportStreamBuffer; use crate::api::api_utils::try_unwrap_body; use std::str::FromStr; use std::fmt; +use std::net::SocketAddr; +use std::sync::Arc; use serde::{Serialize, Deserialize, Serializer, Deserializer}; #[derive(Debug, Copy, Clone)] @@ -64,14 +66,14 @@ impl<'de> Deserialize<'de> for CustomVideoStreamType { } } -fn create_video_stream(video_buffer: Option<&TransportStreamBuffer>, headers: &[(String, String)], log_message: &str) -> ProviderStreamResponse { +fn create_video_stream(stream_type: CustomVideoStreamType, video_buffer: Option<&TransportStreamBuffer>, headers: &[(String, String)], log_message: &str) -> ProviderStreamResponse { if let Some(video) = video_buffer { trace!("{log_message}"); let mut response_headers: Vec<(String, String)> = headers.iter() .filter(|(key, _)| !(key.eq("content-type") || key.eq("content-length") || key.contains("range"))) .map(|(key, value)| (key.clone(), value.clone())).collect(); response_headers.push(("content-type".to_string(), "video/mp2t".to_string())); - (Some(Box::pin(ThrottledStream::new(CustomVideoStream::new(video.clone()), 8000))), Some((response_headers, StatusCode::OK, None))) + (Some(Box::pin(ThrottledStream::new(CustomVideoStream::new(video.clone()), 8000))), Some((response_headers, StatusCode::OK, None, Some(stream_type)))) } else { (None, None) } @@ -80,34 +82,37 @@ fn create_video_stream(video_buffer: Option<&TransportStreamBuffer>, headers: &[ pub fn create_channel_unavailable_stream(cfg: &AppConfig, headers: &[(String, String)], status: StatusCode) -> ProviderStreamResponse { let custom_stream_response = cfg.custom_stream_response.load(); let video = custom_stream_response.as_ref().and_then(|c| c.channel_unavailable.as_ref()); - create_video_stream(video, headers, &format!("Streaming response channel unavailable for status {status}")) + create_video_stream(CustomVideoStreamType::ChannelUnavailable, video, headers, &format!("Streaming response channel unavailable for status {status}")) } pub fn create_user_connections_exhausted_stream(cfg: &AppConfig, headers: &[(String, String)]) -> ProviderStreamResponse { let custom_stream_response = cfg.custom_stream_response.load(); let video = custom_stream_response.as_ref().and_then(|c| c.user_connections_exhausted.as_ref()); - create_video_stream(video, headers, "Streaming response user connections exhausted") + create_video_stream(CustomVideoStreamType::UserConnectionsExhausted, video, headers, "Streaming response user connections exhausted") } pub fn create_provider_connections_exhausted_stream(cfg: &AppConfig, headers: &[(String, String)]) -> ProviderStreamResponse { let custom_stream_response = cfg.custom_stream_response.load(); let video = custom_stream_response.as_ref().and_then(|c| c.provider_connections_exhausted.as_ref()); - create_video_stream(video, headers, "Streaming response provider connections exhausted") + create_video_stream(CustomVideoStreamType::ProviderConnectionsExhausted, video, headers, "Streaming response provider connections exhausted") } pub fn create_user_account_expired_stream(cfg: &AppConfig, headers: &[(String, String)]) -> ProviderStreamResponse { let custom_stream_response = cfg.custom_stream_response.load(); let video = custom_stream_response.as_ref().and_then(|c| c.user_account_expired.as_ref()); - create_video_stream(video, headers, "Streaming response user account expired") + create_video_stream(CustomVideoStreamType::UserAccountExpired, video, headers, "Streaming response user account expired") } -pub fn create_custom_video_stream_response(config: &AppConfig, video_response: CustomVideoStreamType) -> impl axum::response::IntoResponse + Send { - if let (Some(stream), Some((headers, status_code, _))) = match video_response { +pub async fn create_custom_video_stream_response(app_state: &Arc, addr: &SocketAddr, video_response: CustomVideoStreamType) -> impl axum::response::IntoResponse + Send { + let config = &app_state.app_config; + if let (Some(stream), Some((headers, status_code, _, _))) = match video_response { CustomVideoStreamType::ChannelUnavailable => create_channel_unavailable_stream(config, &[], StatusCode::BAD_REQUEST), CustomVideoStreamType::UserConnectionsExhausted => create_user_connections_exhausted_stream(config, &[]), CustomVideoStreamType::ProviderConnectionsExhausted => create_provider_connections_exhausted_stream(config, &[]), CustomVideoStreamType::UserAccountExpired => create_user_account_expired_stream(config, &[]), } { + app_state.connection_manager.update_stream_detail(addr, video_response).await; + app_state.connection_manager.release_provider_connection(addr).await; let mut builder = axum::response::Response::builder() .status(status_code); for (key, value) in headers { diff --git a/backend/src/api/model/streams/provider_stream_factory.rs b/backend/src/api/model/streams/provider_stream_factory.rs index e2ab6181f..af508e5f3 100644 --- a/backend/src/api/model/streams/provider_stream_factory.rs +++ b/backend/src/api/model/streams/provider_stream_factory.rs @@ -1,10 +1,10 @@ use crate::api::api_utils::{get_headers_from_request, StreamOptions}; -use crate::api::model::get_response_headers; +use crate::api::model::{get_response_headers, AppState, CustomVideoStreamType}; use crate::api::model::StreamError; use crate::api::model::TimedClientStream; use crate::api::model::{create_channel_unavailable_stream, get_header_filter_for_item_type}; use crate::api::model::{BoxedProviderStream, ProviderStreamFactoryResponse}; -use crate::model::{AppConfig, ReverseProxyDisabledHeaderConfig}; +use crate::model::{ReverseProxyDisabledHeaderConfig}; use crate::tools::atomic_once_flag::AtomicOnceFlag; use crate::utils::debug_if_enabled; use crate::utils::request::{classify_content_type, get_request_headers, MimeCategory}; @@ -16,6 +16,7 @@ use reqwest::StatusCode; use shared::model::{PlaylistItemType, DEFAULT_USER_AGENT}; use shared::utils::{filter_request_header, sanitize_sensitive_info}; use std::collections::HashMap; +use std::net::SocketAddr; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -29,6 +30,7 @@ const ERR_MAX_RETRY_COUNT: u32 = 5; #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone)] pub struct ProviderStreamFactoryOptions { + addr: SocketAddr, // item_type: PlaylistItemType, reconnect_enabled: bool, force_reconnect_secs: u32, @@ -43,7 +45,9 @@ pub struct ProviderStreamFactoryOptions { } impl ProviderStreamFactoryOptions { + #[allow(clippy::too_many_arguments)] pub(crate) fn new( + addr: SocketAddr, item_type: PlaylistItemType, share_stream: bool, stream_options: &StreamOptions, @@ -71,6 +75,7 @@ impl ProviderStreamFactoryOptions { Self { // item_type, + addr, reconnect_enabled: stream_options.stream_retry, force_reconnect_secs: stream_options.stream_force_retry_secs, pipe_stream: stream_options.pipe_provider_stream, @@ -257,11 +262,11 @@ fn prepare_client( } async fn provider_stream_request( - cfg: &AppConfig, - request_client: Arc, + app_state: &Arc, + request_client: &Arc, stream_options: &ProviderStreamFactoryOptions, ) -> Result, StatusCode> { - let (client, _partial_content) = prepare_client(&request_client, stream_options); + let (client, _partial_content) = prepare_client(request_client, stream_options); match client.send().await { Ok(mut response) => { let status = response.status(); @@ -286,6 +291,7 @@ async fn provider_stream_request( response_headers, response.status(), Some(response.url().clone()), + None, )) }; @@ -316,17 +322,7 @@ async fn provider_stream_request( | StatusCode::UNAUTHORIZED | StatusCode::METHOD_NOT_ALLOWED | StatusCode::BAD_REQUEST => { - if let (Some(boxed_provider_stream), response_info) = - create_channel_unavailable_stream( - cfg, - &get_response_headers(stream_options.get_headers()), - StatusCode::BAD_GATEWAY, - ) - { - Ok(Some((boxed_provider_stream, response_info))) - } else { - Err(StatusCode::SERVICE_UNAVAILABLE) - } + handle_channel_unavailable_stream(app_state, stream_options).await } _ => Err(status), }; @@ -338,17 +334,7 @@ async fn provider_stream_request( | StatusCode::BAD_GATEWAY | StatusCode::SERVICE_UNAVAILABLE | StatusCode::GATEWAY_TIMEOUT => { - if let (Some(boxed_provider_stream), response_info) = - create_channel_unavailable_stream( - cfg, - &get_response_headers(stream_options.get_headers()), - StatusCode::SERVICE_UNAVAILABLE, - ) - { - Ok(Some((boxed_provider_stream, response_info))) - } else { - Err(StatusCode::SERVICE_UNAVAILABLE) - } + handle_channel_unavailable_stream(app_state, stream_options).await } _ => Err(status), }; @@ -356,22 +342,33 @@ async fn provider_stream_request( Err(status) } Err(_err) => { - if let (Some(boxed_provider_stream), response_info) = create_channel_unavailable_stream( - cfg, - &get_response_headers(stream_options.get_headers()), - StatusCode::BAD_GATEWAY, - ) { - Ok(Some((boxed_provider_stream, response_info))) - } else { - Err(StatusCode::SERVICE_UNAVAILABLE) - } + handle_channel_unavailable_stream(app_state, stream_options).await } } } +async fn handle_channel_unavailable_stream(app_state: &Arc, + stream_options: &ProviderStreamFactoryOptions +) -> Result, StatusCode> { + app_state.connection_manager.update_stream_detail(&stream_options.addr, CustomVideoStreamType::ChannelUnavailable).await; + app_state.connection_manager.release_provider_connection(&stream_options.addr).await; + + if let (Some(boxed_provider_stream), response_info) = + create_channel_unavailable_stream( + &app_state.app_config, + &get_response_headers(stream_options.get_headers()), + StatusCode::SERVICE_UNAVAILABLE, + ) + { + Ok(Some((boxed_provider_stream, response_info))) + } else { + Err(StatusCode::SERVICE_UNAVAILABLE) + } +} + async fn get_provider_stream( - cfg: &AppConfig, - client: Arc, + app_state: &Arc, + client: &Arc, stream_options: &ProviderStreamFactoryOptions, ) -> Result, StatusCode> { let url = stream_options.get_url(); @@ -380,7 +377,7 @@ async fn get_provider_stream( let mut connect_err: u32 = 1; while stream_options.should_continue() { - match provider_stream_request(cfg, Arc::clone(&client), stream_options).await { + match provider_stream_request(app_state, client, stream_options).await { Ok(Some(stream_response)) => { return Ok(Some(stream_response)); } @@ -443,8 +440,8 @@ async fn get_provider_stream( #[allow(clippy::too_many_lines)] pub async fn create_provider_stream( - cfg: Arc, - client: Arc, + app_state: &Arc, + client: &Arc, stream_options: ProviderStreamFactoryOptions, ) -> Option { let client_stream_factory = |stream, reconnect_flag, range_cnt| { @@ -471,9 +468,9 @@ pub async fn create_provider_stream( .boxed() }; - match get_provider_stream(&cfg, Arc::clone(&client), &stream_options).await { + match get_provider_stream(app_state, client, &stream_options).await { Ok(Some((init_stream, info))) => { - let is_media_stream_or_not_piped = if let Some((headers, _, _)) = &info { + let is_media_stream_or_not_piped = if let Some((headers, _, _, _custom_video_type)) = &info { // if it is piped or no video stream, then we don't reconnect !stream_options.pipe_stream && classify_content_type(headers) == MimeCategory::Video } else { @@ -485,22 +482,24 @@ pub async fn create_provider_stream( let continue_client_signal = Arc::clone(&continue_signal); let continue_streaming_signal = continue_client_signal.clone(); let stream_options_provider = stream_options.clone(); - let config = Arc::clone(&cfg); + let app_state_clone = Arc::clone(app_state); + let client = Arc::clone(client); let unfold: BoxedProviderStream = stream::unfold((), move |()| { let client = Arc::clone(&client); let stream_opts = stream_options_provider.clone(); let continue_streaming = continue_streaming_signal.clone(); - let config_clone = Arc::clone(&config); + let app_state_clone = Arc::clone(&app_state_clone); async move { if continue_streaming.is_active() { - match get_provider_stream(&config_clone, client, &stream_opts).await { + match get_provider_stream(&app_state_clone, &client, &stream_opts).await { Ok(Some((stream, _info))) => Some((stream, ())), Ok(None) => None, Err(status) => { + app_state_clone.connection_manager.release_provider_connection(&stream_opts.addr).await; continue_streaming.notify(); if let (Some(boxed_provider_stream), _response_info) = create_channel_unavailable_stream( - &config_clone, + &app_state_clone.app_config, &get_response_headers(stream_opts.get_headers()), status, ) @@ -540,8 +539,9 @@ pub async fn create_provider_stream( } Ok(None) => None, Err(status) => { + app_state.connection_manager.release_provider_connection(&stream_options.addr).await; if let (Some(boxed_provider_stream), response_info) = create_channel_unavailable_stream( - &cfg, + &app_state.app_config, &get_response_headers(stream_options.get_headers()), status, ) { diff --git a/backend/src/api/model/streams/shared_stream_manager.rs b/backend/src/api/model/streams/shared_stream_manager.rs index 1cbb7296e..752610d6f 100644 --- a/backend/src/api/model/streams/shared_stream_manager.rs +++ b/backend/src/api/model/streams/shared_stream_manager.rs @@ -1,5 +1,6 @@ use crate::api::model::AppState; use crate::api::model::{ActiveProviderManager, ProviderHandle, StreamError}; +use crate::model::Config; use crate::utils::debug_if_enabled; use bytes::Bytes; use futures::stream::BoxStream; @@ -22,8 +23,7 @@ use tokio::sync::{mpsc, Mutex, RwLock}; use tokio_stream::wrappers::ReceiverStream; use tokio_util::sync::CancellationToken; -// TODO make this configurable -const MIN_SHARED_BUFFER_SIZE: usize = 1024 * 1024 * 12; // 12 MB +const DEFAULT_SHARED_BUFFER_SIZE_BYTES: usize = 1024 * 1024 * 12; // 12 MB const YIELD_COUNTER: usize = 200; @@ -49,6 +49,16 @@ where } } +fn resolve_min_burst_buffer_bytes(config: &Config) -> usize { + config + .reverse_proxy + .as_ref() + .and_then(|rp| rp.stream.as_ref()) + .and_then(|stream| usize::try_from(stream.shared_burst_buffer_mb.saturating_mul(1024 * 1024)).ok()) + .unwrap_or(DEFAULT_SHARED_BUFFER_SIZE_BYTES) + .max(1) +} + fn convert_stream(stream: BoxStream) -> BoxStream> { ReceiverStreamWrapper { stream }.boxed() } @@ -129,10 +139,10 @@ pub struct SharedStreamState { impl SharedStreamState { fn new(headers: Vec<(String, String)>, buf_size: usize, - provider_guard: Option) -> Self { + provider_guard: Option, min_burst_buffer_size: usize) -> Self { let (broadcaster, _) = tokio::sync::broadcast::channel(buf_size); // TODO channel size versus byte size, channels are chunk sized, burst_buffer byte sized - let burst_buffer_size_in_bytes = MIN_SHARED_BUFFER_SIZE.max(buf_size * 1024 * 12); + let burst_buffer_size_in_bytes = min_burst_buffer_size.max(buf_size * 1024 * 12); Self { headers, buf_size, @@ -393,7 +403,9 @@ impl SharedStreamManager { E: std::fmt::Debug + Send, { let buf_size = CHANNEL_SIZE.max(buffer_size); - let shared_state = Arc::new(SharedStreamState::new(headers, buf_size, provider_handle)); + let config = app_state.app_config.config.load(); + let min_buffer_bytes = resolve_min_burst_buffer_bytes(&config); + let shared_state = Arc::new(SharedStreamState::new(headers, buf_size, provider_handle, min_buffer_bytes)); app_state.shared_stream_manager.register(addr, stream_url, Arc::clone(&shared_state)).await; app_state.active_provider.make_shared_connection(addr, stream_url).await; let subscribed_stream = Self::subscribe_shared_stream(app_state, stream_url, addr).await; diff --git a/backend/src/api/serve.rs b/backend/src/api/serve.rs index 35768ee52..479366f49 100644 --- a/backend/src/api/serve.rs +++ b/backend/src/api/serve.rs @@ -136,6 +136,7 @@ where let mut addr_close_rx = connection_manager_clone.get_close_connection_channel(); trace!("Connection opened: {addr}"); + connection_manager.add_connection(&addr).await; loop { tokio::select! { diff --git a/backend/src/main.rs b/backend/src/main.rs index efdf7c59c..13a79a69f 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -106,15 +106,15 @@ fn main() { if let Some(bts) = BUILD_TIMESTAMP.to_string().parse::>().ok().map(|datetime| datetime.format("%Y-%m-%d %H:%M:%S %Z").to_string()) { info!("Build time: {bts}"); } - - let app_config = utils::read_initial_app_config(&mut config_paths, true, true, args.server).unwrap_or_else(|err| exit!("{}", err)); - print_info(&app_config); - - let sources = > as Access>::load(&app_config.sources); - let targets = sources.validate_targets(args.target.as_ref()).unwrap_or_else(|err| exit!("{}", err)); - let rt = tokio::runtime::Runtime::new().unwrap(); let () = rt.block_on(async { + + let app_config = utils::read_initial_app_config(&mut config_paths, true, true, args.server).await.unwrap_or_else(|err| exit!("{}", err)); + print_info(&app_config); + + let sources = > as Access>::load(&app_config.sources); + let targets = sources.validate_targets(args.target.as_ref()).unwrap_or_else(|err| exit!("{}", err)); + if args.server { start_in_server_mode(Arc::new(app_config), Arc::new(targets)).await; } else { diff --git a/backend/src/model/config/api_proxy.rs b/backend/src/model/config/api_proxy.rs index 15fd97ce7..108d3e43a 100644 --- a/backend/src/model/config/api_proxy.rs +++ b/backend/src/model/config/api_proxy.rs @@ -100,14 +100,14 @@ impl ApiProxyConfig { // we have the option to store user in the config file or in the user_db // When we switch from one to other we need to migrate the existing data. /// # Panics - pub fn migrate_api_user(&mut self, cfg: &AppConfig, errors: &mut Vec) { + pub async fn migrate_api_user(&mut self, cfg: &AppConfig, errors: &mut Vec) { let paths = > as Access>::load(&cfg.paths); let api_proxy_file = paths.api_proxy_file_path.as_str(); if self.use_user_db { // we have user defined in config file. // we migrate them to the db and delete them from the config file if !&self.user.is_empty() { - if let Err(err) = merge_api_user(cfg, &self.user) { + if let Err(err) = merge_api_user(cfg, &self.user).await { errors.push(err.to_string()); } else { let config = > as Access>::load(&cfg.config); @@ -118,7 +118,7 @@ impl ApiProxyConfig { } } } - match load_api_user(cfg) { + match load_api_user(cfg).await { Ok(users) => { self.user = users; } @@ -132,7 +132,7 @@ impl ApiProxyConfig { if user_db_path.exists() { // we cant have user defined in db file. // we need to load them and save them into the config file - if let Ok(stored_users) = load_api_user(cfg) { + if let Ok(stored_users) = load_api_user(cfg).await { for stored_user in stored_users { if let Some(target_user) = self.user.iter_mut().find(|t| t.target == stored_user.target) { for stored_credential in &stored_user.credentials { @@ -151,7 +151,7 @@ impl ApiProxyConfig { if let Err(err) = save_api_proxy(api_proxy_file, backup_dir.as_ref(), &ApiProxyConfigDto::from(&*self)) { errors.push(format!("Error saving api proxy file: {err}")); } else { - backup_api_user_db_file(cfg, &user_db_path); + backup_api_user_db_file(cfg, &user_db_path).await; let _ = fs::remove_file(&user_db_path); } } diff --git a/backend/src/model/config/input.rs b/backend/src/model/config/input.rs index 5bf36e346..3f7dc98df 100644 --- a/backend/src/model/config/input.rs +++ b/backend/src/model/config/input.rs @@ -68,6 +68,7 @@ impl InputUserInfo { #[derive(Debug, Clone, Default)] pub struct StagedInput { + pub name: String, pub url: String, pub username: Option, pub password: Option, @@ -80,6 +81,7 @@ macros::from_impl!(StagedInput); impl From<&StagedInputDto> for StagedInput { fn from(dto: &StagedInputDto) -> Self { Self { + name: dto.name.clone(), input_type: dto.input_type, url: dto.url.clone(), username: dto.username.clone(), diff --git a/backend/src/model/config/reverse_proxy.rs b/backend/src/model/config/reverse_proxy.rs index d1e52563c..49df2ee89 100644 --- a/backend/src/model/config/reverse_proxy.rs +++ b/backend/src/model/config/reverse_proxy.rs @@ -1,6 +1,8 @@ -use shared::model::{ReverseProxyConfigDto, ReverseProxyDisabledHeaderConfigDto}; use crate::model::config::cache::CacheConfig; use crate::model::{macros, GeoIpConfig, RateLimitConfig, StreamConfig}; +use shared::model::{ResourceRetryConfigDto, ReverseProxyConfigDto, ReverseProxyDisabledHeaderConfigDto}; +use shared::utils::{default_resource_retry_attempts, default_resource_retry_backoff_ms, default_resource_retry_backoff_multiplier}; +use std::cmp::max; #[derive(Debug, Clone)] pub struct ReverseProxyDisabledHeaderConfig { @@ -24,9 +26,76 @@ impl ReverseProxyDisabledHeaderConfig { } } +#[derive(Debug, Clone)] +pub struct ResourceRetryConfig { + pub max_attempts: u32, + pub backoff_millis: u64, + pub backoff_multiplier: f64, +} + +impl Default for ResourceRetryConfig { + fn default() -> Self { + Self { + max_attempts: default_resource_retry_attempts(), + backoff_millis: default_resource_retry_backoff_ms(), + backoff_multiplier: default_resource_retry_backoff_multiplier(), + } + } +} + +impl ResourceRetryConfig { + pub fn get_retry_values(&self) -> (u32, u64, f64) { + ( + max(1, self.max_attempts), + self.backoff_millis.max(1), + if self.backoff_multiplier.is_finite() { + self.backoff_multiplier.max(1.0) + } else { + 1.0 + }, + ) + } + + pub fn get_default_retry_values() -> (u32, u64, f64) { + ( + default_resource_retry_attempts(), + default_resource_retry_backoff_ms(), + default_resource_retry_backoff_multiplier(), + ) + } +} + +macros::from_impl!(ResourceRetryConfig); + +impl From<&ResourceRetryConfigDto> for ResourceRetryConfig { + fn from(dto: &ResourceRetryConfigDto) -> Self { + let multiplier = if dto.backoff_multiplier.is_finite() { + dto.backoff_multiplier.max(1.0) + } else { + 1.0 + }; + Self { + max_attempts: dto.max_attempts, + backoff_millis: dto.backoff_millis, + backoff_multiplier: multiplier, + } + } +} + +impl From<&ResourceRetryConfig> for ResourceRetryConfigDto { + fn from(cfg: &ResourceRetryConfig) -> Self { + Self { + max_attempts: cfg.max_attempts, + backoff_millis: cfg.backoff_millis, + backoff_multiplier: cfg.backoff_multiplier, + } + } +} + #[derive(Debug, Clone)] pub struct ReverseProxyConfig { pub resource_rewrite_disabled: bool, + pub resource_retry: ResourceRetryConfig, pub disabled_header: Option, pub stream: Option, pub cache: Option, @@ -40,6 +109,10 @@ impl From<&ReverseProxyConfigDto> for ReverseProxyConfig { fn from(dto: &ReverseProxyConfigDto) -> Self { Self { resource_rewrite_disabled: dto.resource_rewrite_disabled, + resource_retry: dto + .resource_retry + .as_ref() + .map_or_else(ResourceRetryConfig::default, Into::into), disabled_header: dto.disabled_header.as_ref().map(|d| ReverseProxyDisabledHeaderConfig { referer_header: d.referer_header, x_header: d.x_header, @@ -57,6 +130,7 @@ impl From<&ReverseProxyConfig> for ReverseProxyConfigDto { fn from(instance: &ReverseProxyConfig) -> Self { Self { resource_rewrite_disabled: instance.resource_rewrite_disabled, + resource_retry: Some(ResourceRetryConfigDto::from(&instance.resource_retry)), disabled_header: instance.disabled_header.as_ref().map(|d| ReverseProxyDisabledHeaderConfigDto { referer_header: d.referer_header, x_header: d.x_header, diff --git a/backend/src/model/config/stream.rs b/backend/src/model/config/stream.rs index 57f2ad0a2..25848c223 100644 --- a/backend/src/model/config/stream.rs +++ b/backend/src/model/config/stream.rs @@ -37,6 +37,7 @@ pub struct StreamConfig { pub forced_retry_interval_secs: u32, pub throttle_str: Option, pub throttle_kbps: u64, + pub shared_burst_buffer_mb: u64, } macros::from_impl!(StreamConfig); @@ -50,6 +51,7 @@ impl From<&StreamConfigDto> for StreamConfig { forced_retry_interval_secs: dto.forced_retry_interval_secs, throttle_str: dto.throttle.clone(), throttle_kbps: dto.throttle.as_ref().map_or(0u64, |throttle| parse_to_kbps(throttle).unwrap_or(0u64)), + shared_burst_buffer_mb: dto.shared_burst_buffer_mb, } } } @@ -64,6 +66,7 @@ impl From<&StreamConfig> for StreamConfigDto { forced_retry_interval_secs: instance.forced_retry_interval_secs, throttle: instance.throttle_str.clone(), throttle_kbps: instance.throttle_kbps, + shared_burst_buffer_mb: instance.shared_burst_buffer_mb, } } } @@ -74,4 +77,4 @@ pub struct CustomStreamResponse { pub user_connections_exhausted: Option, // user has no more connections pub provider_connections_exhausted: Option, // provider limit reached, has no more connections pub user_account_expired: Option, -} \ No newline at end of file +} diff --git a/backend/src/model/input_source.rs b/backend/src/model/input_source.rs index 5746ed78a..8fe3c0787 100644 --- a/backend/src/model/input_source.rs +++ b/backend/src/model/input_source.rs @@ -4,6 +4,7 @@ use crate::model::{ConfigInput, StagedInput}; #[derive(Clone, Debug)] pub struct InputSource { + pub name: String, pub url: String, pub username: Option, pub password: Option, @@ -14,6 +15,7 @@ pub struct InputSource { impl InputSource { pub fn with_url(&self, url: String) -> Self { Self { + name: self.name.clone(), url, username: self.username.clone(), password: self.password.clone(), @@ -28,6 +30,7 @@ macro_rules! impl_input_source_from { impl From<&$input_type> for InputSource { fn from(input: &$input_type) -> Self { Self { + name: input.name.clone(), url: input.url.clone(), username: input.username.clone(), password: input.password.clone(), diff --git a/backend/src/processing/parser/xtream.rs b/backend/src/processing/parser/xtream.rs index b3c6506b8..9a63497f0 100644 --- a/backend/src/processing/parser/xtream.rs +++ b/backend/src/processing/parser/xtream.rs @@ -125,7 +125,7 @@ pub fn parse_xtream(input: &ConfigInput, streams: &Value) -> Result>, TuliproxError> { match map_to_xtream_category(categories) { Ok(xtream_categories) => { - let input_name = Arc::new(input.name.clone()); + let input_name = input.name.clone(); let url = input.url.as_str(); let username = input.username.as_ref().map_or("", |v| v); let password = input.password.as_ref().map_or("", |v| v); @@ -173,7 +173,7 @@ pub fn parse_xtream(input: &ConfigInput, xtream_cluster, additional_properties: stream.get_additional_properties(), category_id: 0, - input_name: input_name.to_string(), + input_name: input_name.clone(), ..Default::default() }, }; diff --git a/backend/src/processing/processor/xtream.rs b/backend/src/processing/processor/xtream.rs index 4cdeb8ed6..f7591d133 100644 --- a/backend/src/processing/processor/xtream.rs +++ b/backend/src/processing/processor/xtream.rs @@ -108,7 +108,7 @@ where }; { - let file_lock = cfg.file_locks.read_lock(&file_path); + let file_lock = cfg.file_locks.read_lock(&file_path).await; if let Ok(info_records) = BPlusTree::::load(&file_path) { info_records.iter().for_each(|(provider_id, record)| { processed_info_ids.insert(*provider_id, extract_ts(record)); diff --git a/backend/src/processing/processor/xtream_series.rs b/backend/src/processing/processor/xtream_series.rs index 752d2ec55..2fe3cbcfe 100644 --- a/backend/src/processing/processor/xtream_series.rs +++ b/backend/src/processing/processor/xtream_series.rs @@ -142,7 +142,7 @@ async fn process_series_info( return result; }; - let _file_lock = app_config.file_locks.read_lock(&info_path); + let _file_lock = app_config.file_locks.read_lock(&info_path).await; // Contains the Series Info with episode listing let Ok(mut info_reader) = IndexedDocumentReader::::new(&info_path, &idx_path) else { return result; }; diff --git a/backend/src/processing/processor/xtream_vod.rs b/backend/src/processing/processor/xtream_vod.rs index df779515b..3122f9081 100644 --- a/backend/src/processing/processor/xtream_vod.rs +++ b/backend/src/processing/processor/xtream_vod.rs @@ -148,7 +148,7 @@ pub async fn playlist_resolve_vod(app_config: &AppConfig, client: Arc>(&content).ok().and_then(|info_doc| { info_doc.get("info").cloned().map(|info_content| { diff --git a/backend/src/repository/m3u_repository.rs b/backend/src/repository/m3u_repository.rs index 12a384e38..f494f60d6 100644 --- a/backend/src/repository/m3u_repository.rs +++ b/backend/src/repository/m3u_repository.rs @@ -62,7 +62,7 @@ pub async fn m3u_write_playlist(cfg: &AppConfig, target: &ConfigTarget, target_o persist_m3u_playlist_as_text(&cfg.config.load(), target, target_output, &m3u_playlist); { - let _file_lock = cfg.file_locks.write_lock(&m3u_path); + let _file_lock = cfg.file_locks.write_lock(&m3u_path).await; match IndexedDocumentWriter::new(m3u_path.clone(), idx_path) { Ok(mut writer) => { for m3u in m3u_playlist { @@ -105,7 +105,7 @@ pub async fn m3u_get_item_for_stream_id(stream_id: u32, app_state: &AppState, ta let cfg: &AppConfig = &app_state.app_config; let target_path = get_target_storage_path(&cfg.config.load(), target.name.as_str()).ok_or_else(|| str_to_io_error(&format!("Could not find path for target {}", &target.name)))?; let (m3u_path, idx_path) = m3u_get_file_paths(&target_path); - let _file_lock = cfg.file_locks.read_lock(&m3u_path); + let _file_lock = cfg.file_locks.read_lock(&m3u_path).await; IndexedDocumentDirectAccess::read_indexed_item::(&m3u_path, &idx_path, &stream_id) } } diff --git a/backend/src/repository/playlist_repository.rs b/backend/src/repository/playlist_repository.rs index 8b4f06a59..eaeb154d3 100644 --- a/backend/src/repository/playlist_repository.rs +++ b/backend/src/repository/playlist_repository.rs @@ -98,12 +98,12 @@ pub async fn persist_playlist(app_config: &AppConfig, playlist: &mut [PlaylistGr for output in &target.output { match output { TargetOutput::Xtream(_) => { - if let Ok(storage) = load_xtream_target_storage(app_config, target) { + if let Ok(storage) = load_xtream_target_storage(app_config, target).await { playlist_storage.cache_playlist(&target.name, PlaylistStorage::XtreamPlaylist(Box::new(storage))).await; } }, TargetOutput::M3u(_) => { - if let Ok(storage) = load_m3u_target_storage(app_config, target) { + if let Ok(storage) = load_m3u_target_storage(app_config, target).await { playlist_storage.cache_playlist(&target.name, PlaylistStorage::M3uPlaylist(Box::new(storage))).await; } }, @@ -123,9 +123,9 @@ pub async fn get_target_id_mapping(cfg: &AppConfig, target_path: &Path) -> (Targ } -fn load_target_id_mapping_as_tree(app_config: &AppConfig, target_path: &Path, target: &ConfigTarget) -> Result, TuliproxError> { +async fn load_target_id_mapping_as_tree(app_config: &AppConfig, target_path: &Path, target: &ConfigTarget) -> Result, TuliproxError> { let target_id_mapping_file = get_target_id_mapping_file(target_path); - let _file_lock = app_config.file_locks.read_lock(&target_id_mapping_file); + let _file_lock = app_config.file_locks.read_lock(&target_id_mapping_file).await; BPlusTree::::load(&target_id_mapping_file).map_err(|err| create_tuliprox_error!( @@ -134,9 +134,9 @@ fn load_target_id_mapping_as_tree(app_config: &AppConfig, target_path: &Path, ta )) } -fn load_xtream_playlist_as_tree(app_config: &AppConfig, storage_path: &Path, cluster: XtreamCluster) -> BPlusTree { +async fn load_xtream_playlist_as_tree(app_config: &AppConfig, storage_path: &Path, cluster: XtreamCluster) -> BPlusTree { let (main_path, index_path) = xtream_get_file_paths(storage_path, cluster); - let _file_lock = app_config.file_locks.read_lock(&main_path); + let _file_lock = app_config.file_locks.read_lock(&main_path).await; let mut tree = BPlusTree::::new(); if let Ok(reader) = IndexedDocumentIterator::::new(&main_path, &index_path) { for (doc, _has_next) in reader { @@ -146,7 +146,7 @@ fn load_xtream_playlist_as_tree(app_config: &AppConfig, storage_path: &Path, clu tree } -fn load_xtream_target_storage(app_config: &AppConfig, target: &ConfigTarget) -> Result { +async fn load_xtream_target_storage(app_config: &AppConfig, target: &ConfigTarget) -> Result { let config = app_config.config.load(); let target_path = get_target_storage_path(&config, target.name.as_str()).ok_or_else(|| create_tuliprox_error!( @@ -159,10 +159,10 @@ fn load_xtream_target_storage(app_config: &AppConfig, target: &ConfigTarget) -> TuliproxErrorKind::Info, "Could not find path for target {} xtream output", &target.name))?; - let target_id_mapping = load_target_id_mapping_as_tree(app_config, &target_path, target)?; - let live_storage = load_xtream_playlist_as_tree(app_config, &storage_path, XtreamCluster::Live); - let vod_storage = load_xtream_playlist_as_tree(app_config, &storage_path, XtreamCluster::Video); - let series_storage = load_xtream_playlist_as_tree(app_config, &storage_path, XtreamCluster::Series); + let target_id_mapping = load_target_id_mapping_as_tree(app_config, &target_path, target).await?; + let live_storage = load_xtream_playlist_as_tree(app_config, &storage_path, XtreamCluster::Live).await; + let vod_storage = load_xtream_playlist_as_tree(app_config, &storage_path, XtreamCluster::Video).await; + let series_storage = load_xtream_playlist_as_tree(app_config, &storage_path, XtreamCluster::Series).await; Ok(PlaylistXtreamStorage { id_mapping: target_id_mapping, @@ -172,7 +172,7 @@ fn load_xtream_target_storage(app_config: &AppConfig, target: &ConfigTarget) -> }) } -fn load_m3u_target_storage(app_config: &AppConfig, target: &ConfigTarget) -> Result { +async fn load_m3u_target_storage(app_config: &AppConfig, target: &ConfigTarget) -> Result { let config = app_config.config.load(); let target_path = get_target_storage_path(&config, target.name.as_str()).ok_or_else(|| create_tuliprox_error!( @@ -181,7 +181,7 @@ fn load_m3u_target_storage(app_config: &AppConfig, target: &ConfigTarget) -> Res ))?; let (main_path, index_path) = m3u_get_file_paths(&target_path); - let _file_lock = app_config.file_locks.read_lock(&main_path); + let _file_lock = app_config.file_locks.read_lock(&main_path).await; let mut tree = BPlusTree::::new(); if let Ok(reader) = IndexedDocumentIterator::::new(&main_path, &index_path) { for (doc, _has_next) in reader { @@ -207,12 +207,12 @@ pub async fn load_target_into_memory_cache(app_state: &AppState, target: &Arc { - if let Ok(storage) = load_xtream_target_storage(&app_state.app_config, target) { + if let Ok(storage) = load_xtream_target_storage(&app_state.app_config, target).await { app_state.cache_playlist(&target.name, PlaylistStorage::XtreamPlaylist(Box::new(storage))).await; } } TargetOutput::M3u(_) => { - if let Ok(storage) = load_m3u_target_storage(&app_state.app_config, target) { + if let Ok(storage) = load_m3u_target_storage(&app_state.app_config, target).await { app_state.cache_playlist(&target.name, PlaylistStorage::M3uPlaylist(Box::new(storage))).await; } } diff --git a/backend/src/repository/strm_repository.rs b/backend/src/repository/strm_repository.rs index 372c22dc0..0c57af254 100644 --- a/backend/src/repository/strm_repository.rs +++ b/backend/src/repository/strm_repository.rs @@ -7,8 +7,8 @@ use crate::repository::bplustree::BPlusTree; use crate::repository::storage::{ensure_target_storage_path, get_input_storage_path}; use crate::repository::storage_const; use crate::repository::xtream_repository::{xtream_get_record_file_path, InputVodInfoRecord}; -use shared::utils::{extract_extension_from_url, hash_bytes, truncate_string, ExportStyleConfig, CONSTANTS}; -use crate::utils::{truncate_filename, FileReadGuard}; +use shared::utils::{extract_extension_from_url, hash_bytes, hash_string_as_hex, truncate_string, ExportStyleConfig, CONSTANTS}; +use crate::utils::{normalize_string_path, truncate_filename, FileReadGuard}; use chrono::Datelike; use filetime::{set_file_times, FileTime}; use log::{error, trace}; @@ -242,8 +242,8 @@ async fn get_tmdb_value( } } -pub fn strm_get_file_paths(target_path: &Path) -> PathBuf { - target_path.join(PathBuf::from(format!("{}.{}", storage_const::FILE_STRM, storage_const::FILE_SUFFIX_DB))) +pub fn strm_get_file_paths(file_prefix: &str, target_path: &Path) -> PathBuf { + target_path.join(PathBuf::from(format!("{file_prefix}_{}.{}", storage_const::FILE_STRM, storage_const::FILE_SUFFIX_DB))) } #[derive(Serialize)] @@ -837,12 +837,14 @@ pub async fn write_strm_playlist( }; let user_and_server_info = get_credentials_and_server_info(app_config, target_output.username.as_ref()); + let normalized_dir = normalize_string_path(&target_output.directory); + let strm_file_prefix = hash_string_as_hex(&normalized_dir); let strm_index_path = - strm_get_file_paths(&ensure_target_storage_path(&config, target.name.as_str())?); + strm_get_file_paths(&strm_file_prefix, &ensure_target_storage_path(&config, target.name.as_str())?); let existing_strm = { let _file_lock = app_config .file_locks - .read_lock(&strm_index_path); + .read_lock(&strm_index_path).await; read_strm_file_index(&strm_index_path) .await .unwrap_or_else(|_| HashSet::with_capacity(4096)) @@ -926,7 +928,7 @@ async fn write_strm_index_file( ) -> Result<(), String> { let _file_lock = cfg .file_locks - .write_lock(index_file_path); + .write_lock(index_file_path).await; let file = File::create(index_file_path) .await .map_err(|err| format!("Failed to create strm index file: {} {err}", index_file_path.display()))?; diff --git a/backend/src/repository/user_repository.rs b/backend/src/repository/user_repository.rs index 04022c2d6..20f1a52a7 100644 --- a/backend/src/repository/user_repository.rs +++ b/backend/src/repository/user_repository.rs @@ -120,23 +120,23 @@ fn add_target_user_to_user_tree(target_users: &[TargetUser], user_tree: &mut BPl } } -pub fn merge_api_user(cfg: &AppConfig, target_users: &[TargetUser]) -> Result { +pub async fn merge_api_user(cfg: &AppConfig, target_users: &[TargetUser]) -> Result { let path = get_api_user_db_path(cfg); - let lock = cfg.file_locks.read_lock(&path); + let lock = cfg.file_locks.read_lock(&path).await; let mut user_tree: BPlusTree = BPlusTree::load(&path).unwrap_or_else(|_| BPlusTree::new()); drop(lock); add_target_user_to_user_tree(target_users, &mut user_tree); - let _lock = cfg.file_locks.write_lock(&path); + let _lock = cfg.file_locks.write_lock(&path).await; user_tree.store(&path) } /// # Panics /// /// Will panic if `backup_dir` is not given -pub fn backup_api_user_db_file(cfg: &AppConfig, path: &Path) { +pub async fn backup_api_user_db_file(cfg: &AppConfig, path: &Path) { if let Some(backup_dir) = cfg.config.load().backup_dir.as_ref() { let backup_path = PathBuf::from(backup_dir).join(format!("{}_{}", storage_const::API_USER_DB_FILE, Local::now().format("%Y%m%d_%H%M%S"))); - let _lock = cfg.file_locks.read_lock(path); + let _lock = cfg.file_locks.read_lock(path).await; match std::fs::copy(path, &backup_path) { Ok(_) => {} Err(err) => { error!("Could not backup file {}:{}", &backup_path.to_str().unwrap_or("?"), err) } @@ -144,19 +144,19 @@ pub fn backup_api_user_db_file(cfg: &AppConfig, path: &Path) { } } -pub fn store_api_user(cfg: &AppConfig, target_users: &[TargetUser]) -> Result { +pub async fn store_api_user(cfg: &AppConfig, target_users: &[TargetUser]) -> Result { let mut user_tree = BPlusTree::::new(); add_target_user_to_user_tree(target_users, &mut user_tree); let path = get_api_user_db_path(cfg); - backup_api_user_db_file(cfg, &path); - let _lock = cfg.file_locks.write_lock(&path); + backup_api_user_db_file(cfg, &path).await; + let _lock = cfg.file_locks.write_lock(&path).await; user_tree.store(&path) } // TODO remove me if we get stable on user_db -pub fn load_api_user_deprecated(cfg: &AppConfig) -> Result, Error> { +pub async fn load_api_user_deprecated(cfg: &AppConfig) -> Result, Error> { let path = get_api_user_db_path(cfg); - let lock = cfg.file_locks.read_lock(&path); + let lock = cfg.file_locks.read_lock(&path).await; let user_tree = BPlusTree::::load(&path)?; drop(lock); let mut target_users: HashMap = HashMap::new(); @@ -180,10 +180,10 @@ pub fn load_api_user_deprecated(cfg: &AppConfig) -> Result, Erro } -pub fn load_api_user(cfg: &AppConfig) -> Result, Error> { +pub async fn load_api_user(cfg: &AppConfig) -> Result, Error> { let path = get_api_user_db_path(cfg); - let lock = cfg.file_locks.read_lock(&path); - let Ok(user_tree) = BPlusTree::::load(&path) else { return load_api_user_deprecated(cfg) }; + let lock = cfg.file_locks.read_lock(&path).await; + let Ok(user_tree) = BPlusTree::::load(&path) else { return load_api_user_deprecated(cfg).await }; drop(lock); let mut target_users: HashMap = HashMap::new(); for (_uname, stored_user) in &user_tree { diff --git a/backend/src/repository/xtream_repository.rs b/backend/src/repository/xtream_repository.rs index 9fafbdd8c..ad741a087 100644 --- a/backend/src/repository/xtream_repository.rs +++ b/backend/src/repository/xtream_repository.rs @@ -101,7 +101,7 @@ pub fn xtream_get_record_file_path(storage_path: &Path, item_type: PlaylistItemT _ => None, } } -fn write_playlists_to_file( +async fn write_playlists_to_file( cfg: &AppConfig, storage_path: &Path, collections: Vec<(XtreamCluster, &[&mut PlaylistItem])>, @@ -109,7 +109,7 @@ fn write_playlists_to_file( for (cluster, playlist) in collections { let (xtream_path, idx_path) = xtream_get_file_paths(storage_path, cluster); { - let _file_lock = cfg.file_locks.write_lock(&xtream_path); + let _file_lock = cfg.file_locks.write_lock(&xtream_path).await; match IndexedDocumentWriter::new(xtream_path.clone(), idx_path) { Ok(mut writer) => { for item in playlist { @@ -183,7 +183,7 @@ pub fn xtream_get_file_paths(storage_path: &Path, cluster: XtreamCluster) -> (Pa // xtream_get_file_paths_for_name(storage_path, storage_const::FILE_SERIES) // } -fn xtream_garbage_collect(config: &AppConfig, target_name: &str) -> std::io::Result<()> { +async fn xtream_garbage_collect(config: &AppConfig, target_name: &str) -> std::io::Result<()> { // Garbage collect series let storage_path = { let cfg = config.config.load(); @@ -194,7 +194,7 @@ fn xtream_garbage_collect(config: &AppConfig, target_name: &str) -> std::io::Res XtreamCluster::Series )); { - let _file_lock = config.file_locks.write_lock(&info_path); + let _file_lock = config.file_locks.write_lock(&info_path).await; IndexedDocumentGarbageCollector::::new(info_path.clone(), idx_path)?.garbage_collect()?; } Ok(()) @@ -273,9 +273,9 @@ pub async fn xtream_write_playlist( (XtreamCluster::Video, &vod_col), (XtreamCluster::Series, &series_col), ], - ) { + ).await { Ok(()) => { - if let Err(err) = xtream_garbage_collect(cfg, &target.name) { + if let Err(err) = xtream_garbage_collect(cfg, &target.name).await { if err.kind() != ErrorKind::NotFound { errors.push(format!("Garbage collection failed:{err}")); } @@ -311,7 +311,7 @@ pub fn xtream_get_collection_path( Err(str_to_io_error(&format!("Cant find collection: {target_name}/{collection_name}"))) } -fn xtream_read_item_for_stream_id( +async fn xtream_read_item_for_stream_id( cfg: &AppConfig, stream_id: u32, storage_path: &Path, @@ -319,19 +319,19 @@ fn xtream_read_item_for_stream_id( ) -> Result { let (xtream_path, idx_path) = xtream_get_file_paths(storage_path, cluster); { - let _file_lock = cfg.file_locks.read_lock(&xtream_path); + let _file_lock = cfg.file_locks.read_lock(&xtream_path).await; IndexedDocumentDirectAccess::read_indexed_item::(&xtream_path, &idx_path, &stream_id) } } -fn xtream_read_series_item_for_stream_id( +async fn xtream_read_series_item_for_stream_id( cfg: &AppConfig, stream_id: u32, storage_path: &Path, ) -> Result { let (xtream_path, idx_path) = xtream_get_file_paths(storage_path, XtreamCluster::Series); { - let _file_lock = cfg.file_locks.read_lock(&xtream_path); + let _file_lock = cfg.file_locks.read_lock(&xtream_path).await; IndexedDocumentDirectAccess::read_indexed_item::(&xtream_path, &idx_path, &stream_id) } } @@ -431,33 +431,33 @@ pub async fn xtream_get_item_for_stream_id( let storage_path = xtream_get_storage_path(&config, target.name.as_str()).ok_or_else(|| str_to_io_error(&format!("Could not find path for target {} xtream output", &target.name)))?; { let target_id_mapping_file = get_target_id_mapping_file(&target_path); - let _file_lock = app_config.file_locks.read_lock(&target_id_mapping_file); + let _file_lock = app_config.file_locks.read_lock(&target_id_mapping_file).await; let mut target_id_mapping = BPlusTreeQuery::::try_new(&target_id_mapping_file).map_err(|err| str_to_io_error(&format!("Could not load id mapping for target {} err:{err}", target.name)))?; let mapping = target_id_mapping.query(&virtual_id).ok_or_else(|| str_to_io_error(&format!("Could not find mapping for target {} and id {}", target.name, virtual_id)))?; let result = match mapping.item_type { PlaylistItemType::SeriesInfo => { - xtream_read_series_item_for_stream_id(app_config, virtual_id, &storage_path) + xtream_read_series_item_for_stream_id(app_config, virtual_id, &storage_path).await } PlaylistItemType::Series => { - if let Ok(mut item) = xtream_read_series_item_for_stream_id(app_config, mapping.parent_virtual_id, &storage_path) { + if let Ok(mut item) = xtream_read_series_item_for_stream_id(app_config, mapping.parent_virtual_id, &storage_path).await { item.provider_id = mapping.provider_id; item.item_type = PlaylistItemType::Series; Ok(item) } else { - xtream_read_item_for_stream_id(app_config, virtual_id, &storage_path, XtreamCluster::Series) + xtream_read_item_for_stream_id(app_config, virtual_id, &storage_path, XtreamCluster::Series).await } } PlaylistItemType::Catchup => { let cluster = try_cluster!(xtream_cluster, mapping.item_type, virtual_id)?; - let mut item = xtream_read_item_for_stream_id(app_config, mapping.parent_virtual_id, &storage_path, cluster)?; + let mut item = xtream_read_item_for_stream_id(app_config, mapping.parent_virtual_id, &storage_path, cluster).await?; item.provider_id = mapping.provider_id; item.item_type = PlaylistItemType::Catchup; Ok(item) } _ => { let cluster = try_cluster!(xtream_cluster, mapping.item_type, virtual_id)?; - xtream_read_item_for_stream_id(app_config, virtual_id, &storage_path, cluster) + xtream_read_item_for_stream_id(app_config, virtual_id, &storage_path, cluster).await } }; @@ -475,7 +475,7 @@ pub async fn xtream_load_rewrite_playlist( XtreamPlaylistJsonIterator::new(cluster, config, target, category_id, user).await } -pub fn xtream_write_series_info( +pub async fn xtream_write_series_info( app_config: &AppConfig, target_name: &str, series_info_id: u32, @@ -490,14 +490,14 @@ pub fn xtream_write_series_info( )); { - let _file_lock = app_config.file_locks.write_lock(&info_path); + let _file_lock = app_config.file_locks.write_lock(&info_path).await; let mut writer = IndexedDocumentWriter::new_append(info_path.clone(), idx_path)?; writer.write_doc(series_info_id, content).map_err(|_| str_to_io_error(&format!("failed to write xtream series info for target {target_name}")))?; writer.store()?; } { let target_id_mapping_file = get_target_id_mapping_file(&target_path); - let _file_lock = app_config.file_locks.write_lock(&target_id_mapping_file); + let _file_lock = app_config.file_locks.write_lock(&target_id_mapping_file).await; if let Ok(mut target_id_mapping) = BPlusTreeUpdate::::try_new(&target_id_mapping_file) { if let Some(record) = target_id_mapping.query(&series_info_id) { let new_record = record.copy_update_timestamp(); @@ -527,20 +527,20 @@ pub async fn xtream_write_vod_info( Ok(()) } -fn xtream_get_series_info_mapping( +async fn xtream_get_series_info_mapping( config: &AppConfig, target_name: &str, series_id: u32, ) -> Option { - xtream_get_info_mapping(config, target_name, series_id).filter(|id_record| !id_record.is_expired()) + xtream_get_info_mapping(config, target_name, series_id).await.filter(|id_record| !id_record.is_expired()) } -fn xtream_get_info_mapping(app_config: &AppConfig, target_name: &str, info_id: u32) -> Option { +async fn xtream_get_info_mapping(app_config: &AppConfig, target_name: &str, info_id: u32) -> Option { let config = app_config.config.load(); let target_path = get_target_storage_path(&config, target_name)?; let target_id_mapping_file = get_target_id_mapping_file(&target_path); - let _file_lock = app_config.file_locks.read_lock(&target_id_mapping_file); + let _file_lock = app_config.file_locks.read_lock(&target_id_mapping_file).await; BPlusTreeQuery::::try_new(&target_id_mapping_file).map_err(|err| { error!("Could not load id mapping for target {target_name}: {err}"); str_to_io_error(&format!("ID mapping load error for target {target_name}")) @@ -548,12 +548,12 @@ fn xtream_get_info_mapping(app_config: &AppConfig, target_name: &str, info_id: u } // Reads the series info entry if exists -pub fn xtream_load_series_info( +pub async fn xtream_load_series_info( app_config: &AppConfig, target_name: &str, series_id: u32, ) -> Option { - xtream_get_series_info_mapping(app_config, target_name, series_id)?; + xtream_get_series_info_mapping(app_config, target_name, series_id).await?; let config = app_config.config.load(); let storage_path = xtream_get_storage_path(&config, target_name)?; @@ -561,7 +561,7 @@ pub fn xtream_load_series_info( if info_path.exists() && idx_path.exists() { { - let _file_lock = app_config.file_locks.read_lock(&info_path); + let _file_lock = app_config.file_locks.read_lock(&info_path).await; return match IndexedDocumentDirectAccess::read_indexed_item::(&info_path, &idx_path, &series_id) { Ok(content) => Some(content), Err(err) => { @@ -573,24 +573,24 @@ pub fn xtream_load_series_info( } None } -fn xtream_get_vod_info_mapping( +async fn xtream_get_vod_info_mapping( config: &AppConfig, target_name: &str, vod_id: u32, ) -> Option { - xtream_get_info_mapping(config, target_name, vod_id) + xtream_get_info_mapping(config, target_name, vod_id).await //.filter(|id_record| !id_record.is_expired()) } // Reads the vod info entry if exists -pub fn xtream_load_vod_info( +pub async fn xtream_load_vod_info( config: &AppConfig, target_name: &str, vod_id: u32, ) -> Option { // Check if the entry exists; if not, we don't need to look further. - xtream_get_vod_info_mapping(config, target_name, vod_id).as_ref()?; + xtream_get_vod_info_mapping(config, target_name, vod_id).await.as_ref()?; // Entry exists, read db entry let target_storage_path = xtream_get_storage_path(&config.config.load(), target_name)?; @@ -598,7 +598,7 @@ pub fn xtream_load_vod_info( if info_path.exists() && idx_path.exists() { { - let _file_lock = config.file_locks.read_lock(&info_path); + let _file_lock = config.file_locks.read_lock(&info_path).await; return IndexedDocumentDirectAccess::read_indexed_item::(&info_path, &idx_path, &vod_id).ok(); } } @@ -809,11 +809,11 @@ pub async fn write_and_get_xtream_series_info

( let mut doc = serde_json::from_str::>(content).map_err(|_| str_to_io_error("Failed to parse JSON content"))?; let virtual_id = pli_series_info.get_virtual_id(); let app_config = &app_state.app_config; - xtream_write_series_info(app_config, target.name.as_str(), virtual_id, content).ok(); + xtream_write_series_info(app_config, target.name.as_str(), virtual_id, content).await.ok(); rewrite_xtream_series_info(app_state, target, xtream_output, pli_series_info, user, &mut doc).await } -pub fn xtream_get_input_info( +pub async fn xtream_get_input_info( cfg: &AppConfig, input: &ConfigInput, provider_id: u32, @@ -821,7 +821,7 @@ pub fn xtream_get_input_info( ) -> Option { if let Ok(Some((info_path, idx_path))) = get_input_storage_path(&input.name, &cfg.config.load().working_dir).map(|storage_path| xtream_get_info_file_paths(&storage_path, cluster)) { - let _file_lock = cfg.file_locks.read_lock(&info_path); + let _file_lock = cfg.file_locks.read_lock(&info_path).await; if let Ok(content) = IndexedDocumentDirectAccess::read_indexed_item::(&info_path, &idx_path, &provider_id) { return Some(content); } @@ -839,7 +839,7 @@ pub async fn xtream_update_input_info_file( match get_input_storage_path(&input.name, &config.working_dir).map(|storage_path| xtream_get_info_file_paths(&storage_path, cluster)) { Ok(Some((info_path, idx_path))) => { { - let _file_lock = cfg.file_locks.write_lock(&info_path); + let _file_lock = cfg.file_locks.write_lock(&info_path).await; let mut reader = file_reader(open_readonly_file(wal_path).map_err(|err| notify_err!(format!("Could not read {cluster} info {err}")))?); match IndexedDocumentWriter::::new_append(info_path.clone(), idx_path) { Ok(mut writer) => { @@ -885,7 +885,7 @@ pub async fn xtream_update_input_vod_record_from_wal_file( .and_then(|opt| opt.ok_or_else(|| notify_err!(format!("Error accessing storage path for input: {}", &input.name))))?; { - let _file_lock = cfg.file_locks.write_lock(&record_path); + let _file_lock = cfg.file_locks.write_lock(&record_path).await; let mut reader = file_reader(open_readonly_file(wal_path).map_err(|err| notify_err!(format!("Could not read vod wal info {err}")))?); let mut provider_id_bytes = [0u8; 4]; let mut tmdb_id_bytes = [0u8; 4]; @@ -953,7 +953,7 @@ pub async fn xtream_update_input_series_record_from_wal_file( .map_err(|err| notify_err!(format!("Error accessing storage path: {err}"))) .and_then(|opt| opt.ok_or_else(|| notify_err!(format!("Error accessing storage path for input: {}", &input.name))))?; { - let _file_lock = cfg.file_locks.write_lock(&record_path); + let _file_lock = cfg.file_locks.write_lock(&record_path).await; let mut reader = file_reader(open_readonly_file(wal_path).map_err(|err| notify_err!(format!("Could not read series wal info {err}")))?); let mut provider_id_bytes = [0u8; 4]; let mut ts_bytes = [0u8; 8]; @@ -988,7 +988,7 @@ pub async fn xtream_update_input_series_episodes_record_from_wal_file( .map_err(|err| notify_err!(format!("Error accessing storage path: {err}"))) .and_then(|opt| opt.ok_or_else(|| notify_err!(format!("Error accessing storage path for input: {}", &input.name))))?; { - let _file_lock = cfg.file_locks.write_lock(&record_path); + let _file_lock = cfg.file_locks.write_lock(&record_path).await; let mut reader = file_reader(open_readonly_file(wal_path).map_err(|err| notify_err!(format!("Could not read series episode wal info {err}")))?); let mut provider_id_bytes = [0u8; 4]; let mut len_bytes = [0u8; 4]; diff --git a/backend/src/utils/file/config_reader.rs b/backend/src/utils/file/config_reader.rs index d78595eaa..4d39e30e2 100644 --- a/backend/src/utils/file/config_reader.rs +++ b/backend/src/utils/file/config_reader.rs @@ -43,13 +43,13 @@ pub fn config_file_reader(file: File, resolve_env: bool) -> impl Read } } -pub fn read_api_proxy_config(config: &AppConfig, resolve_env: bool) -> Result, TuliproxError> { +pub async fn read_api_proxy_config(config: &AppConfig, resolve_env: bool) -> Result, TuliproxError> { let paths = > as Access>::load(&config.paths); let api_proxy_file_path = paths.api_proxy_file_path.as_str(); if let Some(api_proxy_dto) = read_api_proxy_file(api_proxy_file_path, resolve_env)? { let mut errors = vec![]; let mut api_proxy: ApiProxyConfig = ApiProxyConfig::from(&api_proxy_dto); - api_proxy.migrate_api_user(config, &mut errors); + api_proxy.migrate_api_user(config, &mut errors).await; if !errors.is_empty() { for error in errors { error!("{error}"); @@ -181,7 +181,7 @@ pub fn get_batch_aliases(input_type: InputType, url: &str) -> Result Result<(), TuliproxError> { +pub async fn prepare_users(app_config_dto: &mut AppConfigDto, app_config: &AppConfig) -> Result<(), TuliproxError> { let use_user_db = app_config_dto .api_proxy .as_ref() @@ -190,7 +190,7 @@ pub fn prepare_users(app_config_dto: &mut AppConfigDto, app_config: &AppConfig) if use_user_db { let user_db_path = get_api_user_db_path(app_config); if user_db_path.exists() { - match load_api_user(app_config) { + match load_api_user(app_config).await { Ok(stored_users) => if let Some(api_proxy) = app_config_dto.api_proxy.as_mut() { api_proxy.user.extend(stored_users.iter().map(TargetUserDto::from)); }, @@ -203,7 +203,7 @@ pub fn prepare_users(app_config_dto: &mut AppConfigDto, app_config: &AppConfig) Ok(()) } -pub fn read_initial_app_config(paths: &mut ConfigPaths, +pub async fn read_initial_app_config(paths: &mut ConfigPaths, resolve_env: bool, include_computed: bool, server_mode: bool) -> Result { @@ -250,7 +250,7 @@ pub fn read_initial_app_config(paths: &mut ConfigPaths, } if server_mode { - match read_api_proxy_config(&app_config, resolve_env) { + match read_api_proxy_config(&app_config, resolve_env).await { Ok(Some(api_proxy)) => app_config.set_api_proxy(api_proxy)?, Ok(None) => info!("Api-Proxy file: not used"), Err(err) => exit!("{err}"), @@ -279,13 +279,13 @@ pub fn read_api_proxy_file(api_proxy_file: &str, resolve_env: bool) -> Result Option { +pub async fn read_api_proxy(config: &AppConfig, resolve_env: bool) -> Option { let paths = > as Access>::load(&config.paths); match read_api_proxy_file(paths.api_proxy_file_path.as_str(), resolve_env) { Ok(Some(api_proxy_dto)) => { let mut errors = vec![]; let mut api_proxy: ApiProxyConfig = api_proxy_dto.into(); - api_proxy.migrate_api_user(config, &mut errors); + api_proxy.migrate_api_user(config, &mut errors).await; if !errors.is_empty() { for error in errors { error!("{error}"); diff --git a/backend/src/utils/file/file_lock_manager.rs b/backend/src/utils/file/file_lock_manager.rs index a692488fa..f604641f5 100644 --- a/backend/src/utils/file/file_lock_manager.rs +++ b/backend/src/utils/file/file_lock_manager.rs @@ -1,8 +1,9 @@ use std::collections::HashMap; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::{fmt, io}; -use std::path::{Path, PathBuf}; -use tokio::sync::{Mutex, RwLock, RwLockReadGuard, RwLockWriteGuard}; +use tokio::sync::{Mutex, RwLock}; +use tokio::sync::{OwnedRwLockReadGuard, OwnedRwLockWriteGuard}; use shared::error::str_to_io_error; #[derive(Clone)] @@ -20,26 +21,22 @@ impl FileLockManager { // Acquires a read lock for the specified file and returns a FileReadGuard. pub async fn read_lock(&self, path: &Path) -> FileReadGuard { let file_lock = self.get_or_create_lock(path).await; - let guard = file_lock.read().await; - // Clone the Arc to avoid moving `file_lock` out, as it is still borrowed by `guard` - FileReadGuard::new(Arc::clone(&file_lock), guard) + let guard = Arc::clone(&file_lock).read_owned().await; + FileReadGuard::new(guard) } // Acquires a write lock for the specified file and returns a FileWriteGuard. pub async fn write_lock(&self, path: &Path) -> FileWriteGuard { let file_lock = self.get_or_create_lock(path).await; - let guard = file_lock.write().await; - // Clone the Arc to avoid moving `file_lock` out, as it is still borrowed by `guard` - FileWriteGuard::new(Arc::clone(&file_lock), guard) + let guard = Arc::clone(&file_lock).write_owned().await; + FileWriteGuard::new(guard) } // Tries to acquire a write lock for the specified file and returns a FileWriteGuard. pub async fn try_write_lock(&self, path: &Path) -> io::Result { let file_lock = self.get_or_create_lock(path).await; - let guard = file_lock.try_write(); - match guard { - // Clone the Arc to avoid moving `file_lock` out, as it is still borrowed by `guard` - Ok(lock_guard) => Ok(FileWriteGuard::new(Arc::clone(&file_lock), lock_guard)), + match Arc::clone(&file_lock).try_write_owned() { + Ok(lock_guard) => Ok(FileWriteGuard::new(lock_guard)), Err(_) => Err(str_to_io_error("Failed to acquire write lock")) } } @@ -75,39 +72,25 @@ impl fmt::Debug for FileLockManager { } // Define FileReadGuard to hold both the lock reference and the actual read guard. -#[derive(Clone)] #[allow(dead_code)] pub struct FileReadGuard { - lock: Arc>, - guard: Arc>, + _guard: OwnedRwLockReadGuard<()>, } impl FileReadGuard { - pub fn new(lock: Arc>, guard: RwLockReadGuard<'_, ()>) -> Self { - // Convert the lifetime of `guard` to 'static by transmuting. - let static_guard: RwLockReadGuard<'static, ()> = unsafe { std::mem::transmute(guard) }; - Self { - lock, - guard: Arc::new(static_guard), - } + fn new(guard: OwnedRwLockReadGuard<()>) -> Self { + Self { _guard: guard } } } // Define FileWriteGuard to hold both the lock reference and the actual write guard. -#[derive(Clone)] #[allow(dead_code)] pub struct FileWriteGuard { - lock: Arc>, - guard: Arc>, + _guard: OwnedRwLockWriteGuard<()>, } impl FileWriteGuard { - pub fn new(lock: Arc>, guard: RwLockWriteGuard<'_, ()>) -> Self { - // Convert the lifetime of `guard` to 'static by transmuting. - let static_guard: RwLockWriteGuard<'static, ()> = unsafe { std::mem::transmute(guard) }; - Self { - lock, - guard: Arc::new(static_guard), - } + fn new(guard: OwnedRwLockWriteGuard<()>) -> Self { + Self { _guard: guard } } } diff --git a/backend/src/utils/file/file_utils.rs b/backend/src/utils/file/file_utils.rs index aa43b8188..fd1051215 100644 --- a/backend/src/utils/file/file_utils.rs +++ b/backend/src/utils/file/file_utils.rs @@ -361,4 +361,52 @@ pub fn truncate_filename(path: &Path, max_len: usize) -> PathBuf { }; path.with_file_name(truncated_name) -} \ No newline at end of file +} + +pub fn normalize_string_path(path: &str) -> String { + std::path::PathBuf::from(path) + .components() + .collect::() + .to_string_lossy() + .to_string() +} + +#[cfg(test)] +mod tests { + use super::normalize_string_path; + + #[test] + fn test_simple_relative_path() { + let input = "foo/bar/baz"; + let normalized = normalize_string_path(input); + assert_eq!(normalized, "foo/bar/baz"); + } + + #[test] + fn test_redundant_slashes() { + let input = "foo//bar///baz"; + let normalized = normalize_string_path(input); + assert_eq!(normalized, "foo/bar/baz"); + } + + #[test] + fn test_dot_components() { + let input = "./foo/./bar/./baz"; + let normalized = normalize_string_path(input); + assert_eq!(normalized, "./foo/bar/baz"); + } + + #[test] + fn test_parent_components() { + let input = "foo/bar/../baz"; + let normalized = normalize_string_path(input); + assert_eq!(normalized, "foo/bar/../baz"); + } + + #[test] + fn test_empty_path() { + let input = ""; + let normalized = normalize_string_path(input); + assert_eq!(normalized, ""); + } +} diff --git a/backend/src/utils/network/request.rs b/backend/src/utils/network/request.rs index b2c54ba0b..ab4024309 100644 --- a/backend/src/utils/network/request.rs +++ b/backend/src/utils/network/request.rs @@ -60,7 +60,7 @@ pub async fn get_input_epg_content_as_file(client: Arc, input: match download_epg_content_as_file(client, input, url_str, working_dir, persist_filepath).await { Ok(content) => Ok(content), Err(e) => { - error!("cant download input epg url: {} => {}", sanitize_sensitive_info(url_str), sanitize_sensitive_info(e.to_string().as_str())); + error!("cant download input {} epg url: {} => {}", input.name, sanitize_sensitive_info(url_str), sanitize_sensitive_info(e.to_string().as_str())); create_tuliprox_error_result!(TuliproxErrorKind::Notify, "Failed to download") } } @@ -107,7 +107,7 @@ pub async fn get_input_text_content(client: Arc, input: &InputS match download_text_content(client, None, input, None, persist_filepath).await { Ok((content, _response_url)) => Ok(content), Err(e) => { - error!("cant download input url: {} => {}", sanitize_sensitive_info(&input.url), sanitize_sensitive_info(e.to_string().as_str())); + error!("Failed to download input '{}': {}", &input.name, sanitize_sensitive_info(e.to_string().as_str())); create_tuliprox_error_result!(TuliproxErrorKind::Notify, "Failed to download") } } diff --git a/backend/src/utils/network/xtream.rs b/backend/src/utils/network/xtream.rs index 3f0fdb243..723b434ce 100644 --- a/backend/src/utils/network/xtream.rs +++ b/backend/src/utils/network/xtream.rs @@ -70,7 +70,7 @@ where let app_config = &app_state.app_config; if cluster == XtreamCluster::Series { - if let Some(content) = xtream_repository::xtream_load_series_info(app_config, target.name.as_str(), pli.get_virtual_id()) { + if let Some(content) = xtream_repository::xtream_load_series_info(app_config, target.name.as_str(), pli.get_virtual_id()).await { // Deliver existing target content return rewrite_xtream_series_info_content(app_state, target, xtream_output, pli, user, &content).await; } @@ -78,20 +78,20 @@ where // Check if the content has been resolved if xtream_output.resolve_series { if let Some(provider_id) = pli.get_provider_id() { - if let Some(content) = xtream_get_input_info(app_config, input, provider_id, XtreamCluster::Series) { + if let Some(content) = xtream_get_input_info(app_config, input, provider_id, XtreamCluster::Series).await { return xtream_repository::write_and_get_xtream_series_info(app_state, target, xtream_output, pli, user, &content).await; } } } } else if cluster == XtreamCluster::Video { - if let Some(content) = xtream_repository::xtream_load_vod_info(app_config, target.name.as_str(), pli.get_virtual_id()) { + if let Some(content) = xtream_repository::xtream_load_vod_info(app_config, target.name.as_str(), pli.get_virtual_id()).await { // Deliver existing target content return rewrite_xtream_vod_info_content(app_config, target, xtream_output, pli, user, &content); } // Check if the content has been resolved if xtream_output.resolve_vod { if let Some(provider_id) = pli.get_provider_id() { - if let Some(content) = xtream_get_input_info(app_config, input, provider_id, XtreamCluster::Video) { + if let Some(content) = xtream_get_input_info(app_config, input, provider_id, XtreamCluster::Video).await { return xtream_repository::write_and_get_xtream_vod_info(app_config, target, xtream_output, pli, user, &content).await; } } diff --git a/bin/build_docker.sh b/bin/build_docker.sh index 1fe452263..67c8ad936 100755 --- a/bin/build_docker.sh +++ b/bin/build_docker.sh @@ -126,7 +126,7 @@ for PLATFORM in "${!ARCHITECTURES[@]}"; do # Use incremental compilation and enable cache-friendly flags env RUSTFLAGS="--remap-path-prefix $HOME=~ -C incremental=/tmp/rust-incremental-${ARCHITECTURE}" \ CARGO_INCREMENTAL=1 \ - cross build -p tuliprox --release --target "$ARCHITECTURE" + cross build -p tuliprox --release --target "$ARCHITECTURE" --locked BINARY_PATH="${WORKING_DIR}/target/${ARCHITECTURE}/release/tuliprox" if [ ! -f "$BINARY_PATH" ]; then diff --git a/config/config.yml b/config/config.yml index 67304842d..58da85454 100644 --- a/config/config.yml +++ b/config/config.yml @@ -64,3 +64,11 @@ video: directory: ${env:TULIPROX_HOME}/downloads organize_into_directories: false episode_pattern: .*(?P[Ss]\d{1,2}(.*?)[Ee]\d{1,2}).* + +reverse_proxy: + resource_retry: + max_attempts: 3 + backoff_millis: 250 + backoff_multiplier: 1.0 + stream: + shared_burst_buffer_mb: 12 # default 12 MB diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index 0529548c2..95d800f41 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -1,10 +1,10 @@ [package] name = "frontend" -version = "3.1.8" +version = "3.2.0" edition = "2021" [dependencies] -shared = { version = "3.1.8", path = "../shared" } +shared = { version = "3.2.0", path = "../shared" } chrono = "0" yew = "0.21" yew-router = "0.18" diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index 336d34ddc..fdde2dd9b 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -39,7 +39,11 @@ "SANITIZE_SENSITIVE_INFO": "Sanitize sensitive info", "ACTIVE_USER": "Active User", "RESOURCE_REWRITE_DISABLE": "Resource Rewrite disable", + "RESOURCE_RETRY": "Resource Retry", "DISABLED_HEADER": "Disabled header", + "MAX_ATTEMPTS": "Max attempts", + "BACKOFF_MILLIS": "Backoff (ms)", + "BACKOFF_MULTIPLIER": "Backoff multiplier", "REFERER_HEADER": "Remove referer header", "X_HEADER": "Remove X-* headers", "CUSTOM_HEADERS": "Custom headers", @@ -50,6 +54,7 @@ "GRACE_PERIOD_TIMEOUT": "Grace period timeout sec", "BUFFER_ENABLED": "Buffer Enabled", "BUFFER_SIZE": "Buffer Size", + "SHARED_BURST_BUFFER_BYTES": "Shared burst buffer (MB)", "ENABLED": "Enabled", "SIZE": "Size", "CACHE_DIR": "Cache dir", @@ -693,4 +698,4 @@ "ZM": "Zambia", "ZW": "Zimbabwe" } -} \ No newline at end of file +} diff --git a/frontend/public/assets/icons.json b/frontend/public/assets/icons.json index 6846ac8d8..eef6df83d 100644 --- a/frontend/public/assets/icons.json +++ b/frontend/public/assets/icons.json @@ -520,5 +520,4 @@ "keys": ["Accept"], "path": "m18 7-1.41-1.41-6.34 6.34 1.41 1.41zm4.24-1.41L11.66 16.17 7.48 12l-1.41 1.41L11.66 19l12-12zM.41 13.41 6 19l1.41-1.41L1.83 12z" } - ] \ No newline at end of file diff --git a/frontend/scss/_theme.scss b/frontend/scss/_theme.scss index 13fbbce26..e6e38b754 100644 --- a/frontend/scss/_theme.scss +++ b/frontend/scss/_theme.scss @@ -363,8 +363,11 @@ --source-editor-line-color: #ffffff; --source-editor-pending-line-color: yellow; -} + --brick-port-hover-color: yellow; + --selection-area-border-color: rgba(0, 120, 215, 0.9); + --selection-area-background-color: rgba(0, 120, 215, 0.12); +} body[data-theme="bright"] { --primary-color: rgba(10, 120, 255, 0.95); diff --git a/frontend/scss/app/components/_sidebar.scss b/frontend/scss/app/components/_sidebar.scss index d3f1c91e7..36d60986f 100644 --- a/frontend/scss/app/components/_sidebar.scss +++ b/frontend/scss/app/components/_sidebar.scss @@ -1,3 +1,5 @@ +@use "../../size" as size; + .tp__app-sidebar.expanded { min-width: var(--sidebar-width-expaned); max-width: var(--sidebar-width-expaned); @@ -76,3 +78,11 @@ } } } + +@media (max-width: size.$mobile-breakpoint) { + .tp__app-sidebar-menu { + &--source_editor { + display: none; + } + } +} diff --git a/frontend/scss/app/components/config/_config_view.scss b/frontend/scss/app/components/config/_config_view.scss index 432858e11..252a4570b 100644 --- a/frontend/scss/app/components/config/_config_view.scss +++ b/frontend/scss/app/components/config/_config_view.scss @@ -1,4 +1,4 @@ -@use "../../../size"; +@use "../../../size" as size; .tp__config-view { display: flex; @@ -75,6 +75,16 @@ box-sizing: border-box; overflow: auto; + &__title { + display: none; + color: var(--modest-text-color); + font-size: 1.5rem; + margin-bottom: var(--margin-default); + @media (max-width: size.$mobile-breakpoint) { + display: block; + } + } + &__header { display: grid; grid-template-columns: repeat(auto-fit, minmax(size.$config-grid-cell-width, 1fr)); diff --git a/frontend/scss/app/components/source_editor/_source_editor.scss b/frontend/scss/app/components/source_editor/_source_editor.scss index 997a7ed35..e7ff885ac 100644 --- a/frontend/scss/app/components/source_editor/_source_editor.scss +++ b/frontend/scss/app/components/source_editor/_source_editor.scss @@ -79,6 +79,15 @@ &__connections { } + &__selection-rect { + position:absolute; + display: none; + border: 1px dashed var(--selection-area-border-color); + background: var(--selection-area-background-color); + z-index: 9999; + pointer-events: none; + } + } .graph-paper-advanced { @@ -94,6 +103,11 @@ cursor: grabbing; } +.selection_mode * { + cursor: crosshair !important; +} + + .clickable { cursor: pointer; } diff --git a/frontend/scss/app/components/source_editor/_source_editor_block.scss b/frontend/scss/app/components/source_editor/_source_editor_block.scss index 88b0a228d..834a73d37 100644 --- a/frontend/scss/app/components/source_editor/_source_editor_block.scss +++ b/frontend/scss/app/components/source_editor/_source_editor_block.scss @@ -6,6 +6,8 @@ $port-size: 10px; .tp__source-editor__block { position: absolute; display: flex; + left: 0; + top: 0; flex-flow: column; align-items: center; background-color: transparent; @@ -13,6 +15,17 @@ $port-size: 10px; min-width: $block-width; max-width: $block-width; width: $block-width; + box-sizing: border-box; + + &-editing { + .tp__source-editor__block-content { + border-color: red !important; + } + } + + &-selected { + filter: sepia(1); + } &-header { position: relative; @@ -41,7 +54,6 @@ $port-size: 10px; justify-content: center; border-top-left-radius: 4px; border-top-right-radius: 4px; - } &-content { @@ -71,6 +83,10 @@ $port-size: 10px; } } + &-batch { + border-bottom-right-radius: 0; + } + &-label { display: block; width: 100%; @@ -113,6 +129,10 @@ $port-size: 10px; cursor: crosshair; z-index: 2; + &:hover { + background-color: var(--brick-port-hover-color); + } + &--left { left: -5px; background-color: var(--brick-port-background-color); @@ -180,8 +200,35 @@ $port-size: 10px; } .tp__source-editor__block { + + &-batch-banner { + display: flex; + flex-flow: row; + justify-content: flex-end; + width: 100%; + &-label { + color: var(--modest-text-color); + font-size: 0.7rem; + font-weight: bold; + width: 50%; + height: 100%; + cursor: grab; + display: flex; + align-items: center; + justify-content: center; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + } + } + &-InputXtream, &-InputM3u { + .tp__source-editor__block-batch-banner-label { + border: 1px solid var(--brick-input-border-color); + border-top-color: transparent; + background-color: var(--brick-input-background-color); + } + .tp__source-editor__block-content { border-color: var(--brick-input-border-color); background-color: var(--brick-input-background-color); diff --git a/frontend/src/app/components/authentication.rs b/frontend/src/app/components/authentication.rs index 4324bf1ac..63bd4ac4b 100644 --- a/frontend/src/app/components/authentication.rs +++ b/frontend/src/app/components/authentication.rs @@ -24,7 +24,7 @@ pub fn Authentication(props: &AuthenticationProps) -> Html { &mut |success| { authenticated_state.set(success); if success { - services_ctx.websocket.connect_ws(); + services_ctx.websocket.connect_ws_with_backoff(); } future::ready(()) } diff --git a/frontend/src/app/components/config/api_config_view.rs b/frontend/src/app/components/config/api_config_view.rs index 7f212f512..0805263ca 100644 --- a/frontend/src/app/components/config/api_config_view.rs +++ b/frontend/src/app/components/config/api_config_view.rs @@ -5,7 +5,7 @@ use shared::model::{ConfigApiDto}; use crate::{config_field, config_field_empty, edit_field_number_u16, edit_field_text, generate_form_reducer, html_if}; use crate::app::components::AppIcon; use crate::app::components::config::config_view_context::ConfigViewContext; -use crate::app::components::config::config_page::ConfigForm; +use crate::app::components::config::config_page::{ConfigForm, LABEL_API_CONFIG}; const LABEL_HOST: &str = "LABEL.HOST"; const LABEL_PORT: &str = "LABEL.PORT"; @@ -96,6 +96,7 @@ pub fn ApiConfigView() -> Html { html! {

+
{translate.t(LABEL_API_CONFIG)}
{ html_if!(*config_view_ctx.edit_mode, {
diff --git a/frontend/src/app/components/config/config_page.rs b/frontend/src/app/components/config/config_page.rs index 4e6e7a9f5..023f8d5dc 100644 --- a/frontend/src/app/components/config/config_page.rs +++ b/frontend/src/app/components/config/config_page.rs @@ -4,6 +4,18 @@ use shared::model::{ConfigApiDto, HdHomeRunConfigDto, IpCheckConfigDto, LogConfi use std::fmt; use std::str::FromStr; +pub const LABEL_MAIN_CONFIG: &str = "LABEL.MAIN"; +pub const LABEL_API_CONFIG: &str = "LABEL.API"; +pub const LABEL_LOG_CONFIG: &str = "LABEL.LOG"; +pub const LABEL_SCHEDULES_CONFIG: &str = "LABEL.SCHEDULES"; +pub const LABEL_MESSAGING_CONFIG: &str = "LABEL.MESSAGING"; +pub const LABEL_WEB_UI_CONFIG: &str = "LABEL.WEB_UI"; +pub const LABEL_REVERSE_PROXY_CONFIG: &str = "LABEL.REVERSE_PROXY"; +pub const LABEL_HDHOMERUN_CONFIG: &str = "LABEL.HDHOMERUN_CONFIG"; +pub const LABEL_PROXY_CONFIG: &str = "LABEL.PROXY"; +pub const LABEL_IP_CHECK_CONFIG: &str = "LABEL.IP_CHECK"; +pub const LABEL_VIDEO_CONFIG: &str = "LABEL.VIDEO"; + const MAIN_PAGE: &str = "main"; const API_PAGE: &str = "api"; const LOG_PAGE: &str = "log"; diff --git a/frontend/src/app/components/config/config_view.rs b/frontend/src/app/components/config/config_view.rs index ea5801eb4..8966e6910 100644 --- a/frontend/src/app/components/config/config_view.rs +++ b/frontend/src/app/components/config/config_view.rs @@ -1,4 +1,4 @@ -use crate::app::components::config::config_page::{ConfigForm, ConfigPage}; +use crate::app::components::config::config_page::{ConfigForm, ConfigPage, LABEL_API_CONFIG, LABEL_HDHOMERUN_CONFIG, LABEL_IP_CHECK_CONFIG, LABEL_LOG_CONFIG, LABEL_MAIN_CONFIG, LABEL_MESSAGING_CONFIG, LABEL_PROXY_CONFIG, LABEL_REVERSE_PROXY_CONFIG, LABEL_SCHEDULES_CONFIG, LABEL_VIDEO_CONFIG, LABEL_WEB_UI_CONFIG}; use crate::app::components::config::config_view_context::ConfigViewContext; use crate::app::components::config::{ApiConfigView, HdHomerunConfigView, IpCheckConfigView, LogConfigView, MainConfigView, MessagingConfigView, ProxyConfigView, ReverseProxyConfigView, SchedulesConfigView, VideoConfigView, WebUiConfigView}; use crate::app::components::{Card, TabItem, TabSet, TextButton}; @@ -12,26 +12,12 @@ use crate::app::components::config::config_update::update_config; use crate::app::{ConfigContext}; use crate::hooks::use_service_context; -// ==== Label Konstanten ==== -const LABEL_MAIN: &str = "LABEL.MAIN"; -const LABEL_API: &str = "LABEL.API"; -const LABEL_LOG: &str = "LABEL.LOG"; -const LABEL_SCHEDULES: &str = "LABEL.SCHEDULES"; -const LABEL_MESSAGING: &str = "LABEL.MESSAGING"; -const LABEL_WEB_UI: &str = "LABEL.WEB_UI"; -const LABEL_REVERSE_PROXY: &str = "LABEL.REVERSE_PROXY"; -const LABEL_HDHOMERUN_CONFIG: &str = "LABEL.HDHOMERUN_CONFIG"; -const LABEL_PROXY: &str = "LABEL.PROXY"; -const LABEL_IP_CHECK: &str = "LABEL.IP_CHECK"; -const LABEL_VIDEO: &str = "LABEL.VIDEO"; const LABEL_CONFIG: &str = "LABEL.CONFIG"; const LABEL_EDIT: &str = "LABEL.EDIT"; const LABEL_VIEW: &str = "LABEL.VIEW"; const LABEL_SAVE: &str = "LABEL.SAVE"; const LABEL_UPDATE_GEOIP: &str = "LABEL.UPDATE_GEOIP_DB"; -// ========================== - macro_rules! collect_modified { ($forms:expr, [ $($field:ident),+ $(,)? ]) => {{ let mut modified = Vec::new(); @@ -111,17 +97,17 @@ pub fn ConfigView() -> Html { .collect::>(); let tab_configs = vec![ - (ConfigPage::Main, LABEL_MAIN, html! { }, "MainConfig"), - (ConfigPage::Api, LABEL_API, html! { }, "ApiConfig"), - (ConfigPage::Log, LABEL_LOG, html! { }, "Log"), - (ConfigPage::Schedules, LABEL_SCHEDULES, html! { }, "SchedulesConfig"), - (ConfigPage::Messaging, LABEL_MESSAGING, html! { }, "MessagingConfig"), - (ConfigPage::WebUi, LABEL_WEB_UI, html! { }, "WebUiConfig"), - (ConfigPage::ReverseProxy, LABEL_REVERSE_PROXY, html! { }, "ReverseProxyConfig"), + (ConfigPage::Main, LABEL_MAIN_CONFIG, html! { }, "MainConfig"), + (ConfigPage::Api, LABEL_API_CONFIG, html! { }, "ApiConfig"), + (ConfigPage::Log, LABEL_LOG_CONFIG, html! { }, "Log"), + (ConfigPage::Schedules, LABEL_SCHEDULES_CONFIG, html! { }, "SchedulesConfig"), + (ConfigPage::Messaging, LABEL_MESSAGING_CONFIG, html! { }, "MessagingConfig"), + (ConfigPage::WebUi, LABEL_WEB_UI_CONFIG, html! { }, "WebUiConfig"), + (ConfigPage::ReverseProxy, LABEL_REVERSE_PROXY_CONFIG, html! { }, "ReverseProxyConfig"), (ConfigPage::HdHomerun, LABEL_HDHOMERUN_CONFIG, html! { }, "HdHomerunConfig"), - (ConfigPage::Proxy, LABEL_PROXY, html! { }, "ProxyConfig"), - (ConfigPage::IpCheck, LABEL_IP_CHECK, html! { }, "IpCheckConfig"), - (ConfigPage::Video, LABEL_VIDEO, html! { }, "VideoConfig"), + (ConfigPage::Proxy, LABEL_PROXY_CONFIG, html! { }, "ProxyConfig"), + (ConfigPage::IpCheck, LABEL_IP_CHECK_CONFIG, html! { }, "IpCheckConfig"), + (ConfigPage::Video, LABEL_VIDEO_CONFIG, html! { }, "VideoConfig"), ]; let editing = *editing; diff --git a/frontend/src/app/components/config/hdhomerun_config_view.rs b/frontend/src/app/components/config/hdhomerun_config_view.rs index a4df235dc..2033a79e7 100644 --- a/frontend/src/app/components/config/hdhomerun_config_view.rs +++ b/frontend/src/app/components/config/hdhomerun_config_view.rs @@ -5,7 +5,7 @@ use shared::model::{HdHomeRunDeviceConfigDto, HdHomeRunConfigDto}; use crate::app::components::{Card, NoContent, TextButton}; use crate::app::context::ConfigContext; use crate::{config_field_bool, edit_field_bool, generate_form_reducer, html_if}; -use crate::app::components::config::config_page::ConfigForm; +use crate::app::components::config::config_page::{ConfigForm, LABEL_HDHOMERUN_CONFIG}; use crate::app::components::config::hdhomerun_device_view::{HdHomerunDeviceView}; use crate::app::components::config::config_view_context::ConfigViewContext; @@ -128,6 +128,7 @@ pub fn HdHomerunConfigView() -> Html { html! {
+
{translate.t(LABEL_HDHOMERUN_CONFIG)}
{if edit_mode { html! { diff --git a/frontend/src/app/components/config/ipcheck_config_view.rs b/frontend/src/app/components/config/ipcheck_config_view.rs index 5243620da..74f6635a5 100644 --- a/frontend/src/app/components/config/ipcheck_config_view.rs +++ b/frontend/src/app/components/config/ipcheck_config_view.rs @@ -4,7 +4,7 @@ use shared::model::{IpCheckConfigDto}; use crate::app::context::ConfigContext; use crate::{config_field_empty, config_field_optional, edit_field_text_option, generate_form_reducer}; use crate::app::components::config::config_view_context::ConfigViewContext; -use crate::app::components::config::config_page::{ConfigForm}; +use crate::app::components::config::config_page::{ConfigForm, LABEL_IP_CHECK_CONFIG}; const LABEL_URL: &str = "LABEL.URL"; const LABEL_URL_IPV4: &str = "LABEL.URL_IPV4"; const LABEL_URL_IPV6: &str = "LABEL.URL_IPV6"; @@ -104,6 +104,7 @@ pub fn IpCheckConfigView() -> Html { html! {
+
{translate.t(LABEL_IP_CHECK_CONFIG)}
{ if *config_view_ctx.edit_mode { diff --git a/frontend/src/app/components/config/log_config_view.rs b/frontend/src/app/components/config/log_config_view.rs index b4a4cc0d6..fb2e55026 100644 --- a/frontend/src/app/components/config/log_config_view.rs +++ b/frontend/src/app/components/config/log_config_view.rs @@ -5,7 +5,7 @@ use shared::model::{LogConfigDto}; use crate::app::context::ConfigContext; use crate::{config_field_bool, config_field_child, edit_field_bool, generate_form_reducer}; use crate::app::components::config::config_view_context::ConfigViewContext; -use crate::app::components::config::config_page::{ConfigForm}; +use crate::app::components::config::config_page::{ConfigForm, LABEL_LOG_CONFIG}; use crate::app::components::{Chip, RadioButtonGroup}; const LABEL_LOG_LEVEL: &str = "LABEL.LOG_LEVEL"; @@ -105,6 +105,7 @@ pub fn LogConfigView() -> Html { html! {
+
{translate.t(LABEL_LOG_CONFIG)}
{ if *config_view_ctx.edit_mode { diff --git a/frontend/src/app/components/config/macros.rs b/frontend/src/app/components/config/macros.rs index e1bf91a75..98dc7a65f 100644 --- a/frontend/src/app/components/config/macros.rs +++ b/frontend/src/app/components/config/macros.rs @@ -247,6 +247,32 @@ macro_rules! edit_field_number_u8 { }}; } +#[macro_export] +macro_rules! edit_field_number_f64 { + ($instance:expr, $label:expr, $field:ident, $action:path) => {{ + let instance = $instance.clone(); + html! { +
+ <$crate::app::components::number_input::NumberInput + label={$label} + name={stringify!($field)} + float_value={Some(instance.form.$field)} + on_change_float={Some({ + let instance = instance.clone(); + Callback::from(move |value: Option| { + if let Some(value) = value { + if value.is_finite() { + instance.dispatch($action(value)); + } + } + }) + })} + /> +
+ } + }}; +} + #[macro_export] macro_rules! edit_field_number_u16 { ($instance:expr, $label:expr, $field:ident, $action:path) => {{ diff --git a/frontend/src/app/components/config/main_config_view.rs b/frontend/src/app/components/config/main_config_view.rs index d5c5050a1..75481ae1e 100644 --- a/frontend/src/app/components/config/main_config_view.rs +++ b/frontend/src/app/components/config/main_config_view.rs @@ -3,8 +3,9 @@ use yew_i18n::use_translation; use shared::model::MainConfigDto; use crate::app::context::ConfigContext; use crate::app::components::config::config_view_context::ConfigViewContext; -use crate::app::components::config::config_page::ConfigForm; -use crate::{config_field_optional, config_field_bool, config_field, edit_field_text_option, edit_field_bool, generate_form_reducer, edit_field_number, edit_field_number_option, edit_field_text, edit_field_number_u8}; +use crate::app::components::config::config_page::{ConfigForm, LABEL_MAIN_CONFIG}; +use crate::{config_field_optional, config_field_bool, config_field, edit_field_text_option, edit_field_bool, + generate_form_reducer, edit_field_number, edit_field_number_option, edit_field_text, edit_field_number_u8}; const LABEL_UPDATE_ON_BOOT: &str = "LABEL.UPDATE_ON_BOOT"; const LABEL_CONFIG_HOT_RELOAD: &str = "LABEL.CONFIG_HOT_RELOAD"; @@ -94,7 +95,7 @@ pub fn MainConfigView() -> Html { { edit_field_bool!(form_state, translate.t(LABEL_UPDATE_ON_BOOT), update_on_boot, MainConfigFormAction::UpdateOnBoot) } { edit_field_bool!(form_state, translate.t(LABEL_CONFIG_HOT_RELOAD), config_hot_reload, MainConfigFormAction::ConfigHotReload) } { edit_field_bool!(form_state, translate.t(LABEL_USER_ACCESS_CONTROL), user_access_control, MainConfigFormAction::UserAccessControl) } - { edit_field_bool!(form_state, translate.t(LABEL_ACCEPT_INSECURE_SSL_CERTIFICATES), user_access_control, MainConfigFormAction::AcceptInsecureSslCertificates) } + { edit_field_bool!(form_state, translate.t(LABEL_ACCEPT_INSECURE_SSL_CERTIFICATES), accept_insecure_ssl_certificates, MainConfigFormAction::AcceptInsecureSslCertificates) } { edit_field_number_u8!(form_state, translate.t(LABEL_THREADS), threads, MainConfigFormAction::Threads) } { edit_field_text!(form_state, translate.t(LABEL_WORKING_DIR), working_dir, MainConfigFormAction::WorkingDir) } { edit_field_text_option!(form_state, translate.t(LABEL_MAPPING_PATH), mapping_path, MainConfigFormAction::MappingPath) } @@ -108,6 +109,7 @@ pub fn MainConfigView() -> Html { html! {
+
{translate.t(LABEL_MAIN_CONFIG)}
{ if *config_view_ctx.edit_mode { diff --git a/frontend/src/app/components/config/messaging_config_view.rs b/frontend/src/app/components/config/messaging_config_view.rs index b81222703..6216cbb92 100644 --- a/frontend/src/app/components/config/messaging_config_view.rs +++ b/frontend/src/app/components/config/messaging_config_view.rs @@ -1,4 +1,4 @@ -use crate::app::components::config::config_page::ConfigForm; +use crate::app::components::config::config_page::{ConfigForm, LABEL_MESSAGING_CONFIG}; use crate::app::components::config::config_view_context::ConfigViewContext; use crate::app::components::{Card, Chip, RadioButtonGroup}; use crate::{config_field, config_field_bool, config_field_bool_empty, config_field_child, config_field_empty, config_field_hide, config_field_optional, edit_field_bool, edit_field_list, edit_field_text, edit_field_text_option, generate_form_reducer}; @@ -305,6 +305,7 @@ pub fn MessagingConfigView() -> Html { html! {
+
{translate.t(LABEL_MESSAGING_CONFIG)}
{ if *config_view_ctx.edit_mode { render_edit_mode() } else { render_view_mode() } }
} diff --git a/frontend/src/app/components/config/proxy_config_view.rs b/frontend/src/app/components/config/proxy_config_view.rs index 3a97fad58..8396e9f76 100644 --- a/frontend/src/app/components/config/proxy_config_view.rs +++ b/frontend/src/app/components/config/proxy_config_view.rs @@ -3,7 +3,7 @@ use yew_i18n::use_translation; use shared::model::ProxyConfigDto; use crate::app::context::ConfigContext; use crate::app::components::config::config_view_context::ConfigViewContext; -use crate::app::components::config::config_page::ConfigForm; +use crate::app::components::config::config_page::{ConfigForm, LABEL_PROXY_CONFIG}; use crate::{ config_field, config_field_optional, config_field_optional_hide, edit_field_text, edit_field_text_option, generate_form_reducer, @@ -74,6 +74,7 @@ pub fn ProxyConfigView() -> Html { html! {
+
{translate.t(LABEL_PROXY_CONFIG)}
{ if *config_view_ctx.edit_mode { render_edit_mode() diff --git a/frontend/src/app/components/config/reverse_proxy_config_view.rs b/frontend/src/app/components/config/reverse_proxy_config_view.rs index 458e6ce76..a071e331a 100644 --- a/frontend/src/app/components/config/reverse_proxy_config_view.rs +++ b/frontend/src/app/components/config/reverse_proxy_config_view.rs @@ -2,12 +2,13 @@ use yew::prelude::*; use yew_i18n::use_translation; -use shared::model::{CacheConfigDto, GeoIpConfigDto, RateLimitConfigDto, ReverseProxyConfigDto, ReverseProxyDisabledHeaderConfigDto, StreamConfigDto}; +use shared::model::{CacheConfigDto, GeoIpConfigDto, RateLimitConfigDto, ResourceRetryConfigDto, ReverseProxyConfigDto, ReverseProxyDisabledHeaderConfigDto, StreamConfigDto}; +use shared::utils::format_float_localized; use crate::app::context::ConfigContext; use crate::app::components::config::config_view_context::ConfigViewContext; -use crate::app::components::config::config_page::ConfigForm; +use crate::app::components::config::config_page::{ConfigForm, LABEL_REVERSE_PROXY_CONFIG}; use crate::app::components::{Card}; -use crate::{config_field, config_field_bool, config_field_custom, config_field_optional, edit_field_bool, edit_field_list, edit_field_number, edit_field_number_u64, edit_field_text, edit_field_text_option, generate_form_reducer}; +use crate::{config_field, config_field_bool, config_field_custom, config_field_optional, edit_field_bool, edit_field_list, edit_field_number, edit_field_number_f64, edit_field_number_u64, edit_field_text, edit_field_text_option, generate_form_reducer}; const LABEL_CACHE: &str = "LABEL.CACHE"; const LABEL_ENABLED: &str = "LABEL.ENABLED"; @@ -19,15 +20,20 @@ const LABEL_RETRY: &str = "LABEL.RETRY"; const LABEL_THROTTLE: &str = "LABEL.THROTTLE"; const LABEL_GRACE_PERIOD_MILLIS: &str = "LABEL.GRACE_PERIOD_MILLIS"; const LABEL_GRACE_PERIOD_TIMEOUT_SECS: &str = "LABEL.GRACE_PERIOD_TIMEOUT_SECS"; -const LABEL_FORCED_RETRY_INTERVAL_SECS: &str = "LABEL.FORCED_RETRY_INTERVAL_SECS"; +//const LABEL_FORCED_RETRY_INTERVAL_SECS: &str = "LABEL.FORCED_RETRY_INTERVAL_SECS"; const LABEL_THROTTLE_KBPS: &str = "LABEL.THROTTLE_KBPS"; const LABEL_RATE_LIMIT: &str = "LABEL.RATE_LIMIT"; const LABEL_PERIOD_MILLIS: &str = "LABEL.PERIOD_MILLIS"; const LABEL_BURST_SIZE: &str = "LABEL.BURST_SIZE"; +const LABEL_SHARED_BURST_BUFFER_MB: &str = "LABEL.SHARED_BURST_BUFFER_BYTES"; const LABEL_SETTINGS: &str = "LABEL.SETTINGS"; const LABEL_RESOURCE_REWRITE_DISABLED: &str = "LABEL.RESOURCE_REWRITE_DISABLED"; +const LABEL_RESOURCE_RETRY: &str = "LABEL.RESOURCE_RETRY"; +const LABEL_MAX_ATTEMPTS: &str = "LABEL.MAX_ATTEMPTS"; +const LABEL_BACKOFF_MILLIS: &str = "LABEL.BACKOFF_MILLIS"; +const LABEL_BACKOFF_MULTIPLIER: &str = "LABEL.BACKOFF_MULTIPLIER"; const LABEL_DISABLED_HEADER: &str = "LABEL.DISABLED_HEADER"; const LABEL_REFERER_HEADER: &str = "LABEL.REFERER_HEADER"; const LABEL_X_HEADER: &str = "LABEL.X_HEADER"; @@ -56,6 +62,16 @@ generate_form_reducer!( } ); +generate_form_reducer!( + state: ResourceRetryConfigFormState { form: ResourceRetryConfigDto }, + action_name: ResourceRetryConfigFormAction, + fields { + MaxAttempts => max_attempts: u32, + BackoffMillis => backoff_millis: u64, + BackoffMultiplier => backoff_multiplier: f64, + } +); + generate_form_reducer!( state: StreamConfigFormState { form: StreamConfigDto }, action_name: StreamConfigFormAction, @@ -64,8 +80,9 @@ generate_form_reducer!( Throttle => throttle: Option, GracePeriodMillis => grace_period_millis: u64, GracePeriodTimeoutSecs => grace_period_timeout_secs: u64, - ForcedRetryIntervalSecs => forced_retry_interval_secs: u32, + //ForcedRetryIntervalSecs => forced_retry_interval_secs: u32, ThrottleKbps => throttle_kbps: u64, + SharedBurstBufferMb => shared_burst_buffer_mb: u64, } ); @@ -114,6 +131,9 @@ pub fn ReverseProxyConfigView() -> Html { let rate_limit_state: UseReducerHandle = use_reducer(|| { RateLimitConfigFormState { form: RateLimitConfigDto::default(), modified: false } }); + let resource_retry_state: UseReducerHandle = use_reducer(|| { + ResourceRetryConfigFormState { form: ResourceRetryConfigDto::default(), modified: false } + }); let stream_state: UseReducerHandle = use_reducer(|| { StreamConfigFormState { form: StreamConfigDto::default(), modified: false } }); @@ -128,6 +148,7 @@ pub fn ReverseProxyConfigView() -> Html { let disabled_header_state = disabled_header_state.clone(); let cache_state = cache_state.clone(); let rate_limit_state = rate_limit_state.clone(); + let resource_retry_state = resource_retry_state.clone(); let stream_state = stream_state.clone(); let geoip_state = geoip_state.clone(); @@ -137,13 +158,15 @@ pub fn ReverseProxyConfigView() -> Html { disabled_header_state, cache_state, rate_limit_state, + resource_retry_state, stream_state, geoip_state, ), - move |(rp, disabled_header, cache, rl, stream, geoip)| { + move |(rp, disabled_header, cache, rl, resource_retry, stream, geoip)| { let mut form = rp.form.clone(); form.cache = Some(cache.form.clone()); form.rate_limit = Some(rl.form.clone()); + form.resource_retry = Some(resource_retry.form.clone()); form.stream = Some(stream.form.clone()); form.geoip = Some(geoip.form.clone()); form.disabled_header = if disabled_header.form.is_empty() { @@ -156,6 +179,7 @@ pub fn ReverseProxyConfigView() -> Html { || disabled_header.modified || cache.modified || rl.modified + || resource_retry.modified || stream.modified || geoip.modified; on_form_change.emit(ConfigForm::ReverseProxy(modified, form)); @@ -168,6 +192,7 @@ pub fn ReverseProxyConfigView() -> Html { let disabled_header_state = disabled_header_state.clone(); let cache_state = cache_state.clone(); let rate_limit_state = rate_limit_state.clone(); + let resource_retry_state = resource_retry_state.clone(); let stream_state = stream_state.clone(); let geoip_state = geoip_state.clone(); @@ -178,6 +203,7 @@ pub fn ReverseProxyConfigView() -> Html { disabled_header_state.dispatch(ReverseProxyDisabledHeaderConfigFormAction::SetAll(rp.disabled_header.as_ref().map_or_else(ReverseProxyDisabledHeaderConfigDto::default, |d| d.clone()))); cache_state.dispatch(CacheConfigFormAction::SetAll(rp.cache.as_ref().map_or_else(CacheConfigDto::default, |c| c.clone()))); rate_limit_state.dispatch(RateLimitConfigFormAction::SetAll(rp.rate_limit.as_ref().map_or_else(RateLimitConfigDto::default, |rl| rl.clone()))); + resource_retry_state.dispatch(ResourceRetryConfigFormAction::SetAll(rp.resource_retry.as_ref().map_or_else(ResourceRetryConfigDto::default, |rr| rr.clone()))); stream_state.dispatch(StreamConfigFormAction::SetAll(rp.stream.as_ref().map_or_else(StreamConfigDto::default, |s| s.clone()))); geoip_state.dispatch(GeoIpConfigFormAction::SetAll(rp.geoip.as_ref().map_or_else(GeoIpConfigDto::default, |s| s.clone()))); } else { @@ -185,6 +211,7 @@ pub fn ReverseProxyConfigView() -> Html { disabled_header_state.dispatch(ReverseProxyDisabledHeaderConfigFormAction::SetAll(ReverseProxyDisabledHeaderConfigDto::default())); cache_state.dispatch(CacheConfigFormAction::SetAll(CacheConfigDto::default())); rate_limit_state.dispatch(RateLimitConfigFormAction::SetAll(RateLimitConfigDto::default())); + resource_retry_state.dispatch(ResourceRetryConfigFormAction::SetAll(ResourceRetryConfigDto::default())); stream_state.dispatch(StreamConfigFormAction::SetAll(StreamConfigDto::default())); geoip_state.dispatch(GeoIpConfigFormAction::SetAll(GeoIpConfigDto::default())); } @@ -210,8 +237,9 @@ pub fn ReverseProxyConfigView() -> Html { { config_field_optional!(stream_state.form, translate.t(LABEL_THROTTLE), throttle) } { config_field!(stream_state.form, translate.t(LABEL_GRACE_PERIOD_MILLIS), grace_period_millis) } { config_field!(stream_state.form, translate.t(LABEL_GRACE_PERIOD_TIMEOUT_SECS), grace_period_timeout_secs) } - { config_field!(stream_state.form, translate.t(LABEL_FORCED_RETRY_INTERVAL_SECS), forced_retry_interval_secs) } + //{ config_field!(stream_state.form, translate.t(LABEL_FORCED_RETRY_INTERVAL_SECS), forced_retry_interval_secs) } { config_field!(stream_state.form, translate.t(LABEL_THROTTLE_KBPS), throttle_kbps) } + { config_field!(stream_state.form, translate.t(LABEL_SHARED_BURST_BUFFER_MB), shared_burst_buffer_mb) } } }; @@ -255,6 +283,33 @@ pub fn ReverseProxyConfigView() -> Html { } }; + let render_resource_retry_view = || { + html! { + +

{translate.t(LABEL_RESOURCE_RETRY)}

+ { config_field!(resource_retry_state.form, translate.t(LABEL_MAX_ATTEMPTS), max_attempts) } + { config_field!(resource_retry_state.form, translate.t(LABEL_BACKOFF_MILLIS), backoff_millis) } + { + config_field_custom!( + translate.t(LABEL_BACKOFF_MULTIPLIER), + format_float_localized(resource_retry_state.form.backoff_multiplier, 4, true) + ) + } +
+ } + }; + + let render_resource_retry_edit = || { + html! { + +

{translate.t(LABEL_RESOURCE_RETRY)}

+ { edit_field_number!(resource_retry_state, translate.t(LABEL_MAX_ATTEMPTS), max_attempts, ResourceRetryConfigFormAction::MaxAttempts) } + { edit_field_number_u64!(resource_retry_state, translate.t(LABEL_BACKOFF_MILLIS), backoff_millis, ResourceRetryConfigFormAction::BackoffMillis) } + { edit_field_number_f64!(resource_retry_state, translate.t(LABEL_BACKOFF_MULTIPLIER), backoff_multiplier, ResourceRetryConfigFormAction::BackoffMultiplier) } +
+ } + }; + let render_disabled_header_view = || { let custom_headers = if disabled_header_state.form.custom_header.is_empty() { "-".to_string() @@ -286,6 +341,7 @@ pub fn ReverseProxyConfigView() -> Html { html! {
{ render_settings_view() } + { render_resource_retry_view() } { render_disabled_header_view() } { render_cache() } { render_rate_limit() } @@ -298,6 +354,7 @@ pub fn ReverseProxyConfigView() -> Html { let render_edit_mode = || html! {
{ render_settings_edit() } + { render_resource_retry_edit() } { render_disabled_header_edit() }

{translate.t(LABEL_CACHE)}

@@ -317,8 +374,9 @@ pub fn ReverseProxyConfigView() -> Html { { edit_field_text_option!(stream_state, translate.t(LABEL_THROTTLE), throttle, StreamConfigFormAction::Throttle) } { edit_field_number_u64!(stream_state, translate.t(LABEL_GRACE_PERIOD_MILLIS), grace_period_millis, StreamConfigFormAction::GracePeriodMillis) } { edit_field_number_u64!(stream_state, translate.t(LABEL_GRACE_PERIOD_TIMEOUT_SECS), grace_period_timeout_secs, StreamConfigFormAction::GracePeriodTimeoutSecs) } - { edit_field_number!(stream_state, translate.t(LABEL_FORCED_RETRY_INTERVAL_SECS), forced_retry_interval_secs, StreamConfigFormAction::ForcedRetryIntervalSecs) } + //{ edit_field_number!(stream_state, translate.t(LABEL_FORCED_RETRY_INTERVAL_SECS), forced_retry_interval_secs, StreamConfigFormAction::ForcedRetryIntervalSecs) } { edit_field_number_u64!(stream_state, translate.t(LABEL_THROTTLE_KBPS), throttle_kbps, StreamConfigFormAction::ThrottleKbps) } + { edit_field_number_u64!(stream_state, translate.t(LABEL_SHARED_BURST_BUFFER_MB), shared_burst_buffer_mb, StreamConfigFormAction::SharedBurstBufferMb) }

{translate.t(LABEL_GEOIP)}

@@ -330,6 +388,7 @@ pub fn ReverseProxyConfigView() -> Html { html! {
+
{translate.t(LABEL_REVERSE_PROXY_CONFIG)}
{ if *config_view_ctx.edit_mode { render_edit_mode() diff --git a/frontend/src/app/components/config/schedules_config_view.rs b/frontend/src/app/components/config/schedules_config_view.rs index 75d26e1a6..1a2d54e7e 100644 --- a/frontend/src/app/components/config/schedules_config_view.rs +++ b/frontend/src/app/components/config/schedules_config_view.rs @@ -5,7 +5,7 @@ use crate::app::ConfigContext; use yew::prelude::*; use yew_i18n::use_translation; use shared::model::{ScheduleConfigDto, SchedulesConfigDto}; -use crate::app::components::config::config_page::ConfigForm; +use crate::app::components::config::config_page::{ConfigForm, LABEL_SCHEDULES_CONFIG}; use crate::app::components::config::config_view_context::ConfigViewContext; use crate::app::components::config::HasFormData; use crate::{config_field_child, generate_form_reducer, html_if}; @@ -231,6 +231,7 @@ pub fn SchedulesConfigView() -> Html { html! {
+
{translate.t(LABEL_SCHEDULES_CONFIG)}
{ if *config_view_ctx.edit_mode { render_edit_mode() } else { html!{} } } { render_view_mode(*config_view_ctx.edit_mode) } diff --git a/frontend/src/app/components/config/video_config_view.rs b/frontend/src/app/components/config/video_config_view.rs index 9f5a8e46b..9cde0265f 100644 --- a/frontend/src/app/components/config/video_config_view.rs +++ b/frontend/src/app/components/config/video_config_view.rs @@ -6,7 +6,7 @@ use crate::{config_field_bool, config_field_child, config_field_optional, edit_f use shared::model::{VideoDownloadConfigDto, VideoConfigDto}; use yew::prelude::*; use yew_i18n::use_translation; -use crate::app::components::config::config_page::ConfigForm; +use crate::app::components::config::config_page::{ConfigForm, LABEL_VIDEO_CONFIG}; use crate::app::components::config::config_view_context::ConfigViewContext; const LABEL_DOWNLOAD: &str = "LABEL.DOWNLOAD"; @@ -161,6 +161,7 @@ pub fn VideoConfigView() -> Html { html! {
+
{translate.t(LABEL_VIDEO_CONFIG)}
{ if *config_view_ctx.edit_mode { render_edit_mode() } else { render_view_mode() } }
} diff --git a/frontend/src/app/components/config/webui_config_view.rs b/frontend/src/app/components/config/webui_config_view.rs index 684a61903..38a1bf395 100644 --- a/frontend/src/app/components/config/webui_config_view.rs +++ b/frontend/src/app/components/config/webui_config_view.rs @@ -1,7 +1,7 @@ use crate::app::components::{AppIcon, Card, Chip}; use crate::app::context::ConfigContext; use crate::app::components::config::config_view_context::ConfigViewContext; -use crate::app::components::config::config_page::ConfigForm; +use crate::app::components::config::config_page::{ConfigForm, LABEL_WEB_UI_CONFIG}; use crate::{config_field, config_field_bool, config_field_child, config_field_hide, config_field_optional, edit_field_bool, edit_field_list_option, edit_field_number, edit_field_text, edit_field_text_option, generate_form_reducer, html_if}; use yew::prelude::*; use yew_i18n::use_translation; @@ -182,6 +182,7 @@ pub fn WebUiConfigView() -> Html { html! {
+
{translate.t(LABEL_WEB_UI_CONFIG)}
{ html_if!(*config_view_ctx.edit_mode, {
diff --git a/frontend/src/app/components/dashboard/streams_view.rs b/frontend/src/app/components/dashboard/streams_view.rs index 4a968c8fa..37638ef3d 100644 --- a/frontend/src/app/components/dashboard/streams_view.rs +++ b/frontend/src/app/components/dashboard/streams_view.rs @@ -3,11 +3,15 @@ use yew::prelude::*; use yew_i18n::use_translation; use crate::app::components::{Card, StatusCard, StreamsTable}; use crate::app::StatusContext; +use crate::hooks::use_service_context; +use crate::model::EventMessage; #[function_component] pub fn StreamsView() -> Html { let translate = use_translation(); + let service_ctx = use_service_context(); let status_ctx = use_context::().expect("Status context not found"); + let provider_connections = use_state(|| 0); let memo_streams = { let status = status_ctx.status.clone(); @@ -16,6 +20,30 @@ pub fn StreamsView() -> Html { }) }; + { + let services = service_ctx.clone(); + let provider_connections = provider_connections.clone(); + use_effect_with((), move |_| { + let services = services.clone(); + let subid = services.event.subscribe(move |msg| { + if let EventMessage::ActiveProviderCount(count) = msg { + provider_connections.set(count); + } + }); + move || services.event.unsubscribe(subid) + }); + } + + { + let status_ctx = status_ctx.clone(); + let provider_connections = provider_connections.clone(); + use_effect_with(status_ctx.status, move |status| { + let count = status.as_ref().map_or(0, |status| + status.active_provider_connections.as_ref().map(|map| map.values().sum::()).unwrap_or(0)); + provider_connections.set(count); + }) + } + html! {
@@ -28,8 +56,7 @@ pub fn StreamsView() -> Html { ()).unwrap_or(0).to_string())} + data={(*provider_connections).to_string()} />
diff --git a/frontend/src/app/components/login.rs b/frontend/src/app/components/login.rs index 87a2df317..018fc98be 100644 --- a/frontend/src/app/components/login.rs +++ b/frontend/src/app/components/login.rs @@ -38,32 +38,30 @@ pub fn Login() -> Html { let username = username_input.value(); let password = password_input.value(); let result = services_ctx.auth.authenticate(username, password).await; - let success = result.is_ok(); - authorized_state.set(success); + match &result { + Ok(_token) => { + authorized_state.set(true) + } + Err(_) => {authorized_state.set(false);} + } result }) }; - let do_login = { + + let handle_login = { let authenticator = authenticate.clone(); - Callback::from(move |_| { + Callback::from(move |_: String| { authenticator.run(); }) }; - let handle_login = { - let login = do_login.clone(); - Callback::from(move |_: String| { - login.emit(()); - }) - }; - let handle_key_down = { - let login = do_login.clone(); + let authenticator = authenticate.clone(); Callback::from(move |e: KeyboardEvent| { if e.key() == "Enter" { e.prevent_default(); - login.emit(()); + authenticator.run(); } }) }; diff --git a/frontend/src/app/components/number_input.rs b/frontend/src/app/components/number_input.rs index 0320e5783..fa0bde0f7 100644 --- a/frontend/src/app/components/number_input.rs +++ b/frontend/src/app/components/number_input.rs @@ -1,6 +1,7 @@ use web_sys::{HtmlInputElement, KeyboardEvent}; use yew::prelude::*; use yew::TargetCast; +use shared::utils::{format_float_localized, parse_localized_float}; #[derive(Properties, Clone, PartialEq)] pub struct NumberInputProps { @@ -11,8 +12,12 @@ pub struct NumberInputProps { #[prop_or_default] pub value: Option, #[prop_or_default] + pub float_value: Option, + #[prop_or_default] pub on_change: Callback>, #[prop_or_default] + pub on_change_float: Option>>, + #[prop_or_default] pub placeholder: String, } @@ -22,25 +27,46 @@ pub fn NumberInput(props: &NumberInputProps) -> Html { { let input_ref = input_ref.clone(); - let value = props.value; - use_effect_with(value, move |val| { + let deps = (props.value, props.float_value); + use_effect_with(deps, move |(int_val, float_val)| { if let Some(input) = input_ref.cast::() { - match val { - Some(v) => input.set_value(&v.to_string()), - None => input.set_value(""), - } + let new_value = float_val + .map(|f| format_float_localized(f, 4, true)) + .or_else(|| int_val.map(|v| v.to_string())) + .unwrap_or_default(); + input.set_value(&new_value); } || () }); } + let prefers_float = props.on_change_float.is_some(); let on_input = { - let onchange = props.on_change.clone(); + let onchange_int = props.on_change.clone(); + let onchange_float = props.on_change_float.clone(); Callback::from(move |e: InputEvent| { if let Some(input) = e.target_dyn_into::() { - let raw = input.value(); - let parsed = raw.parse::().ok(); - onchange.emit(parsed); + let raw = input.value().trim().to_string(); + if raw.is_empty() { + if prefers_float { + if let Some(cb) = onchange_float.as_ref() { + cb.emit(None); + } + } else { + onchange_int.emit(None); + } + return; + } + + if prefers_float { + if let Some(cb) = onchange_float.as_ref() { + let parsed = parse_localized_float(&raw); + cb.emit(parsed); + } + } else { + let parsed = raw.parse::().ok(); + onchange_int.emit(parsed); + } } }) }; @@ -67,22 +93,19 @@ pub fn NumberInput(props: &NumberInputProps) -> Html { html! {
- { if props.label.is_some() { - html! { - - } - } else { html!{} } - } + { if let Some(label) = &props.label { + html! { } + } else { html!{} } }
+ />
} -} +} \ No newline at end of file diff --git a/frontend/src/app/components/sidebar.rs b/frontend/src/app/components/sidebar.rs index 482b14812..5e5ff765a 100644 --- a/frontend/src/app/components/sidebar.rs +++ b/frontend/src/app/components/sidebar.rs @@ -154,18 +154,18 @@ pub fn Sidebar(props: &SidebarProps) -> Html { let render_collapsed = || { html! {
- - - + + + - - - + + + - - - - + + + +
} }; diff --git a/frontend/src/app/components/source_editor/block.rs b/frontend/src/app/components/source_editor/block_view.rs similarity index 55% rename from frontend/src/app/components/source_editor/block.rs rename to frontend/src/app/components/source_editor/block_view.rs index 96b55e822..e125eeb1e 100644 --- a/frontend/src/app/components/source_editor/block.rs +++ b/frontend/src/app/components/source_editor/block_view.rs @@ -1,18 +1,20 @@ use web_sys::MouseEvent; -use yew::{classes, function_component, html, Callback, Html, Properties}; +use yew::{classes, function_component, html, Callback, Html, Properties, TargetCast}; use yew_i18n::use_translation; use crate::html_if; -use crate::app::components::{Block, BlockInstance, BlockType, PortStatus}; +use crate::app::components::{Block, BlockId, BlockInstance, BlockType, PortStatus}; #[derive(Properties, PartialEq)] pub struct BlockProps { pub(crate) block: Block, + pub(crate) edited:bool, + pub(crate) selected:bool, pub(crate) delete_mode: bool, - pub(crate) delete_block: Callback, + pub(crate) delete_block: Callback, pub(crate) port_status: PortStatus, - pub(crate) on_edit: Callback, - pub(crate) on_mouse_down: Callback<(usize, MouseEvent)>, - pub(crate) on_connection_drop: Callback, // to_id - pub(crate) on_connection_start: Callback, // from_id + pub(crate) on_edit: Callback, + pub(crate) on_mouse_down: Callback<(BlockId, MouseEvent)>, + pub(crate) on_connection_drop: Callback, // to_id + pub(crate) on_connection_start: Callback, // from_id } #[function_component] @@ -27,7 +29,7 @@ pub fn BlockView(props: &BlockProps) -> Html { let block_id = block.id; let block_type = block.block_type; - let style = format!("position:absolute; left:{}px; top:{}px;", block.position.0, block.position.1); + let style = format!("transform: translate({}px, {}px)", block.position.0, block.position.1); let from_id = block_id; let to_id = block_id; @@ -43,7 +45,17 @@ pub fn BlockView(props: &BlockProps) -> Html { let handle_mouse_down = { let on_block_mouse_down = props.on_mouse_down.clone(); - Callback::from(move |e| on_block_mouse_down.emit((block_id, e))) + Callback::from(move |e: MouseEvent| { + e.prevent_default(); + if let Some(target) = e.target_dyn_into::() { + let tag = target.tag_name().to_lowercase(); + if &tag == "span" { + return; + } + } + e.stop_propagation(); + on_block_mouse_down.emit((block_id, e)) + }) }; let handle_edit = { @@ -53,26 +65,38 @@ pub fn BlockView(props: &BlockProps) -> Html { }) }; - let (title, show_type) = { - let (dto_title, show_type) = match &block.instance { - BlockInstance::Input(dto) => (dto.name.clone(), true), - BlockInstance::Target(dto) => (dto.name.clone(), true), + let (title, show_type, is_batch) = { + let (dto_title, show_type, is_batch) = match &block.instance { + BlockInstance::Input(dto) => { + dto.aliases.as_ref().map_or( + (dto.name.clone(), true, false), + |a| { + if a.is_empty() { + (dto.name.clone(), true, false) + } else { + (if dto.name.is_empty() {a[0].name.clone()} else {dto.name.clone()}, true, true) + } + } + ) + }, + BlockInstance::Target(dto) => (dto.name.clone(), true, false), BlockInstance::Output(_output) => { - (translate.t(&format!("SOURCE_EDITOR.BRICK_{}", block_type)), false) + (translate.t(&format!("SOURCE_EDITOR.BRICK_{}", block_type)), false, false) } }; if dto_title.is_empty() { - (translate.t(&format!("SOURCE_EDITOR.BRICK_{}", block_type)), false) + (translate.t(&format!("SOURCE_EDITOR.BRICK_{}", block_type)), false, is_batch) } else { - (dto_title, show_type) + (dto_title, show_type, is_batch) } }; html! { -
+
// Block handle (drag) -
+
// Delete button for block { html_if!(delete_mode, { @@ -84,7 +108,7 @@ pub fn BlockView(props: &BlockProps) -> Html { }) }
-
+
{ title } @@ -98,24 +122,35 @@ pub fn BlockView(props: &BlockProps) -> Html { {html_if!(is_target || is_output, { // Left port -
})} {html_if!(is_target || is_input, { // Right port -
})}
+ {html_if!(is_batch, { +
+
{"batch"}
+
+ })}
} } \ No newline at end of file diff --git a/frontend/src/app/components/source_editor/editor_model.rs b/frontend/src/app/components/source_editor/editor_model.rs index 3ee55c482..719795540 100644 --- a/frontend/src/app/components/source_editor/editor_model.rs +++ b/frontend/src/app/components/source_editor/editor_model.rs @@ -2,7 +2,12 @@ use std::fmt; use std::rc::Rc; use serde::{Deserialize, Serialize}; use yew::{Callback, UseStateHandle}; -use shared::model::{ConfigInputDto, ConfigTargetDto, TargetOutputDto}; +use shared::model::{ConfigInputDto, ConfigTargetDto, InputType, TargetOutputDto}; + +pub const BLOCK_WIDTH: f32 = 100.0; +pub const BLOCK_HEIGHT: f32 = 50.0; +pub const BLOCK_HEADER_HEIGHT: f32 = 12.0; +pub const BLOCK_PORT_HEIGHT: f32 = 10.0; // ----------------- Data Models ----------------- #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)] @@ -25,6 +30,18 @@ impl BlockType { pub const OUTPUT_XTREAM: &'static str = "OutputXtream"; pub const OUTPUT_HDHOMERUN: &'static str = "OutputHdHomeRun"; pub const OUTPUT_STRM: &'static str = "OutputStrm"; + + pub fn is_input(&self) -> bool { + matches!(self, Self::InputXtream | Self::InputM3u) + } + + pub fn is_target(&self) -> bool { + matches!(self, Self::Target) + } + + // pub fn is_output(&self) -> bool { + // matches!(self, Self::OutputXtream | Self::OutputM3u | Self::OutputHdHomeRun | Self::OutputStrm) + // } } // Convert from String to BlockType @@ -49,6 +66,17 @@ impl From for BlockType { } } +impl From for BlockType { + fn from(s: InputType) -> Self { + match s { + InputType::M3uBatch + | InputType::M3u => BlockType::InputM3u, + InputType::XtreamBatch + | InputType::Xtream => BlockType::InputXtream, + } + } +} + // Display trait using constants impl fmt::Display for BlockType { @@ -65,8 +93,7 @@ impl fmt::Display for BlockType { write!(f, "{}", s) } } - -pub(crate) type BlockId = usize; +pub(crate) type BlockId = u16; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct Block { @@ -76,13 +103,50 @@ pub struct Block { pub instance: BlockInstance, } +impl Block { + /// Liefert die Bounding-Box des Blocks unter Berücksichtigung der Canvas-Verschiebung. + pub fn bounds(&self, canvas_offset: (f32, f32)) -> (f32, f32, f32, f32) { + let (ox, oy) = canvas_offset; + let bx = self.position.0 + ox; + let by = self.position.1 + oy; + let b_left = bx; + let b_top = by; + let b_right = bx + BLOCK_WIDTH; + let b_bottom = by + (BLOCK_HEIGHT + BLOCK_HEADER_HEIGHT + BLOCK_PORT_HEIGHT); + (b_left, b_top, b_right, b_bottom) + } + + /// Prüft, ob der Block von einem Auswahlrechteck getroffen wird. + pub fn intersects_rect( + &self, + rect_start: (f32, f32), + rect_end: (f32, f32), + canvas_offset: (f32, f32), + ) -> bool { + let (b_left, b_top, b_right, b_bottom) = self.bounds(canvas_offset); + + // Rechteck normalisieren + let (x1, y1) = rect_start; + let (x2, y2) = rect_end; + let r_left = x1.min(x2); + let r_top = y1.min(y2); + let r_right = x1.max(x2); + let r_bottom = y1.max(y2); + + b_right >= r_left && + b_left <= r_right && + b_bottom >= r_top && + b_top <= r_bottom + } +} + + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct Connection { pub from: BlockId, pub to: BlockId, } - #[derive(Clone, Copy, PartialEq)] pub enum PortStatus { Valid, @@ -90,8 +154,6 @@ pub enum PortStatus { Inactive } - - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum BlockInstance { Input(Rc), diff --git a/frontend/src/app/components/source_editor/editor_view.rs b/frontend/src/app/components/source_editor/editor_view.rs index 2cde9c0b3..6123cb94f 100644 --- a/frontend/src/app/components/source_editor/editor_view.rs +++ b/frontend/src/app/components/source_editor/editor_view.rs @@ -1,14 +1,184 @@ -use std::rc::Rc; -use web_sys::{HtmlElement, MouseEvent}; -use yew::prelude::*; +use crate::app::components::source_editor::layout::layout; +use crate::app::components::{can_connect, Block, BlockId, BlockInstance, BlockType, BlockView, Connection, EditMode, InputRow, PortStatus, SourceEditorContext, SourceEditorForm, SourceEditorSidebar, BLOCK_HEADER_HEIGHT, BLOCK_HEIGHT, BLOCK_PORT_HEIGHT, BLOCK_WIDTH}; +use crate::app::PlaylistContext; use shared::model::{ConfigInputDto, ConfigTargetDto, HdHomeRunTargetOutputDto, M3uTargetOutputDto, StrmTargetOutputDto, TargetOutputDto, XtreamTargetOutputDto}; -use crate::app::components::{can_connect, Block, BlockId, BlockInstance, BlockType, BlockView, Connection, EditMode, PortStatus, SourceEditorContext, SourceEditorForm, SourceEditorSidebar}; +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; +use wasm_bindgen::JsCast; +use web_sys::{Element, HtmlElement, MouseEvent}; +use yew::prelude::*; -const BLOCK_WIDTH: f32 = 100.0; -const BLOCK_HEIGHT: f32 = 50.0; -const BLOCK_HEADER_HEIGHT: f32 = 12.0; -const BLOCK_PORT_HEIGHT: f32 = 10.0; -const BLOCK_MIDDLE_Y: f32 = (BLOCK_HEIGHT + BLOCK_HEADER_HEIGHT + BLOCK_PORT_HEIGHT) / 2.0; +const PENDING_LINE: &str = "pending-line"; +const SELECTION_RECT: &str = "selection-rect"; + +const BLOCK_MIDDLE_Y: f32 = (BLOCK_HEIGHT + BLOCK_HEADER_HEIGHT + BLOCK_PORT_HEIGHT) / 2.0; +const PORT_SNAP_THRESHOLD: f32 = 100.0; + +type Position = (f32, f32); +type MoveBlockParams = (f32, f32, Position, Vec<(BlockId, Position)>); + +#[derive(Clone, PartialEq)] +struct DragState { + block_id: Option, + drag_offset: Position, + sidebar_drag_offset: Position, + dragging_group: HashSet::, +} + +impl Default for DragState { + fn default() -> Self { + Self { + block_id: None, + drag_offset: (0.0f32, 0.0f32), + dragging_group: HashSet::::new(), + sidebar_drag_offset: (0.0f32, 0.0f32), + } + } +} + +impl DragState { + pub fn with_drag_block_offset(&mut self, block_id: BlockId, drag_offset: Position) { + self.block_id = Some(block_id); + self.drag_offset = (drag_offset.0, drag_offset.1); + } + + pub(crate) fn reset_dragging(&mut self) { + self.block_id = None; + self.drag_offset = (0.0f32, 0.0f32); + self.dragging_group.clear(); + self.sidebar_drag_offset = (0.0f32, 0.0f32); + } +} + +#[derive(Clone, PartialEq)] +struct SelectionState { + is_selecting: bool, + select_rect_elem: Option, + selection_rect: Option<(f32, f32, f32, f32)>, // x,y,w,h relative to canva; + selection_start: Position, + selected_blocks: HashSet::, + group_initial_positions: Vec::<(BlockId, Position)>, + group_anchor_mouse: Position, +} + +impl Default for SelectionState { + fn default() -> Self { + Self { + is_selecting: false, + selection_rect: None, + select_rect_elem: None, + selection_start: (0.0f32, 0.0f32), + selected_blocks: HashSet::::new(), + group_initial_positions: Vec::<(BlockId, Position)>::new(), + group_anchor_mouse: (0.0f32, 0.0f32), + } + } +} + +impl SelectionState { + pub fn reset_selection(&mut self) { + self.is_selecting = false; + if let Some(rect_elem) = &self.select_rect_elem { + rect_elem.style().set_property("display", "none").unwrap(); + } + self.select_rect_elem = None; + self.selection_rect = None; + self.selection_start = (0.0f32, 0.0f32); + self.selected_blocks.clear(); + self.group_initial_positions.clear(); + self.group_anchor_mouse = (0.0f32, 0.0f32); + } + + pub fn stop_selection(&mut self) { + self.is_selecting = false; + if let Some(rect_elem) = &self.select_rect_elem { + rect_elem.style().set_property("display", "none").unwrap(); + } + self.select_rect_elem = None; + self.selection_rect = None; + self.selection_start = (0.0f32, 0.0f32); + } + + pub fn with_selecting_start_rect_and_clear_blocks(&mut self, is_selecting: bool, + selection_start: Position, + selection_rect: Option<(f32, f32, f32, f32)>) { + self.is_selecting = is_selecting; + self.selection_rect = selection_rect; + self.selection_start = selection_start; + self.selected_blocks.clear(); + } + + pub fn with_selecting_start_and_rect(&mut self, is_selecting: bool, + selection_start: Position, + selection_rect: Option<(f32, f32, f32, f32)>) { + self.is_selecting = is_selecting; + self.selection_rect = selection_rect; + self.selection_start = selection_start; + } + + pub(crate) fn with_cleared_blocks(&mut self, block_id: BlockId) { + let updated_selections: HashSet = self.selected_blocks + .iter() + .filter(|&&id| id != block_id) + .map(|&id| if id > block_id { id - 1 } else { id }) + .collect(); + + self.selected_blocks = updated_selections; + } +} + +struct EditorState { + canvas_offset: Position, + pan_start: Position, + drag: DragState, + selection: SelectionState, + next_id: BlockId, + blocks: Vec::, + connections: Vec::, + block_elements: HashMap::, + connection_elements: HashMap::<(BlockId, BlockId), Element>, + pending_line_element: Option, + pending_line: Option<(Position, Position)>, + pending_connection: Option, + is_panning: bool, +} + +impl Default for EditorState { + fn default() -> Self { + Self { + canvas_offset: (0.0f32, 0.0f32), + pan_start: (0.0f32, 0.0f32), + drag: DragState::default(), + selection: SelectionState::default(), + next_id: 1, + blocks: Vec::::new(), + connections: Vec::::new(), + block_elements: HashMap::::new(), + connection_elements: HashMap::<(BlockId, BlockId), Element>::new(), + pending_line_element: None, + pending_line: None, + pending_connection: None, + is_panning: false, + } + } +} + +impl EditorState { + pub fn get_block(&self, block_id: BlockId) -> Option<&Block> { + self.blocks.get(block_id as usize - 1) + } + + pub fn get_block_mut(&mut self, block_id: BlockId) -> Option<&mut Block> { + self.blocks.get_mut(block_id as usize - 1) + } + + pub fn clear_pending(&mut self) { + self.pending_connection = None; + self.pending_line = None; + self.pending_line_element = None; + } +} fn create_instance(block_type: BlockType) -> BlockInstance { match block_type { @@ -17,7 +187,7 @@ fn create_instance(block_type: BlockType) -> BlockInstance { BlockType::Target => { let dto = ConfigTargetDto { name: String::new(), ..Default::default() }; BlockInstance::Target(Rc::new(dto)) - }, + } BlockType::OutputM3u => BlockInstance::Output(Rc::new(TargetOutputDto::M3u(M3uTargetOutputDto::default()))), BlockType::OutputXtream => BlockInstance::Output(Rc::new(TargetOutputDto::Xtream(XtreamTargetOutputDto::default()))), BlockType::OutputHdHomeRun => BlockInstance::Output(Rc::new(TargetOutputDto::HdHomeRun(HdHomeRunTargetOutputDto::default()))), @@ -25,39 +195,146 @@ fn create_instance(block_type: BlockType) -> BlockInstance { } } +fn create_block(block_id: BlockId, block_type: BlockType, instance: BlockInstance) -> Block { + Block { + id: block_id, + block_type, + position: (0.0, 0.0), + instance, + } +} + +fn create_output_instance(output: &TargetOutputDto) -> (BlockInstance, BlockType) { + match output { + TargetOutputDto::Xtream(dto) => (BlockInstance::Output(Rc::new(TargetOutputDto::Xtream(dto.clone()))), BlockType::OutputXtream), + TargetOutputDto::M3u(dto) => (BlockInstance::Output(Rc::new(TargetOutputDto::M3u(dto.clone()))), BlockType::OutputM3u), + TargetOutputDto::Strm(dto) => (BlockInstance::Output(Rc::new(TargetOutputDto::Strm(dto.clone()))), BlockType::OutputStrm), + TargetOutputDto::HdHomeRun(dto) => (BlockInstance::Output(Rc::new(TargetOutputDto::HdHomeRun(dto.clone()))), BlockType::OutputHdHomeRun), + } +} + // ----------------- Component ----------------- #[function_component] pub fn SourceEditor() -> Html { let canvas_ref = use_node_ref(); - let blocks = use_state(Vec::::new); - let connections = use_state(Vec::::new); - let next_id = use_state(|| 1usize); - + let playlist_ctx = use_context::().expect("Playlist context not found"); + let force_update = use_state(|| 0); // ----------------- virtual canvas offset ----------------- - let canvas_offset = use_state(|| (0.0f32, 0.0f32)); - let is_panning = use_state(|| false); - let pan_start = use_state(|| (0.0f32, 0.0f32)); - - // Dragging state - let dragging_block = use_state(|| None); - let drag_offset = use_state(|| (0.0f32, 0.0f32)); - let sidebar_drag_offset = use_state(|| (0.0f32, 0.0f32)); - - // Pending line for live connection - let pending_line = use_state(|| None); - let pending_connection = use_state(|| None ); - + let editor_state_ref = use_mut_ref(EditorState::default); // Delete mode toggle let delete_mode = use_state(|| false); - let cursor_grabbing = use_state(|| false); + { + let playlists = playlist_ctx.clone(); + let editor_state_ref = editor_state_ref.clone(); + let force_update = force_update.clone(); + use_effect_with(playlists.sources.clone(), move |sources| { + if let Some(entries) = sources.as_ref() { + let mut current_id = 1; + let mut gen_blocks = Vec::new(); + let mut gen_connections = Vec::new(); + for (inputs, targets) in entries.as_ref() { + let mut input_ids = vec![]; + for input_row in inputs { + match input_row.as_ref() { + InputRow::Input(input_config) => { + let input_id = current_id; + current_id += 1; + input_ids.push(input_id); + gen_blocks.push(create_block(input_id, + BlockType::from(input_config.input_type), + BlockInstance::Input(input_config.clone()))); + } + InputRow::Alias(_, _) => {} + } + } + for target_config in targets { + let target_id = current_id; + current_id += 1; + gen_blocks.push(create_block(target_id, BlockType::Target, BlockInstance::Target(target_config.clone()))); + input_ids.iter().for_each(|input_id| gen_connections.push(Connection { from: *input_id, to: target_id })); + + for output in &target_config.output { + let (block_instance, block_type) = create_output_instance(output); + let output_id = current_id; + current_id += 1; + let block = create_block(output_id, block_type, block_instance); + gen_blocks.push(block); + gen_connections.push(Connection { from: target_id, to: output_id }); + } + } + } + layout(&mut gen_blocks, &gen_connections); + { + let mut editor_state = editor_state_ref.borrow_mut(); + editor_state.blocks = gen_blocks; + editor_state.connections = gen_connections; + editor_state.next_id = current_id; + } + force_update.set(*force_update + 1) + } + + || {} + }); + } + + let collect_block_elements = { + let editor_state_ref = editor_state_ref.clone(); + + Callback::from(move |block_ids: HashSet| { + let mut editor_state = editor_state_ref.borrow_mut(); + if editor_state.block_elements.is_empty() { + if let Some(window) = web_sys::window() { + if let Some(document) = window.document() { + for block_id in &block_ids { + if let Some(el) = document.get_element_by_id(&format!("block-{block_id}")) { + let div = el.dyn_into::().unwrap(); + editor_state.block_elements.insert(*block_id, div); + } + + // find connections + let connections = { + let mut connections = HashMap::new(); + for conn in &editor_state.connections { + if *block_id == conn.from || *block_id == conn.to + || block_ids.contains(&conn.from) || block_ids.contains(&conn.to) { + if let Some(el) = document.get_element_by_id(&format!("conn-{}-{}", conn.from, conn.to)) { + let path_el = el.dyn_into::().unwrap(); + connections.insert((conn.from, conn.to), path_el); + } + } + } + connections + }; + for (key, elem) in connections { + editor_state.connection_elements.insert(key, elem); + } + } + } + } + } + }) + }; + + let handle_layout = { + let editor_state_ref = editor_state_ref.clone(); + let force_update = force_update.clone(); + Callback::from(move |_| { + let mut editor_state = editor_state_ref.borrow_mut(); + let connections = editor_state.connections.clone(); + layout(&mut editor_state.blocks, &connections); + + force_update.set(*force_update + 1); + }) + }; // ----------------- Drag Start from Sidebar ----------------- let handle_drag_start = { - let sidebar_drag_offset = sidebar_drag_offset.clone(); + let editor_state_ref = editor_state_ref.clone(); let cursor_grabbing = cursor_grabbing.clone(); Callback::from(move |e: DragEvent| { + editor_state_ref.borrow_mut().selection.reset_selection(); if let Some(target) = e.target_dyn_into::() { let block_type = target.get_attribute("data-block-type").unwrap_or_default(); e.data_transfer().unwrap().set_data("text/plain", &block_type).unwrap(); @@ -65,46 +342,47 @@ pub fn SourceEditor() -> Html { let rect = target.get_bounding_client_rect(); let offset_x = e.client_x() as f32 - rect.left() as f32; let offset_y = e.client_y() as f32 - rect.top() as f32; - sidebar_drag_offset.set((offset_x, offset_y)); + editor_state_ref.borrow_mut().drag.sidebar_drag_offset = (offset_x, offset_y); cursor_grabbing.set(true); } - } - )}; + }) + }; // ----------------- Drop on Canvas ----------------- let handle_drop = { - let blocks = blocks.clone(); - let next_id = next_id.clone(); + let editor_state_ref = editor_state_ref.clone(); let canvas_ref = canvas_ref.clone(); - let sidebar_drag_offset = sidebar_drag_offset.clone(); - let canvas_offset = canvas_offset.clone(); // <-- add canvas_offset let cursor_grabbing = cursor_grabbing.clone(); Callback::from(move |e: DragEvent| { e.prevent_default(); + e.stop_propagation(); cursor_grabbing.set(false); if let Some(canvas) = canvas_ref.cast::() { if let Ok(data) = e.data_transfer().unwrap().get_data("text/plain") { let rect = canvas.get_bounding_client_rect(); let mouse_x = e.client_x() as f32 - rect.left() as f32; let mouse_y = e.client_y() as f32 - rect.top() as f32; - let (offset_x, offset_y) = *sidebar_drag_offset; - let (ox, oy) = *canvas_offset; // <-- include canvas offset + let ((canvas_ox, canvas_oy), (offset_x, offset_y)) = { + let editor_state = editor_state_ref.borrow(); + (editor_state.canvas_offset, editor_state.drag.sidebar_drag_offset) + }; let block_type = BlockType::from(data.as_str()); - - let mut current_blocks = (*blocks).clone(); - current_blocks.push(Block { - id: *next_id, - block_type, - position: ( - mouse_x - offset_x - ox, // <-- subtract canvas offset - mouse_y - offset_y - oy - ), - instance: create_instance(block_type), - }); - blocks.set(current_blocks); - next_id.set(*next_id + 1); + { + let mut editor_state = editor_state_ref.borrow_mut(); + let next_id = editor_state.next_id; + editor_state.blocks.push(Block { + id: next_id, + block_type, + position: ( + mouse_x - offset_x - canvas_ox, // <-- subtract canvas offset + mouse_y - offset_y - canvas_oy + ), + instance: create_instance(block_type), + }); + editor_state.next_id += 1; + } } } }) @@ -115,115 +393,183 @@ pub fn SourceEditor() -> Html { let cursor_grabbing = cursor_grabbing.clone(); Callback::from(move |e: DragEvent| { cursor_grabbing.set(false); - e.prevent_default() + e.prevent_default(); + e.stop_propagation(); }) }; // ----------------- Connection logic ----------------- let handle_connection_start = { - let pending_connection = pending_connection.clone(); - let pending_line = pending_line.clone(); - let blocks = blocks.clone(); - let canvas_offset = canvas_offset.clone(); - Callback::from(move |from_id: usize| { - pending_connection.set(Some(from_id)); - if let Some(block) = (*blocks).iter().find(|b| b.id == from_id) { - let (ox, oy) = *canvas_offset; - let x = block.position.0 + BLOCK_WIDTH + ox; - let y = block.position.1 + BLOCK_MIDDLE_Y + oy; - pending_line.set(Some(((x, y), (x, y)))); + let editor_state_ref = editor_state_ref.clone(); + Callback::from(move |from_id: BlockId| { + let pending_line = { + let editor_state = editor_state_ref.borrow(); + if let Some(block) = editor_state.get_block(from_id) { + let (canvas_ox, canvas_oy) = editor_state.canvas_offset; + let x = block.position.0 + BLOCK_WIDTH + canvas_ox; + let y = block.position.1 + BLOCK_MIDDLE_Y + canvas_oy; + Some(((x, y), (x, y))) + } else { + None + } + }; + { + let mut editor_state = editor_state_ref.borrow_mut(); + editor_state.pending_connection = Some(from_id); + editor_state.pending_line = pending_line; } }) }; let handle_connection_drop = { - let pending_connection = pending_connection.clone(); - let connections = connections.clone(); - let pending_line = pending_line.clone(); - let blocks = blocks.clone(); - Callback::from(move |to_id: usize| { - if let Some(from_id) = *pending_connection { + let editor_state_ref = editor_state_ref.clone(); + Callback::from(move |to_id: BlockId| { + let pending_connection = editor_state_ref.borrow().pending_connection; + if let Some(from_id) = pending_connection { if from_id != to_id { - let current_blocks = (*blocks).clone(); - if let (Some(from_block), Some(to_block)) = ( - current_blocks.iter().find(|b| b.id == from_id), - current_blocks.iter().find(|b| b.id == to_id), - ) { - // ✅ Check connection rules before adding - if can_connect(from_block, to_block, &connections, &blocks) { - let mut current_connections = (*connections).clone(); - current_connections.push(Connection { from: from_id, to: to_id }); - connections.set(current_connections); - } else { - // ❌ (Optional) visual feedback - web_sys::console::log_1( - &format!( - "Connection from {:?} to {:?} not allowed", - from_block.block_type, to_block.block_type - ) - .into(), - ); + if let (Some(from_block), Some(to_block)) = { + let editor_state = editor_state_ref.borrow(); + (editor_state.get_block(from_id).cloned(), + editor_state.get_block(to_id).cloned()) + } { + // Check connection rules before adding + let connection = { + let editor_state = editor_state_ref.borrow(); + if can_connect(&from_block, &to_block, &editor_state.connections, &editor_state.blocks) { + Some(Connection { from: from_id, to: to_id }) + } else { + None + } + }; + if let Some(con) = connection { + editor_state_ref.borrow_mut().connections.push(con); } } } } - - // // Snap pending line end to target port - // if let Some(to_block) = (*blocks).iter().find(|b| b.id == to_id) { - // let x = to_block.position.0; - // let y = to_block.position.1 + BLOCK_MIDDLE_Y; - // if let Some(((from_x, from_y), _)) = *pending_line { - // pending_line.set(Some(((from_x, from_y), (x, y)))); - // } - // } - - pending_connection.set(None); - pending_line.set(None); + { + editor_state_ref.borrow_mut().clear_pending(); + } }) }; - // ----------------- Drag block logic ----------------- + // ----------------- Drag block logic ----------------- let handle_block_mouse_down = { - let dragging_block = dragging_block.clone(); - let drag_offset = drag_offset.clone(); + let editor_state_ref = editor_state_ref.clone(); let canvas_ref = canvas_ref.clone(); - let blocks = blocks.clone(); let cursor_grabbing = cursor_grabbing.clone(); - Callback::from(move |(block_id, e): (usize, MouseEvent)| { + Callback::from(move |(block_id, e): (BlockId, MouseEvent)| { e.prevent_default(); + e.stop_propagation(); + + if editor_state_ref.borrow().pending_line.is_some() { + return; + } + + let ctrl_key = e.ctrl_key(); if let Some(canvas) = canvas_ref.cast::() { cursor_grabbing.set(true); let rect = canvas.get_bounding_client_rect(); let mouse_x = e.client_x() as f32 - rect.left() as f32; let mouse_y = e.client_y() as f32 - rect.top() as f32; - if let Some(block) = (*blocks).iter().find(|b| b.id == block_id) { - drag_offset.set((mouse_x - block.position.0, mouse_y - block.position.1)); - dragging_block.set(Some(block_id)); + + let possible_block = editor_state_ref.borrow().get_block(block_id).cloned(); + let mut editor_state = editor_state_ref.borrow_mut(); + + if let Some(block) = possible_block { + // Prepare group + editor_state.selection.group_initial_positions.clear(); + editor_state.drag.dragging_group.clear(); + + // Neue Auswahllogik: + let (selected_blocks, new_selection) = { + let is_selected = editor_state.selection.selected_blocks.contains(&block_id); + + if is_selected && ctrl_key { + // Ctrl + Click on existing block -> remove from selection + editor_state.selection.selected_blocks.remove(&block_id); + (editor_state.selection.selected_blocks.clone(), None) + } else if !is_selected { + // Block not selected, select only this block + (HashSet::from([block_id]), Some(block_id)) + } else { + // The block is selected and Ctrl is not pressed -> the selection remains as is. + (editor_state.selection.selected_blocks.clone(), None) + } + }; + + // initial positions for drag + let mut initial_pos = Vec::new(); + for id in &selected_blocks { + if let Some(b) = editor_state.get_block(*id) { + initial_pos.push((*id, b.position)); + } + } + + editor_state.drag.dragging_group = selected_blocks.clone(); + editor_state.selection.group_initial_positions = initial_pos; + + // Drag-Offset calculation + editor_state.drag.with_drag_block_offset(block_id, (mouse_x - block.position.0, mouse_y - block.position.1)); + + // update selection + if let Some(block) = new_selection { + if !ctrl_key { + editor_state.selection.selected_blocks.clear(); + } + editor_state.selection.selected_blocks.insert(block); + } + + editor_state.selection.group_anchor_mouse = (mouse_x, mouse_y); } } }) }; - // ----------------- Canvas mouse down (start panning if clicking the canvas itself) ----------------- + + // ----------------- Canvas mouse down (start panning or marquee selection) ----------------- let handle_canvas_mouse_down = { - let is_panning = is_panning.clone(); - let pan_start = pan_start.clone(); + let editor_state_ref = editor_state_ref.clone(); let canvas_ref = canvas_ref.clone(); let cursor_grabbing = cursor_grabbing.clone(); Callback::from(move |e: MouseEvent| { - // Start panning with right mouse button if the click target is exactly the canvas element. - if e.button() == 2 { - if let Some(target) = e.target_dyn_into::() { - if let Some(canvas) = canvas_ref.cast::() { - let tag = target.tag_name().to_lowercase(); - // accept both the canvas
itself and its background as valid pan targets - if target.is_same_node(Some(&canvas)) || tag == "svg" { - e.prevent_default(); + let mouse_button = e.button(); + if mouse_button != 0 && mouse_button != 2 { + return; + } + if let Some(target) = e.target_dyn_into::() { + if let Some(canvas) = canvas_ref.cast::() { + let tag = target.tag_name().to_lowercase(); + if target.is_same_node(Some(&canvas)) || tag == "svg" { + e.prevent_default(); + e.stop_propagation(); + let mut editor_state = editor_state_ref.borrow_mut(); + if e.button() == 0 { // left button + if editor_state.selection.is_selecting { + editor_state.selection.reset_selection(); + } else { + // selection area mode + if let Some(rect_el) = canvas_ref.cast::() { + let rect = rect_el.get_bounding_client_rect(); + let mouse_x = e.client_x() as f32 - rect.left() as f32; + let mouse_y = e.client_y() as f32 - rect.top() as f32; + if e.ctrl_key() { + editor_state.selection + .with_selecting_start_and_rect(true, (mouse_x, mouse_y), Some((mouse_x, mouse_y, 0.0, 0.0))); + } else { + editor_state.selection + .with_selecting_start_rect_and_clear_blocks(true, (mouse_x, mouse_y), Some((mouse_x, mouse_y, 0.0, 0.0))); + } + } + } + } else if e.button() == 2 { // right button + editor_state.selection.reset_selection(); + // Right button panning cursor_grabbing.set(true); - is_panning.set(true); - pan_start.set((e.client_x() as f32, e.client_y() as f32)); + editor_state.is_panning = true; + editor_state.pan_start = (e.client_x() as f32, e.client_y() as f32); } } } @@ -231,85 +577,229 @@ pub fn SourceEditor() -> Html { }) }; - // ----------------- Mouse move for both pending line, block drag, and canvas panning ----------------- + let move_blocks = { + let editor_state_ref = editor_state_ref.clone(); + Callback::from(move |(mouse_x, mouse_y, offset, initial_positions): MoveBlockParams| { + { + let to_collect: HashSet = initial_positions.iter().map(|(block_id, _)| *block_id).collect(); + collect_block_elements.emit(to_collect); + } + + let mut moved_block_ids = HashSet::new(); + { + let (anchor_x, anchor_y) = offset; + let dx = mouse_x - anchor_x; + let dy = mouse_y - anchor_y; + + let mut editor_state = editor_state_ref.borrow_mut(); + for (id, (ix, iy)) in initial_positions { + if let Some(b) = editor_state.get_block_mut(id) { + b.position = (ix + dx, iy + dy); + moved_block_ids.insert(b.id); + } + } + } + + if !moved_block_ids.is_empty() { + let editor_state = editor_state_ref.borrow(); + let (canvas_ox, canvas_oy) = editor_state.canvas_offset; + + for block_id in &moved_block_ids { + if let Some(div) = editor_state.block_elements.get(block_id) { + if let Some(block) = editor_state.get_block(*block_id) { + let (x, y) = block.position; + div.style().set_property("transform", &format!("translate({}px,{}px)", x + canvas_ox, y + canvas_oy)).unwrap(); + } + } + } + + let mut move_connections = HashMap::::new(); + for conn in &editor_state.connections { + if moved_block_ids.contains(&conn.from) || moved_block_ids.contains(&conn.to) { + move_connections.insert(format!("conn-{}-{}", conn.from, conn.to), (conn.from, conn.to)); + } + } + for (from, to) in move_connections.values() { + if let Some(path_el) = editor_state.connection_elements.get(&(*from, *to)) { + if let (Some(from_block), Some(to_block)) = + (&editor_state.get_block(*from), &editor_state.get_block(*to)) { + let (d, _) = update_connection(canvas_ox, canvas_oy, from_block, to_block); + path_el.set_attribute("d", &d).unwrap(); + } + } + } + } + }) + }; + + // ----------------- Mouse move for pending line, block drag, canvas panning, marquee update ----------------- let handle_canvas_mouse_move = { - let pending_line = pending_line.clone(); - let dragging_block = dragging_block.clone(); - let drag_offset = drag_offset.clone(); - let blocks = blocks.clone(); + let editor_state_ref = editor_state_ref.clone(); let canvas_ref = canvas_ref.clone(); - let is_panning = is_panning.clone(); - let pan_start = pan_start.clone(); - let canvas_offset = canvas_offset.clone(); + let last_frame = RefCell::new(0.0); + let move_blocks = move_blocks.clone(); Callback::from(move |e: MouseEvent| { - // If currently panning the canvas, update the canvas offset and exit early. - if *is_panning { - let (start_x, start_y) = *pan_start; - let dx = e.client_x() as f32 - start_x; - let dy = e.client_y() as f32 - start_y; - let (ox, oy) = *canvas_offset; - canvas_offset.set((ox + dx, oy + dy)); - // Update pan start for smooth continuous panning - pan_start.set((e.client_x() as f32, e.client_y() as f32)); + let now = web_sys::js_sys::Date::now(); + if now - *last_frame.borrow() < 16.0 { return; } + *last_frame.borrow_mut() = now; + + let client_x = e.client_x(); + let client_y = e.client_y(); + + let is_panning = { + editor_state_ref.borrow().is_panning + }; + + if is_panning { + let initial_positions: Vec<(BlockId, Position)> = { + editor_state_ref.borrow().blocks.iter().map(|b| (b.id, b.position)).collect() + }; + + { + let mut editor_state = editor_state_ref.borrow_mut(); + let (start_x, start_y) = editor_state.pan_start; + let dx = client_x as f32 - start_x; + let dy = client_y as f32 - start_y; + let (canvas_ox, canvas_oy) = editor_state.canvas_offset; + editor_state.canvas_offset = (canvas_ox + dx, canvas_oy + dy); + editor_state.pan_start = (client_x as f32, client_y as f32); + }; + + move_blocks.emit((0.0, 0.0, (0.0, 0.0), initial_positions)); return; } if let Some(canvas) = canvas_ref.cast::() { let rect = canvas.get_bounding_client_rect(); - let mouse_x = e.client_x() as f32 - rect.left() as f32; - let mouse_y = e.client_y() as f32 - rect.top() as f32; + let mouse_x = client_x as f32 - rect.left() as f32; + let mouse_y = client_y as f32 - rect.top() as f32; - // Update pending line (snap to nearest port if close) - if let Some(((from_x, from_y), _)) = *pending_line { - let mut snapped = (mouse_x, mouse_y); - let (ox, oy) = *canvas_offset; - for block in (*blocks).iter() { - let port_x = block.position.0 + ox; - let port_y = block.position.1 + BLOCK_MIDDLE_Y + oy; - let dx = mouse_x - port_x; - let dy = mouse_y - port_y; - let dist = (dx * dx + dy * dy).sqrt(); - if dist < 10.0 { // Snap distance threshold - snapped = (port_x, port_y); + { + let mut editor_state = editor_state_ref.borrow_mut(); + // Pending line snap + if let Some(((from_x, from_y), _)) = editor_state.pending_line { + let mut snapped = (mouse_x, mouse_y); + let (canvas_ox, canvas_oy) = editor_state.canvas_offset; + for block in &editor_state.blocks { + if let Some(port_snap) = compute_port_snap_distance(block.position, mouse_x, mouse_y, canvas_ox, canvas_oy) { + snapped = port_snap; + break; + } + } + editor_state.pending_line = Some(((from_x, from_y), snapped)); + + if editor_state.pending_line_element.is_none() { + if let Some(window) = web_sys::window() { + if let Some(document) = window.document() { + if let Some(el) = document.get_element_by_id(PENDING_LINE) { + let line = el.dyn_into::().unwrap(); + editor_state.pending_line_element = Some(line); + } + } + } + } + + if let Some(line) = editor_state.pending_line_element.as_ref() { + update_line(line, from_x, from_y, snapped.0, snapped.1); } } - pending_line.set(Some(((from_x, from_y), snapped))); } - // Update dragging block - if let Some(block_id) = *dragging_block { - let (offset_x, offset_y) = *drag_offset; - let mut current_blocks = (*blocks).clone(); - if let Some(block) = current_blocks.iter_mut().find(|b| b.id == block_id) { - block.position = (mouse_x - offset_x, mouse_y - offset_y); + let (is_selecting, selection_start, canvas_offset) = { + let editor_state = editor_state_ref.borrow(); + (editor_state.selection.is_selecting, + editor_state.selection.selection_start, + editor_state.canvas_offset + ) + }; + + if is_selecting { + let (x,y,w,h) = compute_normalized_selection_rect(selection_start, mouse_x, mouse_y); + let ctrl_key = e.ctrl_key(); + + // Update selected_blocks: block intersects rect? + let selected_blocks: Vec = { + editor_state_ref.borrow().blocks.iter() + .filter(|b| b.intersects_rect((x, y), (x + w, y + h), canvas_offset)).map(|b| b.id).collect() + }; + + { + let mut editor_state = editor_state_ref.borrow_mut(); + if !ctrl_key { + editor_state.selection.selected_blocks.clear(); + } + editor_state.selection.selected_blocks.extend(selected_blocks); + + editor_state.selection.selection_rect = Some((x, y, w, h)); + if editor_state.selection.select_rect_elem.is_none() { + if let Some(window) = web_sys::window() { + if let Some(document) = window.document() { + if let Some(el) = document.get_element_by_id(SELECTION_RECT) { + editor_state.selection.select_rect_elem = el.dyn_into::().ok(); + } + } + } + }; + + if let Some(rect_div) = editor_state.selection.select_rect_elem.as_ref() { + update_selection_rect(rect_div, x, y, w, h); + } } - blocks.set(current_blocks); + } + + let to_move = { + let editor_state = editor_state_ref.borrow(); + // Update dragging block (Single or Group) + if let Some(block_id) = editor_state.drag.block_id { + // If the dragged block is member of a selection -> move group + if editor_state.drag.dragging_group.contains(&block_id) && !editor_state.selection.group_initial_positions.is_empty() { + Some((mouse_x, mouse_y, editor_state.selection.group_anchor_mouse, editor_state.selection.group_initial_positions.clone())) + } else { + // Single drag block + if let Some(block) = { + editor_state.get_block(block_id) + } { + let positions = vec![(block_id, block.position)]; + Some((mouse_x, mouse_y, editor_state.selection.group_anchor_mouse, positions)) + } else { + None + } + } + } else { + None + } + }; + if let Some(move_it) = to_move { + move_blocks.emit(move_it); } } }) }; - let handle_canvas_mouse_up = { - let dragging_block = dragging_block.clone(); - let is_panning = is_panning.clone(); + let editor_state_ref = editor_state_ref.clone(); let cursor_grabbing = cursor_grabbing.clone(); Callback::from(move |_e: MouseEvent| { + let mut editor_state = editor_state_ref.borrow_mut(); + editor_state.block_elements.clear(); + editor_state.connection_elements.clear(); // Stop any block dragging and stop panning - dragging_block.set(None); - is_panning.set(false); + if editor_state.drag.block_id.is_some() { + editor_state.drag.reset_dragging(); + } + editor_state.selection.stop_selection(); + editor_state.is_panning = false; cursor_grabbing.set(false); }) }; let handle_canvas_right_click = { - let pending_connection = pending_connection.clone(); - let pending_line = pending_line.clone(); + let editor_state_ref = editor_state_ref.clone(); Callback::from(move |e: MouseEvent| { e.prevent_default(); // prevent default browser context menu - pending_connection.set(None); - pending_line.set(None); + e.stop_propagation(); + editor_state_ref.borrow_mut().clear_pending(); }) }; @@ -319,38 +809,57 @@ pub fn SourceEditor() -> Html { Callback::from(move |_| delete_mode.set(!*delete_mode)) }; + // Deleting a Block means updating the following block ids, + // because a BlockId is the index in the blocks list. let handle_delete_block = { - let blocks = blocks.clone(); - let connections = connections.clone(); - Callback::from(move |block_id: usize| { - let mut current_blocks = (*blocks).clone(); - current_blocks.retain(|b| b.id != block_id); - blocks.set(current_blocks); + let editor_state_ref = editor_state_ref.clone(); + let force_update = force_update.clone(); + Callback::from(move |block_id: BlockId| { + let mut editor_state = editor_state_ref.borrow_mut(); + editor_state.blocks.retain(|b| b.id != block_id); + editor_state.connections.retain(|c| c.from != block_id && c.to != block_id); - let mut current_connections = (*connections).clone(); - current_connections.retain(|c| c.from != block_id && c.to != block_id); - connections.set(current_connections); + for block in editor_state.blocks.iter_mut() { + if block.id >= block_id { + block.id -= 1; + } + } + + // udpate connection ids + for conn in editor_state.connections.iter_mut() { + if conn.from >= block_id { + conn.from -= 1; + } + if conn.to >= block_id { + conn.to -= 1; + } + } + + let max_id = editor_state.blocks.iter().map(|b| b.id).max().unwrap_or(0); + editor_state.next_id = max_id + 1; + + editor_state.selection.with_cleared_blocks(block_id); + force_update.set(*force_update + 1); }) }; let handle_delete_connection = { - let connections = connections.clone(); - Callback::from(move |(from, to): (usize, usize)| { - let mut current_connections = (*connections).clone(); - current_connections.retain(|c| !(c.from == from && c.to == to)); - connections.set(current_connections); + let editor_state_ref = editor_state_ref.clone(); + Callback::from(move |(from, to): (BlockId, BlockId)| { + editor_state_ref.borrow_mut().connections.retain(|c| !(c.from == from && c.to == to)); }) }; let get_port_status = { |block: &Block| { - if let Some(from_id) = *pending_connection { - if let Some(from_block) = (*blocks).iter().find(|b| b.id == from_id) { - return if can_connect(from_block, block, &connections, &blocks) { + if let Some(from_id) = editor_state_ref.borrow().pending_connection { + let editor_state = editor_state_ref.borrow(); + if let Some(from_block) = editor_state.get_block(from_id) { + return if can_connect(from_block, block, &editor_state.connections, &editor_state.blocks) { PortStatus::Valid } else { PortStatus::Invalid - } + }; } } PortStatus::Inactive @@ -358,13 +867,11 @@ pub fn SourceEditor() -> Html { }; let form_changed = { - let blocks = blocks.clone(); + let editor_state_ref = editor_state_ref.clone(); Callback::<(BlockId, BlockInstance)>::from(move |(block_id, instance): (BlockId, BlockInstance)| { - let mut current_blocks = (*blocks).clone(); - if let Some(block) = current_blocks.iter_mut().find(|b| b.id == block_id) { - block.instance = instance; - } - blocks.set(current_blocks); + if let Some(block) = editor_state_ref.borrow_mut().get_block_mut(block_id) { + block.instance = instance; + } }) }; @@ -372,10 +879,12 @@ pub fn SourceEditor() -> Html { let handle_block_edit = { let edit_mode_set = edit_mode.clone(); - let blocks = blocks.clone(); - Callback::from(move |block_id: usize| { - if let Some(block) = (*blocks).iter().find(|b| b.id == block_id) { + let editor_state_ref = editor_state_ref.clone(); + Callback::from(move |block_id: BlockId| { + let mut editor_state = editor_state_ref.borrow_mut(); + if let Some(block) = editor_state.get_block(block_id) { edit_mode_set.set(EditMode::Active(block.clone())); + editor_state.selection.reset_selection(); } }) }; @@ -385,7 +894,20 @@ pub fn SourceEditor() -> Html { edit_mode: edit_mode.clone(), }; + let edited_block_id = match *edit_mode { + EditMode::Inactive => 0, + EditMode::Active(ref b) => b.id, + }; let grabbed = *cursor_grabbing; + + let editor_state = editor_state_ref.borrow(); + let ((canvas_off_x, canvas_off_y), select_rect_style, pending_line) = { + let canvas_offset = editor_state.canvas_offset; // Apply virtual canvas offset + let select_rect_style = editor_state.selection.selection_rect.as_ref().map( + |(x, y, w, h)| format!("left:{x}px; top:{y}px; width:{w}px; height:{h}px;")); + (canvas_offset, select_rect_style, editor_state.pending_line) + }; + // ----------------- Render ----------------- html! { context={editor_context}> @@ -395,12 +917,15 @@ pub fn SourceEditor() -> Html { delete_mode={*delete_mode} on_drag_start={handle_drag_start.clone()} on_toggle_delete={handle_toggle_delete_mode.clone()} + on_layout={handle_layout.clone()} /> // Canvas
Html { oncontextmenu={handle_canvas_right_click.clone()}> // SVG for connections - - { for (*connections).iter().filter_map(|c| { - let from_block = (*blocks).iter().find(|b| b.id == c.from)?; - let to_block = (*blocks).iter().find(|b| b.id == c.to)?; - let (ox, oy) = *canvas_offset; // Apply virtual canvas offset - let from_x = from_block.position.0 + BLOCK_WIDTH + ox; - let from_y = from_block.position.1 + BLOCK_MIDDLE_Y + oy; - let to_x = to_block.position.0 + ox; - let to_y = to_block.position.1 + BLOCK_MIDDLE_Y + oy; - let dx = to_x - from_x; - let ctrl = dx * 0.5; - let d = format!( - "M {} {} C {} {}, {} {}, {} {}", - from_x, from_y, - from_x + ctrl, from_y, - to_x - ctrl, to_y, - to_x, to_y - ); + + { for editor_state.connections.iter().filter_map(|c| { + let from_block = editor_state.get_block(c.from)?; + let to_block = editor_state.get_block(c.to)?; + let (d, (from_x, from_y, to_x, to_y)) = update_connection(canvas_off_x, canvas_off_y, from_block, to_block); Some(html! { - + { if *delete_mode { let mid_x = (from_x + to_x) / 2.0; let mid_y = (from_y + to_y) / 2.0; @@ -452,10 +966,10 @@ pub fn SourceEditor() -> Html { }) }) } - // Pending line (straight, yellow) - { if let Some(((x1, y1), (x2, y2))) = *pending_line { + // Pending line straight + { if let Some(((x1, y1), (x2, y2))) = pending_line { html! { - Html { } else { html!{} } } - // Render blocks - { for (*blocks).iter().map(|b|{ + // Selection rectangle overlay +
+ + // Render blocks with canvas offset + { for editor_state.blocks.iter().map(|b|{ let port_status = get_port_status(b); - let (ox, oy) = *canvas_offset; // Apply virtual offset to each block let mut shifted_block = b.clone(); - shifted_block.position = (b.position.0 + ox, b.position.1 + oy); + let block_id = shifted_block.id; + shifted_block.position = (b.position.0 + canvas_off_x, b.position.1 + canvas_off_y); + let is_block_selected = editor_state.selection.selected_blocks.contains(&block_id); html! { Html { > } } + +fn update_connection(ox: f32, oy: f32, from_block: &Block, to_block: &Block) -> (String, (f32, f32, f32, f32)) { + let from_x = from_block.position.0 + BLOCK_WIDTH + ox; + let from_y = from_block.position.1 + BLOCK_MIDDLE_Y + oy; + let to_x = to_block.position.0 + ox; + let to_y = to_block.position.1 + BLOCK_MIDDLE_Y + oy; + let dx = to_x - from_x; + let ctrl = dx * 0.5; + (format!( + "M {} {} C {} {}, {} {}, {} {}", + from_x, from_y, + from_x + ctrl, from_y, + to_x - ctrl, to_y, + to_x, to_y + ), (from_x, from_y, to_x, to_y)) +} + +fn update_line(line: &Element, from_x: f32, from_y: f32, to_x: f32, to_y: f32) { + line.set_attribute("x1", &from_x.to_string()).unwrap(); + line.set_attribute("y1", &from_y.to_string()).unwrap(); + line.set_attribute("x2", &to_x.to_string()).unwrap(); + line.set_attribute("y2", &to_y.to_string()).unwrap(); +} + +fn update_selection_rect(rect_div: &HtmlElement, x: f32, y: f32, w: f32, h: f32) { + let div_style = rect_div.style(); + div_style.set_property("display", "block").unwrap(); + div_style.set_property("left", &format!("{x}px")).unwrap(); + div_style.set_property("top", &format!("{y}px")).unwrap(); + div_style.set_property("width", &format!("{w}px")).unwrap(); + div_style.set_property("height", &format!("{h}px")).unwrap(); +} + +fn compute_normalized_selection_rect(selection_start: Position, mouse_x: f32, mouse_y: f32) -> (f32, f32, f32,f32) { + // compute normalized rect + let (start_x, start_y) = selection_start; + let x = start_x.min(mouse_x); + let y = start_y.min(mouse_y); + let w = (mouse_x - start_x).abs(); + let h = (mouse_y - start_y).abs(); + (x, y, w, h) +} + +fn compute_port_snap_distance(block_position: Position, mouse_x: f32, mouse_y: f32, canvas_ox: f32, canvas_oy: f32) -> Option { + let port_x = block_position.0 + canvas_ox; + let port_y = block_position.1 + BLOCK_MIDDLE_Y + canvas_oy; + let dx = mouse_x - port_x; + let dy = mouse_y - port_y; + let dist_sq = dx * dx + dy * dy; + if dist_sq < PORT_SNAP_THRESHOLD { + Some((port_x, port_y)) + } else { + None + } +} \ No newline at end of file diff --git a/frontend/src/app/components/source_editor/input_form.rs b/frontend/src/app/components/source_editor/input_form.rs index acdcddb45..4100ca505 100644 --- a/frontend/src/app/components/source_editor/input_form.rs +++ b/frontend/src/app/components/source_editor/input_form.rs @@ -1,6 +1,6 @@ use crate::app::components::config::HasFormData; use crate::app::components::select::Select; -use crate::app::components::{BlockInstance, Card, DropDownOption, DropDownSelection, EditMode, Panel, RadioButtonGroup, SourceEditorContext, TextButton}; +use crate::app::components::{BlockId, BlockInstance, Card, DropDownOption, DropDownSelection, EditMode, Panel, RadioButtonGroup, SourceEditorContext, TextButton}; use crate::{config_field_child, edit_field_bool, edit_field_number_i16, edit_field_number_u16, edit_field_text, edit_field_text_option, generate_form_reducer}; use shared::model::{ConfigInputDto, ConfigInputOptionsDto, InputFetchMethod, InputType, StagedInputDto}; use std::fmt::Display; @@ -101,7 +101,7 @@ generate_form_reducer!( #[derive(Properties, PartialEq, Clone)] pub struct ConfigInputViewProps { - pub(crate) block_id: usize, + pub(crate) block_id: BlockId, pub(crate) input: Option>, } diff --git a/frontend/src/app/components/source_editor/layout.rs b/frontend/src/app/components/source_editor/layout.rs new file mode 100644 index 000000000..108494bfe --- /dev/null +++ b/frontend/src/app/components/source_editor/layout.rs @@ -0,0 +1,251 @@ +use crate::app::components::{Block, BlockId, Connection}; +use std::collections::HashMap; + +const CANVAS_OFFSET: f32 = 10.0; + +const Y_GAP: f32 = 25.0; +const X_GAP: f32 = 50.0; + +const BLOCK_HEIGHT: f32 = 50.0; +const BLOCK_WIDTH: f32 = 200.0; + +struct TargetBlock { + id: BlockId, + outputs: Option>, + height: f32, + position: (f32, f32), +} + +impl TargetBlock { + pub fn new(id: BlockId, outputs: Option>) -> Self { + let height = Self::height(outputs.as_ref()); + TargetBlock { id, outputs, height, position: (0.0, 0.0) } + } + + fn height(outputs: Option<&Vec>) -> f32 { + if let Some(outs) = outputs { + let len = outs.len() as f32; + ((len * BLOCK_HEIGHT) + ((len - 1.0) * Y_GAP)).max(BLOCK_HEIGHT) + } else { + BLOCK_HEIGHT + } + } + + pub fn set_position(&mut self, x: f32, y: f32, blocks: &mut [Block]) { + self.position = (x, y); + let out_x = x + BLOCK_WIDTH + X_GAP; + let mut out_y = y; + if let Some(outputs) = self.outputs.as_ref() { + for out in outputs { + blocks[*out as usize -1].position = (out_x, out_y); + out_y += BLOCK_HEIGHT + Y_GAP; + } + } + blocks[self.id as usize - 1].position = (x, y + (self.height - BLOCK_HEIGHT) / 2.0); + } +} + +fn build_target_blocks(blocks: &mut [Block], connections: &[Connection]) -> Vec { + let mut out_edges: HashMap> = HashMap::new(); + let mut in_edges: HashMap> = HashMap::new(); + + for c in connections { + out_edges.entry(c.from).or_default().push(c.to); + in_edges.entry(c.to).or_default().push(c.from); + } + + blocks + .iter() + .filter(|b| b.block_type.is_target()) + .map(|b| TargetBlock::new(b.id, out_edges.get(&b.id).cloned())) + .collect() +} + + +/// calcuates Barycenter for a Block, based on connected Blocks in given Order-Array +fn barycenter(id: BlockId, map: &HashMap>, order: &[BlockId]) -> f32 { + if let Some(connected) = map.get(&id) { + let mut sum = 0.0; + let mut count = 0; + for &c in connected { + if let Some(pos) = order.iter().position(|&x| x == c) { + sum += pos as f32; + count += 1; + } + } + if count == 0 { f32::INFINITY } else { sum / count as f32 } + } else { + f32::INFINITY + } +} + +/// Counts crossings +fn count_crossings( + input_order: &[BlockId], + target_order: &[BlockId], + connections: &[Connection], +) -> usize { + let input_index: HashMap = + input_order.iter().enumerate().map(|(i, &id)| (id, i)).collect(); + let target_index: HashMap = + target_order.iter().enumerate().map(|(i, &id)| (id, i)).collect(); + + let mut count = 0; + for (i, c1) in connections.iter().enumerate() { + if !input_index.contains_key(&c1.from) || !target_index.contains_key(&c1.to) { + continue; + } + for c2 in &connections[i + 1..] { + if !input_index.contains_key(&c2.from) || !target_index.contains_key(&c2.to) { + continue; + } + let i1 = input_index[&c1.from]; + let j1 = target_index[&c1.to]; + let i2 = input_index[&c2.from]; + let j2 = target_index[&c2.to]; + + if (i1 < i2 && j1 > j2) || (i1 > i2 && j1 < j2) { + count += 1; + } + } + } + count +} + +/// Barycentric Sort +pub fn barycentric_sort( + blocks: &[Block], + connections: &[Connection], + iterations: usize, +) -> (Vec, Vec) { + // Initiale Reihenfolge + let mut input_order: Vec = blocks + .iter() + .filter(|b| b.block_type.is_input()) + .map(|b| b.id) + .collect(); + let mut target_order: Vec = blocks + .iter() + .filter(|b| b.block_type.is_target()) + .map(|b| b.id) + .collect(); + + let mut input_to_targets: HashMap> = HashMap::new(); + let mut target_to_inputs: HashMap> = HashMap::new(); + + for con in connections { + if blocks[con.from as usize - 1].block_type.is_input() + && blocks[con.to as usize - 1].block_type.is_target() + { + input_to_targets.entry(con.from).or_default().push(con.to); + target_to_inputs.entry(con.to).or_default().push(con.from); + } + } + + // Iterative Barycenter-Sortierung + for _ in 0..iterations { + // sort inputs by middle value of targets + input_order.sort_by(|&a, &b| { + barycenter(a, &input_to_targets, &target_order) + .partial_cmp(&barycenter(b, &input_to_targets, &target_order)) + .unwrap() + }); + + // sort targets by middle value of inputs + target_order.sort_by(|&a, &b| { + barycenter(a, &target_to_inputs, &input_order) + .partial_cmp(&barycenter(b, &target_to_inputs, &input_order)) + .unwrap() + }); + } + + // simple local cross optimisation for inputs + let mut improved = true; + for _ in 0..10 { + if !improved { break; } + improved = false; + for i in 0..input_order.len().saturating_sub(1) { + let mut swapped = input_order.clone(); + swapped.swap(i, i + 1); + if count_crossings(&swapped, &target_order, connections) < count_crossings(&input_order, &target_order, connections) { + input_order.swap(i, i + 1); + improved = true; + } + } + } + + // simple local cross optimisation for targets + improved = true; + for _ in 0..10 { + if !improved { break; } + improved = false; + for i in 0..target_order.len().saturating_sub(1) { + let mut swapped = target_order.clone(); + swapped.swap(i, i + 1); + if count_crossings(&input_order, &swapped, connections) < count_crossings(&input_order, &target_order, connections) { + target_order.swap(i, i + 1); + improved = true; + } + } + } + + (input_order, target_order) +} + +pub fn layout(blocks: &mut [Block], connections: &[Connection]) { + + let (input_order, target_order) = barycentric_sort(blocks, connections, 5); + + let mut target_blocks = build_target_blocks(blocks, connections); + target_blocks.sort_by_key(|a| target_order.iter().position(|&id| id == a.id).unwrap()); + + let mut start_y = CANVAS_OFFSET; + let start_x = CANVAS_OFFSET + BLOCK_WIDTH + X_GAP; + for target_block in &mut target_blocks { + target_block.set_position(start_x, start_y, blocks); + start_y += target_block.height + Y_GAP; + } + + let target_map: HashMap = target_blocks + .iter() + .map(|t| (t.id, t)) + .collect(); + + let mut last_input_y = CANVAS_OFFSET; + + for block_id in &input_order { + let connected_targets: Vec<&TargetBlock> = connections + .iter() + .filter(|c| c.from == *block_id) + .filter_map(|c| target_map.get(&c.to)) + .copied() + .collect(); + + let desired_y = if connected_targets.is_empty() { + last_input_y + } else { + // Center Y-Position of target + let min_y = connected_targets + .iter() + .map(|t| t.position.1) + .fold(f32::INFINITY, |a, b| a.min(b)); + + let max_y = connected_targets + .iter() + .map(|t| t.position.1 + t.height) + .fold(f32::NEG_INFINITY, |a, b| a.max(b)); + + (min_y + max_y)/2.0 - BLOCK_HEIGHT/2.0 + }; + + let mut final_y = desired_y; + // prevent overlap + if final_y < last_input_y { + final_y = last_input_y; + } + + let block = &mut blocks[*block_id as usize -1]; + block.position = (CANVAS_OFFSET, final_y); + last_input_y = final_y + BLOCK_HEIGHT + Y_GAP*2.0; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/source_editor/mod.rs b/frontend/src/app/components/source_editor/mod.rs index 358a2b400..352f80d99 100644 --- a/frontend/src/app/components/source_editor/mod.rs +++ b/frontend/src/app/components/source_editor/mod.rs @@ -6,8 +6,9 @@ mod input_form; mod target_form; mod output_form; mod editor_form; -mod block; +mod block_view; mod output_xtream_form; +mod layout; pub(crate) use editor_view::*; pub(crate) use editor_model::*; @@ -17,5 +18,5 @@ pub(crate) use input_form::*; pub(crate) use target_form::*; pub(crate) use output_form::*; pub(crate) use editor_form::*; -pub(crate) use block::*; +pub(crate) use block_view::*; pub(crate) use output_xtream_form::*; \ No newline at end of file diff --git a/frontend/src/app/components/source_editor/output_form.rs b/frontend/src/app/components/source_editor/output_form.rs index 1c8a316b0..1397099c8 100644 --- a/frontend/src/app/components/source_editor/output_form.rs +++ b/frontend/src/app/components/source_editor/output_form.rs @@ -1,11 +1,11 @@ -use crate::app::components::{BlockType, EditMode, SourceEditorContext, XtreamTargetOutputView}; +use crate::app::components::{BlockId, BlockType, EditMode, SourceEditorContext, XtreamTargetOutputView}; use shared::model::TargetOutputDto; use std::rc::Rc; use yew::{function_component, html, use_context, Html, Properties}; #[derive(Properties, PartialEq, Clone)] pub struct ConfigOutputViewProps { - pub(crate) block_id: usize, + pub(crate) block_id: BlockId, pub(crate) output: Option>, } diff --git a/frontend/src/app/components/source_editor/output_xtream_form.rs b/frontend/src/app/components/source_editor/output_xtream_form.rs index ae93974f0..78172360c 100644 --- a/frontend/src/app/components/source_editor/output_xtream_form.rs +++ b/frontend/src/app/components/source_editor/output_xtream_form.rs @@ -1,5 +1,5 @@ use crate::app::components::config::HasFormData; -use crate::app::components::{BlockInstance, Card, EditMode, Panel, SourceEditorContext, TextButton}; +use crate::app::components::{BlockId, BlockInstance, Card, EditMode, Panel, SourceEditorContext, TextButton}; use crate::{ edit_field_bool, edit_field_number_u16, edit_field_text_option, generate_form_reducer}; use shared::model::{ XtreamTargetOutputDto, TargetOutputDto}; use std::fmt::Display; @@ -60,7 +60,7 @@ generate_form_reducer!( #[derive(Properties, PartialEq, Clone)] pub struct XtreamTargetOutputViewProps { - pub(crate) block_id: usize, + pub(crate) block_id: BlockId, pub(crate) output: Option>, } diff --git a/frontend/src/app/components/source_editor/sidebar.rs b/frontend/src/app/components/source_editor/sidebar.rs index 7ea19bd79..39907e663 100644 --- a/frontend/src/app/components/source_editor/sidebar.rs +++ b/frontend/src/app/components/source_editor/sidebar.rs @@ -39,6 +39,8 @@ pub struct SourceEditorSidebarProps { pub on_toggle_delete: Callback<(String, MouseEvent)>, #[prop_or_default] pub on_drag_start: Callback, + #[prop_or_default] + pub on_layout: Callback<(String, MouseEvent)>, } #[function_component] @@ -49,6 +51,7 @@ pub fn SourceEditorSidebar(props: &SourceEditorSidebarProps) -> Html { // Sidebar
+ // Delete mode toggle button
diff --git a/frontend/src/app/components/source_editor/target_form.rs b/frontend/src/app/components/source_editor/target_form.rs index 808dd637c..05a5742fe 100644 --- a/frontend/src/app/components/source_editor/target_form.rs +++ b/frontend/src/app/components/source_editor/target_form.rs @@ -1,6 +1,6 @@ use crate::app::components::config::HasFormData; use crate::app::components::select::Select; -use crate::app::components::{BlockInstance, Card, ClusterFlagsInput, DropDownOption, DropDownSelection, EditMode, Panel, SourceEditorContext, TextButton}; +use crate::app::components::{BlockId, BlockInstance, Card, ClusterFlagsInput, DropDownOption, DropDownSelection, EditMode, Panel, SourceEditorContext, TextButton}; use crate::{config_field_child, edit_field_bool, edit_field_list_option, edit_field_text, generate_form_reducer}; use shared::model::{ClusterFlags, ConfigTargetDto, ConfigTargetOptions, ProcessingOrder}; use std::fmt::Display; @@ -69,7 +69,7 @@ generate_form_reducer!( #[derive(Properties, PartialEq, Clone)] pub struct ConfigTargetViewProps { - pub(crate) block_id: usize, + pub(crate) block_id: BlockId, pub(crate) target: Option>, } diff --git a/frontend/src/hooks/use_server_status.rs b/frontend/src/hooks/use_server_status.rs index cc0779683..0b4b99a00 100644 --- a/frontend/src/hooks/use_server_status.rs +++ b/frontend/src/hooks/use_server_status.rs @@ -37,9 +37,6 @@ pub fn use_server_status( }; match event { - ActiveUserConnectionChange::Connected(stream_info) => { - server_status.active_user_streams.push(stream_info); - } ActiveUserConnectionChange::Updated(stream_info) => { if let Some(pos) = server_status .active_user_streams diff --git a/frontend/src/model/event_message.rs b/frontend/src/model/event_message.rs index 4b536c29a..d5ff761fb 100644 --- a/frontend/src/model/event_message.rs +++ b/frontend/src/model/event_message.rs @@ -9,7 +9,8 @@ pub enum EventMessage { ServerError(String), ServerStatus(Rc), ActiveUser(ActiveUserConnectionChange), - ActiveProvider(String, usize), + ActiveProvider(String, usize), // single provider + ActiveProviderCount(usize), // all provider ConfigChange(ConfigType), Busy(BusyStatus), PlaylistUpdate(PlaylistUpdateState), diff --git a/frontend/src/services/auth_service.rs b/frontend/src/services/auth_service.rs index 737e276be..010ff10ad 100644 --- a/frontend/src/services/auth_service.rs +++ b/frontend/src/services/auth_service.rs @@ -1,14 +1,13 @@ use std::cell::RefCell; -use super::{get_base_href, request_post}; -use crate::services::requests::set_token; +use super::{check_dummy_token, get_base_href, request_post, set_token}; use futures_signals::signal::Mutable; use futures_signals::signal::SignalExt; use shared::model::{Claims, TokenResponse, UserCredential, ROLE_ADMIN, ROLE_USER, TOKEN_NO_AUTH}; use std::future::Future; use shared::utils::{concat_path, concat_path_leading_slash}; use base64::{engine::general_purpose, Engine as _}; -use log::warn; -use crate::error::{Error, Error::BadRequest, Error::NotFound}; +use log::{warn}; +use crate::error::{Error, Error::Unauthorized}; fn decode_jwt_payload(token: &str) -> Option { let payload_enc = token.split('.').nth(1)?; @@ -64,21 +63,10 @@ impl AuthService { self.auth_channel.set(false); } - fn no_auth(&self, err: Error) -> Result { - if matches!(err, BadRequest(_)) { - self.username.replace("admin".to_string()); - self.auth_channel.set(true); - set_token(Some(TOKEN_NO_AUTH)); - self.roles.borrow_mut().push(ROLE_ADMIN.to_string()); - Ok(TokenResponse { - token: TOKEN_NO_AUTH.to_string(), - username: "admin".to_string(), - }) - } else { - self.auth_channel.set(false); - set_token(None); - Err(err) - } + fn unauthorized(&self) -> Result { + self.auth_channel.set(false); + set_token(None); + Err(Unauthorized) } pub async fn authenticate(&self, username: String, password: String) -> Result { @@ -94,12 +82,12 @@ impl AuthService { self.handle_token(&token.token); Ok(token) } - Ok(None) => self.no_auth(NotFound), - Err(e) => self.no_auth(e), + _ => self.unauthorized(), } } pub async fn refresh(&self) -> Result { + check_dummy_token(); match request_post::<(), TokenResponse>(&concat_path(&self.auth_path, "refresh"), (), None, None).await { Ok(Some(token)) => { self.username.replace(token.username.to_string()); @@ -108,8 +96,7 @@ impl AuthService { self.handle_token(&token.token); Ok(token) } - Ok(None) => self.no_auth(NotFound), - Err(e) => self.no_auth(e), + _ => self.unauthorized(), } } @@ -120,7 +107,7 @@ impl AuthService { if token == TOKEN_NO_AUTH { roles.push(ROLE_ADMIN.to_string()); } - + if let Some(claims) = decode_jwt_payload(token) { for role in claims.roles.iter() { roles.push(role.clone()); diff --git a/frontend/src/services/requests.rs b/frontend/src/services/requests.rs index 3f8dcf3c4..2fb978b22 100644 --- a/frontend/src/services/requests.rs +++ b/frontend/src/services/requests.rs @@ -1,11 +1,11 @@ use crate::error::{Error, ErrorInfo, ErrorSetInfo}; use gloo_storage::{LocalStorage, Storage}; -use log::{error}; +use log::error; use reqwasm::http::Request; use serde::{de::DeserializeOwned, Serialize}; use serde_json::Value; -use web_sys::window; use shared::utils::bin_deserialize; +use web_sys::window; const CONTENT_TYPE_BIN: &str = "application/cbor"; const CONTENT_TYPE_JSON: &str = "application/json"; @@ -32,6 +32,16 @@ pub fn set_token(token: Option<&str>) { } } +const DUMMY_TOKEN: &str = "eyJraWQiOiJkZWZhdWx0IiwiYWxnIjoiUlMyNTYifQ.eyJsb2dpbiI6ImR1bW15In0.WWzZP0hICmJeIgMLVYNOpayriEC08J_lYssk9z8GglHXfZ6oJUDv3svlJDA8sQG025VA_LR5UzyyiWeQDCdpWyrCI_nI2Xd-3ga3JwWtxHE9NWFalgq0Q9jjxoB4LYWCXsAkqoZqk6s7b3F5Fi_h5oYHfwM4h8hXEbrgnJ_Z1wpSc7HNh6SUnOllxcaJOxYlRrlUn3XulSSf2NhHe3XotvFguiIV1-RIns3cSIL29bvMUEFw84w7BfJn-joynZsWlfJBvzyOiuDqduXa0deH7b962unM2wPpbvTgliJhFFOUBHClRhBOmoo0cijuMZB4K7NjgjGmU5eVfHG6pVWs_b0ikS4V_P6RJcNS6Alcc_HB_YXv0yCD3pjcBbuRXAskivEhgXuecdRMGQgohAhXplLuu5SR0K6Bcrt7UFFnBi2qN6fbw1i4s8PDXqiTu4rIg9agCkVNfplRvj8Szl6egF0Vd1TN1WGEarkdINEUyfNQkAihFY5BKxfaPun1-a0VydRMZElu6VzrrUMxXt4T7zybuJZI63C3mKLEHZixdSC76c9AE-zGom5LZYE4mqwd4dW3QHtWFZgGZiL9C_VBIf63WzjTYhWVuO2U8O9bsKkSEl5L-Ww9j8ccDHp5nc7y6yUgSYd600TBRI7WblFovLsl2tjElvUqfJZhj6JmX_Q"; + +// The Authorization header is for the Backend Authenticator mandatory. +// If we don't set a dummy token the backend api cant be called. +pub fn check_dummy_token() { + if get_token().is_none() { + set_token(Some(DUMMY_TOKEN)); + } +} + /// build all kinds of http request: post/get/delete etc. async fn request(method: RequestMethod, url: &str, body: B, content_type: Option, response_type: Option) -> Result, Error> @@ -43,14 +53,14 @@ where let r_type = response_type.as_ref().map_or(CONTENT_TYPE_JSON, |c| c.as_str()); let mut request = match method { RequestMethod::Get => Request::get(url), - RequestMethod::Post => { + RequestMethod::Post => { let json = serde_json::to_string(&body).map_err(|_| Error::RequestError)?; Request::post(url).body(json).header("Content-Type", c_type) - }, + } RequestMethod::Put => { let json = serde_json::to_string(&body).map_err(|_| Error::RequestError)?; Request::put(url).body(json).header("Content-Type", c_type) - }, + } // RequestMethod::PATCH => Request::patch(&url).body(serde_json::to_string(&body).unwrap()), RequestMethod::Delete => Request::delete(url), }; @@ -85,7 +95,7 @@ where Err(err) => { error!("Failed to deserialize {err}"); Err(Error::DeserializeError) - }, + } } } Err(err) => { diff --git a/frontend/src/services/websocket_service.rs b/frontend/src/services/websocket_service.rs index 95acdb7e8..5345e18df 100644 --- a/frontend/src/services/websocket_service.rs +++ b/frontend/src/services/websocket_service.rs @@ -1,25 +1,48 @@ -use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; use web_sys::{WebSocket, MessageEvent, Event, ErrorEvent, CloseEvent}; use std::cell::RefCell; use std::rc::Rc; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU16, Ordering}; use web_sys::js_sys::{Uint8Array, ArrayBuffer}; -use log::{debug, error, trace}; +use log::{error, trace, warn}; use shared::model::{ProtocolMessage, PROTOCOL_VERSION}; use shared::utils::{concat_path_leading_slash}; use crate::model::EventMessage; use crate::services::{get_base_href, get_token, EventService, StatusService}; use crate::utils::set_timeout; +use wasm_bindgen::closure::Closure; -const WS_RECONNECT_MS:i32 = 2000; +const WS_RECONNECT_BASE_MS: u32 = 300; +const WS_RECONNECT_MAX_MS: u32 = 2000; +const WS_RECONNECT_MAX_ATTEMPTS: u32 = 20; + +fn reconnect_delay(attempt: u32) -> u32 { + if attempt < 6 { + let d = WS_RECONNECT_BASE_MS * (attempt+1); + d.min(WS_RECONNECT_MAX_MS) + } else { + WS_RECONNECT_MAX_MS + } +} + +type JsOnMessageCallback = Option>; +type JsOnCloseCallback = Option>; +type JsOnErrorCallback = Option>; +type JsOnOpenCallback = Option>; pub struct WebSocketService { connected: Rc, + attempt_counter: Rc, ws: Rc>>, status_service: Rc, event_service: Rc, ws_path: String, + + // store closures so they live as long as we want and can be dropped on close + ws_onmessage: Rc>, + ws_onopen: Rc>, + ws_onclose: Rc>, + ws_onerror: Rc>, } impl WebSocketService { @@ -27,24 +50,35 @@ impl WebSocketService { let base_href = get_base_href(); Self { connected: Rc::new(AtomicBool::new(false)), + attempt_counter: Rc::new(AtomicU16::new(0)), ws: Rc::new(RefCell::new(None)), status_service, event_service, ws_path: concat_path_leading_slash(&base_href, "ws"), + ws_onmessage: Rc::new(RefCell::new(None)), + ws_onopen: Rc::new(RefCell::new(None)), + ws_onclose: Rc::new(RefCell::new(None)), + ws_onerror: Rc::new(RefCell::new(None)), } } + /// Helper function to allow cloning the service into JS closures for reconnect fn clone_for_reconnect(&self) -> Self { Self { connected: self.connected.clone(), + attempt_counter: self.attempt_counter.clone(), ws: self.ws.clone(), status_service: self.status_service.clone(), event_service: self.event_service.clone(), ws_path: self.ws_path.clone(), + ws_onmessage: self.ws_onmessage.clone(), + ws_onopen: self.ws_onopen.clone(), + ws_onclose: self.ws_onclose.clone(), + ws_onerror: self.ws_onerror.clone(), } } - pub fn connect_ws(&self) { + pub fn connect_ws_with_backoff(&self) { if self.connected.load(Ordering::SeqCst) { return; } @@ -54,119 +88,125 @@ impl WebSocketService { socket.set_binary_type(web_sys::BinaryType::Arraybuffer); let ws_clone = self.ws.clone(); *ws_clone.borrow_mut() = Some(socket.clone()); - let event_service = self.event_service.clone(); - let ws_onmessage_clone = Rc::clone(&ws_clone); // onmessage - let onmessage_callback = Closure::::wrap(Box::new(move |event: MessageEvent| { - trace!("WebSocket received message: {event:?}"); - if let Ok(buf) = event.data().dyn_into::() { - let array = Uint8Array::new(&buf); - let bytes = bytes::Bytes::from(array.to_vec()); - match ProtocolMessage::from_bytes(bytes) { - Ok(message) => { - match message { - ProtocolMessage::Unauthorized => { - event_service.broadcast(EventMessage::Unauthorized); - }, - ProtocolMessage::Error(err) => { - error!("{err}"); - }, - ProtocolMessage::ActiveUserResponse(event) => { - event_service.broadcast(EventMessage::ActiveUser(event)); - }, - ProtocolMessage::ActiveProviderResponse(user_count, connections) => { - event_service.broadcast(EventMessage::ActiveProvider(user_count, connections)); - }, - ProtocolMessage::StatusResponse(status) => { - let data = Rc::new(status); - event_service.broadcast(EventMessage::ServerStatus(data)); - } - ProtocolMessage::ConfigChangeResponse(config_type) => { - if !event_service.is_config_change_message_blocked() { - event_service.broadcast(EventMessage::ConfigChange(config_type)); - } - } - ProtocolMessage::ServerError(error) => { - event_service.broadcast(EventMessage::ServerError(error)); - } - ProtocolMessage::PlaylistUpdateResponse(update_state) => { - event_service.broadcast(EventMessage::PlaylistUpdate(update_state)); - } - ProtocolMessage::PlaylistUpdateProgressResponse(target, msg) => { - event_service.broadcast(EventMessage::PlaylistUpdateProgress(target, msg)); - } - ProtocolMessage::Version(_) => { - if let Some(token) = get_token() { - Self::try_send_message(ws_onmessage_clone.borrow().as_ref(), ProtocolMessage::Auth(token)); - } - } - ProtocolMessage::UserActionResponse(_success) => { - // Success is already handled in the UI component that initiated the action - } - ProtocolMessage::Auth(_) - | ProtocolMessage::Authorized - | ProtocolMessage::StatusRequest(_) - | ProtocolMessage::UserAction(_) => {} - } - } - Err(err) => error!("Failed to decode websocket message: {err}") + { + let ws_onmessage_ref = self.ws_onmessage.clone(); + let ws_onmessage_clone = ws_clone.clone(); + let event_service = self.event_service.clone(); + let attempt_counter = self.attempt_counter.clone(); + let onmessage_callback = Closure::::wrap(Box::new(move |event: MessageEvent| { + trace!("WebSocket received message: {event:?}"); + if let Some(response) = handle_socket_protocol_msg(event, &event_service, &attempt_counter) { + Self::try_send_message(ws_onmessage_clone.borrow().as_ref(), response); } - } - })); - socket.set_onmessage(Some(onmessage_callback.as_ref().unchecked_ref())); - onmessage_callback.forget(); // Important: leak the closure to keep it alive + })); + socket.set_onmessage(Some(onmessage_callback.as_ref().unchecked_ref())); + // store closure so we can drop it later (no forget) + ws_onmessage_ref.borrow_mut().replace(onmessage_callback); + } - let ws_open_clone = Rc::clone(&ws_clone); - let connected_clone = self.connected.clone(); - let event_service_clone = Rc::clone(&self.event_service); // onopen - let onopen_callback = Closure::::wrap(Box::new(move |_event: Event| { - trace!("WebSocket connection opened."); - connected_clone.store(true, Ordering::SeqCst); - Self::try_send_message(ws_open_clone.borrow().as_ref(), ProtocolMessage::Version(PROTOCOL_VERSION)); - event_service_clone.broadcast(EventMessage::WebSocketStatus(true)); - })); - socket.set_onopen(Some(onopen_callback.as_ref().unchecked_ref())); - onopen_callback.forget(); + { + let ws_onopen_ref = self.ws_onopen.clone(); + let ws_open_clone = ws_clone.clone(); + let connected_clone = self.connected.clone(); + let onopen_callback = Closure::::wrap(Box::new(move |_event: Event| { + // on open is called on a connect attempt, it does not mean it is connected! + trace!("WebSocket connection opened."); + if Self::try_send_message(ws_open_clone.borrow().as_ref(), ProtocolMessage::Version(PROTOCOL_VERSION)) { + connected_clone.store(true, Ordering::SeqCst); + } + })); + socket.set_onopen(Some(onopen_callback.as_ref().unchecked_ref())); + ws_onopen_ref.borrow_mut().replace(onopen_callback); + } - let ws_close_rc = self.ws.clone(); - let connected_clone = self.connected.clone(); + // prepare a reconnect-holder (shared clone) let ws_service_reconnect = Rc::new(self.clone_for_reconnect()); - let event_service_clone = Rc::clone(&self.event_service); - let onclose_callback = Closure::::wrap(Box::new(move |e: CloseEvent| { - debug!("Websocket closed, Code: {}, Reason: {}, WasClean: {}", e.code(), e.reason(), e.was_clean()); - *ws_close_rc.borrow_mut() = None; - connected_clone.store(false, Ordering::SeqCst); - event_service_clone.broadcast(EventMessage::WebSocketStatus(false)); - // schedule reconnect after 3 seconds - let ws_service_inner = ws_service_reconnect.clone(); - set_timeout(move || { ws_service_inner.connect_ws(); }, WS_RECONNECT_MS); - })); - socket.set_onclose(Some(onclose_callback.as_ref().unchecked_ref())); - onclose_callback.forget(); + // onclose + { + let ws_onclose_ref = self.ws_onclose.clone(); + let ws_onmessage_ref = self.ws_onmessage.clone(); + let ws_onopen_ref = self.ws_onopen.clone(); + let ws_onerror_ref = self.ws_onerror.clone(); + let ws_close_rc = self.ws.clone(); + let connected_clone = self.connected.clone(); + let event_service_clone = Rc::clone(&self.event_service); + let ws_service_reconnect_clone = Rc::clone(&ws_service_reconnect); + + let onclose_callback = Closure::::wrap(Box::new(move |e: CloseEvent| { + trace!( + "WebSocket closed (Code {}, Reason: {}, Clean: {})", + e.code(), e.reason(), e.was_clean() + ); + + // clear JS handlers on the underlying socket if present + if let Some(s) = ws_close_rc.borrow().as_ref() { + s.set_onmessage(None); + s.set_onopen(None); + s.set_onerror(None); + s.set_onclose(None); + } + + // drop stored closures so they are freed + ws_onmessage_ref.borrow_mut().take(); + ws_onopen_ref.borrow_mut().take(); + ws_onerror_ref.borrow_mut().take(); + // Note: we keep ws_onclose alive (this closure) until function returns; + // it will be dropped when the service or field is taken elsewhere if desired. + + *ws_close_rc.borrow_mut() = None; + connected_clone.store(false, Ordering::SeqCst); + event_service_clone.broadcast(EventMessage::WebSocketStatus(false)); + + // schedule reconnect + ws_service_reconnect_clone.schedule_reconnect(); + })); + socket.set_onclose(Some(onclose_callback.as_ref().unchecked_ref())); + ws_onclose_ref.borrow_mut().replace(onclose_callback); + } - let connected_clone = self.connected.clone(); - let ws_service_reconnect = Rc::new(self.clone_for_reconnect()); - let event_service_clone = Rc::clone(&self.event_service); // onerror - let onerror_callback = Closure::::wrap(Box::new(move |e: ErrorEvent| { - error!("WebSocket error"); - connected_clone.store(false, Ordering::SeqCst); - event_service_clone.broadcast(EventMessage::WebSocketStatus(false)); - web_sys::console::error_1(&e); + { + let ws_onerror_ref = self.ws_onerror.clone(); + let connected_clone = self.connected.clone(); + let event_service_clone = Rc::clone(&self.event_service); + //let ws_service_reconnect_clone = Rc::clone(&ws_service_reconnect); - // schedule reconnect after 3 seconds - let ws_service_inner = ws_service_reconnect.clone(); - set_timeout(move || { ws_service_inner.connect_ws(); }, WS_RECONNECT_MS); - })); - socket.set_onerror(Some(onerror_callback.as_ref().unchecked_ref())); - onerror_callback.forget(); + let onerror_callback = Closure::::wrap(Box::new(move |e: ErrorEvent| { + error!("WebSocket error: {:?}", e); + connected_clone.store(false, Ordering::SeqCst); + event_service_clone.broadcast(EventMessage::WebSocketStatus(false)); + // ws_service_reconnect_clone.schedule_reconnect(); + })); + socket.set_onerror(Some(onerror_callback.as_ref().unchecked_ref())); + ws_onerror_ref.borrow_mut().replace(onerror_callback); + } } } } + fn schedule_reconnect(&self) { + // increment attempts atomically and get the previous value + let attempt = self.attempt_counter.fetch_add(1, Ordering::SeqCst) as u32; + + if attempt >= WS_RECONNECT_MAX_ATTEMPTS { + warn!("WebSocket reconnect attempts exceeded ({attempt}). Giving up."); + return; + } + let delay = reconnect_delay(attempt); + + warn!("WebSocket reconnect attempt #{attempt} scheduled in {} ms", delay); + + // clone_for_reconnect returns a Service with shared internals (Atomic, etc.) + let ws_clone = Rc::new(self.clone_for_reconnect()); + set_timeout(move || { + ws_clone.connect_ws_with_backoff(); + }, delay as i32); + } + fn try_send_message(ws_opt: Option<&WebSocket>, msg: ProtocolMessage) -> bool { if let Some(ws) = ws_opt { match msg.to_bytes() { @@ -205,30 +245,71 @@ impl WebSocketService { Err(err) => {error!("Failed to get server status: {err:?}");} } } - - - // TODO - // on no wesocket connection - - // let fetch_status = { - // let status = status_signal.clone(); - // let services_ctx = services_ctx.clone(); - // move || { - // let status = status.clone(); - // let services_ctx = services_ctx.clone(); - // spawn_local(async move { - // status.set(services_ctx.status.get_server_status().await.ok()); - // }); - // } - // }; - // - // fetch_status(); - // // all 5 seconds - // let interval = Interval::new(5000, move || { - // fetch_status(); - // }); - // - // // Cleanup function - // || drop(interval) } -} \ No newline at end of file +} + +fn handle_socket_protocol_msg(event: MessageEvent, event_service: &Rc, attempt_counter: &Rc) -> Option{ + if let Ok(buf) = event.data().dyn_into::() { + let array = Uint8Array::new(&buf); + let bytes = bytes::Bytes::from(array.to_vec()); + match ProtocolMessage::from_bytes(bytes) { + Ok(message) => { + match message { + ProtocolMessage::Unauthorized => { + event_service.broadcast(EventMessage::Unauthorized); + }, + ProtocolMessage::Error(err) => { + error!("{err}"); + }, + ProtocolMessage::ActiveUserResponse(event) => { + event_service.broadcast(EventMessage::ActiveUser(event)); + }, + ProtocolMessage::ActiveProviderResponse(provider_name, connections) => { + event_service.broadcast(EventMessage::ActiveProvider(provider_name, connections)); + if let Some(token) = get_token() { + return Some(ProtocolMessage::ActiveProviderCountRequest(token)); + } + }, + ProtocolMessage::ActiveProviderCountResponse(connections) => { + event_service.broadcast(EventMessage::ActiveProviderCount(connections)); + } + ProtocolMessage::StatusResponse(status) => { + let data = Rc::new(status); + event_service.broadcast(EventMessage::ServerStatus(data)); + } + ProtocolMessage::ConfigChangeResponse(config_type) => { + if !event_service.is_config_change_message_blocked() { + event_service.broadcast(EventMessage::ConfigChange(config_type)); + } + } + ProtocolMessage::ServerError(error) => { + event_service.broadcast(EventMessage::ServerError(error)); + } + ProtocolMessage::PlaylistUpdateResponse(update_state) => { + event_service.broadcast(EventMessage::PlaylistUpdate(update_state)); + } + ProtocolMessage::PlaylistUpdateProgressResponse(target, msg) => { + event_service.broadcast(EventMessage::PlaylistUpdateProgress(target, msg)); + } + ProtocolMessage::Version(_) => { + attempt_counter.store(0, Ordering::SeqCst); + event_service.broadcast(EventMessage::WebSocketStatus(true)); + if let Some(token) = get_token() { + return Some(ProtocolMessage::Auth(token)); + } + } + ProtocolMessage::UserActionResponse(_success) => { + // Success is already handled in the UI component that initiated the action + } + ProtocolMessage::Auth(_) + | ProtocolMessage::Authorized + | ProtocolMessage::ActiveProviderCountRequest(_) + | ProtocolMessage::StatusRequest(_) + | ProtocolMessage::UserAction(_) => {} + } + } + Err(err) => error!("Failed to decode websocket message: {err}") + } + } + None +} diff --git a/shared/Cargo.toml b/shared/Cargo.toml index 1898124ed..8b2f7a4e9 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "shared" -version = "3.1.8" +version = "3.2.0" edition = "2021" [dependencies] bitflags = "2" -serde = { version = "1.0", features = ["derive"] } +serde = { version = "1.0", features = ["derive", "rc"] } regex = "1" enum-iterator = "2" log = "0" diff --git a/shared/src/foundation/filter.pest b/shared/src/foundation/filter.pest index 40556b6d9..eb938ec16 100644 --- a/shared/src/foundation/filter.pest +++ b/shared/src/foundation/filter.pest @@ -4,7 +4,7 @@ and = { ^"and" } or = { ^"or" } not = { ^"not" } regexp = @{ "\"" ~ ( "\\\"" | (!"\"" ~ ANY) )* ~ "\"" } -type_value = { ^"live" | ^"vod" | ^"series" } +type_value = { ^"live" | ^"vod" | ^"movie" | ^"series" } type_comparison = { ^"type" ~ "=" ~ type_value } field_comparison_value = _{ regexp } field_comparison = { field ~ "~" ~ field_comparison_value } diff --git a/shared/src/foundation/filter.rs b/shared/src/foundation/filter.rs index 1d826183b..9cb0b1d2e 100644 --- a/shared/src/foundation/filter.rs +++ b/shared/src/foundation/filter.rs @@ -93,7 +93,7 @@ and = { ^"and" } or = { ^"or" } not = { ^"not" } regexp = @{ "\"" ~ ( "\\\"" | (!"\"" ~ ANY) )* ~ "\"" } -type_value = { ^"live" | ^"vod" | ^"series" } +type_value = { ^"live" | ^"vod" | ^"movie" | ^"series" } type_comparison = { ^"type" ~ "=" ~ type_value } field_comparison_value = _{ regexp } field_comparison = { field ~ "~" ~ field_comparison_value } @@ -216,6 +216,7 @@ impl Filter { impl Filter { const LIVE: &'static str = "live"; const VOD: &'static str = "vod"; + const MOVIE: &'static str = "movie"; const SERIES: &'static str = "series"; const UNSUPPORTED: &'static str = "unsupported"; } @@ -229,7 +230,7 @@ impl std::fmt::Display for Filter { Self::TypeComparison(field, item_type) => { write!(f, "{} = {}", field, match item_type { PlaylistItemType::Live => Self::LIVE, - PlaylistItemType::Video => Self::VOD, + PlaylistItemType::Video => Self::MOVIE, PlaylistItemType::Series | PlaylistItemType::SeriesInfo => Self::SERIES, // yes series-info is handled as series in filter _ => Self::UNSUPPORTED }) @@ -304,9 +305,9 @@ fn get_parser_field_comparison( fn get_filter_item_type(text_item_type: &str) -> Option { if text_item_type.eq_ignore_ascii_case("live") { Some(PlaylistItemType::Live) - } else if text_item_type.eq_ignore_ascii_case("vod") + } else if text_item_type.eq_ignore_ascii_case(Filter::VOD) || text_item_type.eq_ignore_ascii_case("video") - || text_item_type.eq_ignore_ascii_case("movie") + || text_item_type.eq_ignore_ascii_case(Filter::MOVIE) { Some(PlaylistItemType::Video) } else if text_item_type.eq_ignore_ascii_case("series") { diff --git a/shared/src/foundation/mapper.pest b/shared/src/foundation/mapper.pest index f963ec4e5..87f317535 100644 --- a/shared/src/foundation/mapper.pest +++ b/shared/src/foundation/mapper.pest @@ -18,7 +18,7 @@ block_expr = { "{" ~ statements ~ "}" } condition = { function_call | var_access | field_access } assignment = { (field_access | identifier) ~ "=" ~ expression } expression = { assignment | map_block | match_block | function_call | regex_expr | string_literal | number | var_access | field_access | null | block_expr } -function_name = { "concat" | "uppercase" | "lowercase" | "capitalize" | "trim" | "print" | "number" | "first" | "template" | "replace" } +function_name = { "concat" | "uppercase" | "lowercase" | "capitalize" | "trim" | "print" | "number" | "first" | "template" | "replace" | "pad" | "format"} function_call = { function_name ~ "(" ~ (expression ~ ("," ~ expression)*)? ~ ")" } any_match = { "_" } match_case_key = { any_match | identifier } diff --git a/shared/src/foundation/mapper.rs b/shared/src/foundation/mapper.rs index 82332db66..c5f34421b 100644 --- a/shared/src/foundation/mapper.rs +++ b/shared/src/foundation/mapper.rs @@ -1,21 +1,22 @@ #![allow(clippy::empty_docs)] -use crate::foundation::filter::{ValueAccessor}; +use crate::error::{create_tuliprox_error_result, info_err, TuliproxError, TuliproxErrorKind}; +use crate::foundation::filter::ValueAccessor; +use crate::foundation::mapper::EvalResult::{AnyValue, Failure, Named, Number, Undefined, Value}; +use crate::model::{PatternTemplate, TemplateValue}; +use crate::utils::Capitalize; use log::{debug, trace}; use pest::iterators::{Pair, Pairs}; +use pest::Parser; use pest_derive::Parser; use regex::Regex; use std::borrow::Cow; use std::cmp::Ordering; use std::collections::{HashMap, HashSet}; use std::fmt::Display; +use std::fmt::Write; use std::ops::Deref; use std::str::FromStr; -use pest::Parser; -use crate::error::{TuliproxError, TuliproxErrorKind, create_tuliprox_error_result, info_err}; -use crate::foundation::mapper::EvalResult::{AnyValue, Failure, Named, Number, Undefined, Value}; -use crate::model::{PatternTemplate, TemplateValue}; -use crate::utils::Capitalize; #[derive(Parser)] #[grammar_inline = r##" @@ -39,7 +40,7 @@ block_expr = { "{" ~ statements ~ "}" } condition = { function_call | var_access | field_access } assignment = { (field_access | identifier) ~ "=" ~ expression } expression = { assignment | map_block | match_block | function_call | regex_expr | string_literal | number | var_access | field_access | null | block_expr } -function_name = { "concat" | "uppercase" | "lowercase" | "capitalize" | "trim" | "print" | "number" | "first" | "template" | "replace" } +function_name = { "concat" | "uppercase" | "lowercase" | "capitalize" | "trim" | "print" | "number" | "first" | "template" | "replace" | "pad" | "format" } function_call = { function_name ~ "(" ~ (expression ~ ("," ~ expression)*)? ~ ")" } any_match = { "_" } match_case_key = { any_match | identifier } @@ -118,6 +119,8 @@ pub enum BuiltInFunction { First, Template, Replace, + Pad, + Format, } impl FromStr for BuiltInFunction { @@ -135,6 +138,8 @@ impl FromStr for BuiltInFunction { "first" => Ok(Self::First), "template" => Ok(Self::Template), "replace" => Ok(Self::Replace), + "pad" => Ok(Self::Pad), + "format" => Ok(Self::Format), _ => create_tuliprox_error_result!(TuliproxErrorKind::Info, "Unknown function {s}"), } } @@ -153,6 +158,8 @@ impl Display for BuiltInFunction { Self::First => "first", Self::Template => "template", Self::Replace => "replace", + Self::Pad => "pad", + Self::Format => "format", }.to_owned(); write!(f, "{str}") } @@ -620,7 +627,7 @@ impl<'a> MapperContext<'a> { } Some(hash_map) } - }) + }), } } @@ -696,13 +703,18 @@ impl<'a> MapperContext<'a> { if args.len() > 1 { return create_tuliprox_error_result!(TuliproxErrorKind::Info, "Function accepts only one argument {:?}, {} given", name, args.len()); } - }, + } BuiltInFunction::Replace => { - if args.len() != 3 { + if args.len() != 3 { return create_tuliprox_error_result!(TuliproxErrorKind::Info, "Function accepts three arguments {:?}, {} given", name, args.len()); } } - _ => {} + BuiltInFunction::Pad => { + if !(args.len() == 3 || args.len() == 4) { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "Function accepts three or four arguments {:?}, {} given", name, args.len()); + } + } + _ => {} } for expr_id in args { self.validate_expr(*expr_id, identifiers)?; @@ -931,8 +943,22 @@ fn concat_args(args: &Vec) -> Vec> { macro_rules! extract_evaluated_arg_value { ($evaluated_args:expr, $index:expr) => {{ - let evaluated_arg = &$evaluated_args[$index]; - match evaluated_arg { + if $index >= $evaluated_args.len() { + None + } else { + let evaluated_arg = &$evaluated_args[$index]; + match evaluated_arg { + Value(value) => Some(value), + Named(values) => values.first().map(|(_key, val)| val), + _ => None, + } + } + }}; +} + +macro_rules! extract_arg_value { + ($evaluated_arg:expr) => {{ + match $evaluated_arg { Value(value) => Some(value), Named(values) => values.first().map(|(_key, val)| val), _ => None, @@ -1122,6 +1148,98 @@ impl Expression { evaluated_args[0].clone() } } + BuiltInFunction::Pad => { + let value = match &evaluated_args[0] { + Number(value) => Some(value.to_string()), + Value(value) => Some(value.clone()), + Named(values) => values.first().map(|(_key, val)| val.clone()), + _ => None, + }; + let width = match &evaluated_args[1] { + Number(value) => { + if value.is_nan() || value.is_infinite() { + 0 + } else { + value.abs().min(usize::MAX as f64) as usize + } + } + Value(value) => value.parse::().ok().unwrap_or(0), + Named(values) => values.first().and_then(|(_key, val)| val.parse::().ok()).unwrap_or(0), + _ => 0, + }; + + let fill = match &evaluated_args[2] { + Number(value) => Some(value.to_string()), + Value(value) => Some(value.clone()), + Named(values) => values.first().map(|(_key, val)| val.clone()), + _ => None, + }; + + let align = extract_evaluated_arg_value!(evaluated_args, 3); // "<", ">", "^" + + if let Some(text) = value { + let fill_char = fill.and_then(|s| s.chars().next()).unwrap_or(' '); + + let padded = if width <= text.len() { + text.clone() + } else { + let pad = width - text.len(); + if let Some(al) = align { + match al.as_str() { + "^" => { + let left = pad / 2; + let right = pad - left; + format!( + "{}{}{}", + fill_char.to_string().repeat(left), + text, + fill_char.to_string().repeat(right) + ) + } + "<" => format!("{}{}", text, fill_char.to_string().repeat(pad)), + _ => format!("{}{}", fill_char.to_string().repeat(pad), text), + } + } else { + format!("{}{}", fill_char.to_string().repeat(pad), text) + } + }; + + Value(padded) + } else { + Undefined + } + } + BuiltInFunction::Format => { + let fmt_pattern = extract_evaluated_arg_value!(evaluated_args, 0); + + if let Some(fmt_str) = fmt_pattern { + let args: Vec> = evaluated_args.iter() + .skip(1) + .map(|v| extract_arg_value!(v)) + .collect(); + + let mut formatted = String::new(); + let mut arg_iter = args.iter(); + let mut chars = fmt_str.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch == '{' && chars.peek() == Some(&'}') { + chars.next(); + if let Some(Some(arg)) = arg_iter.next() { + write!(formatted, "{}", arg).unwrap(); + } else { + formatted.push_str("{}"); + } + } else { + formatted.push(ch); + } + } + + Value(formatted) + } else { + Undefined + } + } } } } @@ -1381,6 +1499,27 @@ mod tests { } "#; let mapper = MapperScript::parse(script, None).expect("Parsing failed"); - println!("Program: {mapper:?}"); + println!("Program: {mapper:?}") + } + + #[test] + fn test_mapper_format() { + let dsl = r#" + @Name = pad(1000, 10, 0); + @Title = format("Hello {} how {}", "a", "b"); + "#; + + let mapper = MapperScript::parse(dsl, None).expect("Parsing failed"); + let mut channels: Vec = vec![ + ("D", "HD"), + ].into_iter().map(|(name, quality)| PlaylistItem { header: PlaylistItemHeader { title: format!("Chanel {name} [{quality}]"), ..Default::default() } }).collect::>(); + + for pli in &mut channels { + let mut accessor = ValueAccessor { + pli, + }; + mapper.eval(&mut accessor, None); + println!("Result: {pli:?}"); + } } } diff --git a/shared/src/model/active_user_connection_change.rs b/shared/src/model/active_user_connection_change.rs index a38d84818..5af9fe805 100644 --- a/shared/src/model/active_user_connection_change.rs +++ b/shared/src/model/active_user_connection_change.rs @@ -5,7 +5,6 @@ use crate::model::StreamInfo; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] #[serde(tag = "type", content = "payload", rename_all = "camelCase")] pub enum ActiveUserConnectionChange { - Connected(StreamInfo), Updated(StreamInfo), Disconnected(SocketAddr), // addr Connections(usize, usize) // user_count, connection_count diff --git a/shared/src/model/config/favourites.rs b/shared/src/model/config/favourites.rs index 14e701d79..677246c90 100644 --- a/shared/src/model/config/favourites.rs +++ b/shared/src/model/config/favourites.rs @@ -14,10 +14,7 @@ pub struct ConfigFavouritesDto { impl ConfigFavouritesDto { pub fn prepare(&mut self, templates: Option<&Vec>) -> Result<(), TuliproxError> { - match get_filter(&self.filter, templates) { - Ok(filter) => self.t_filter = Some(filter), - Err(err) => return Err(err), - } + self.t_filter = Some(get_filter(&self.filter, templates)?); Ok(()) } } \ No newline at end of file diff --git a/shared/src/model/config/input.rs b/shared/src/model/config/input.rs index 7180f1ec6..29310c92f 100644 --- a/shared/src/model/config/input.rs +++ b/shared/src/model/config/input.rs @@ -191,6 +191,7 @@ impl ConfigInputOptionsDto { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] #[serde(deny_unknown_fields)] pub struct StagedInputDto { + pub name: String, pub url: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub username: Option, diff --git a/shared/src/model/config/reverse_proxy.rs b/shared/src/model/config/reverse_proxy.rs index c620a804f..94873e223 100644 --- a/shared/src/model/config/reverse_proxy.rs +++ b/shared/src/model/config/reverse_proxy.rs @@ -1,5 +1,6 @@ use crate::error::TuliproxError; use crate::model::{CacheConfigDto, GeoIpConfigDto, RateLimitConfigDto, StreamConfigDto}; +use crate::utils::{default_resource_retry_attempts, default_resource_retry_backoff_ms, default_resource_retry_backoff_multiplier}; use log::warn; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] @@ -31,6 +32,8 @@ pub struct ReverseProxyConfigDto { #[serde(default)] pub resource_rewrite_disabled: bool, #[serde(default, skip_serializing_if = "Option::is_none")] + pub resource_retry: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub disabled_header: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub stream: Option, @@ -46,6 +49,10 @@ impl ReverseProxyConfigDto { pub fn is_empty(&self) -> bool { !self.resource_rewrite_disabled && self.disabled_header.as_ref().is_none_or(|d| d.is_empty()) + && self + .resource_retry + .as_ref() + .is_none_or(ResourceRetryConfigDto::is_default) && (self.stream.is_none() || self.stream.as_ref().is_some_and(|s| s.is_empty())) && (self.cache.is_none() || self.cache.as_ref().is_some_and(|c| c.is_empty())) && (self.rate_limit.is_none() || self.rate_limit.as_ref().is_some_and(|r| r.is_empty())) @@ -59,16 +66,23 @@ impl ReverseProxyConfigDto { self.disabled_header = None; } } - if self.stream.as_ref().is_some_and(|s| s.is_empty()) { + if self + .resource_retry + .as_ref() + .is_some_and(ResourceRetryConfigDto::is_default) + { + self.resource_retry = None; + } + if self.stream.as_ref().is_some_and(StreamConfigDto::is_empty) { self.stream = None; } - if self.cache.as_ref().is_some_and(|s| s.is_empty()) { + if self.cache.as_ref().is_some_and(CacheConfigDto::is_empty) { self.cache = None; } - if self.rate_limit.as_ref().is_some_and(|s| s.is_empty()) { + if self.rate_limit.as_ref().is_some_and(RateLimitConfigDto::is_empty) { self.rate_limit = None; } - if self.geoip.as_ref().is_some_and(|g| g.is_empty()) { + if self.geoip.as_ref().is_some_and(GeoIpConfigDto::is_empty) { self.geoip = None; } } @@ -93,3 +107,32 @@ impl ReverseProxyConfigDto { Ok(()) } } + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ResourceRetryConfigDto { + #[serde(default = "default_resource_retry_attempts")] + pub max_attempts: u32, + #[serde(default = "default_resource_retry_backoff_ms")] + pub backoff_millis: u64, + #[serde(default = "default_resource_retry_backoff_multiplier")] + pub backoff_multiplier: f64, +} + +impl Default for ResourceRetryConfigDto { + fn default() -> Self { + Self { + max_attempts: default_resource_retry_attempts(), + backoff_millis: default_resource_retry_backoff_ms(), + backoff_multiplier: default_resource_retry_backoff_multiplier(), + } + } +} + +impl ResourceRetryConfigDto { + pub fn is_default(&self) -> bool { + self.max_attempts == default_resource_retry_attempts() + && self.backoff_millis == default_resource_retry_backoff_ms() + && (self.backoff_multiplier - default_resource_retry_backoff_multiplier()).abs() < f64::EPSILON + } +} diff --git a/shared/src/model/config/stream.rs b/shared/src/model/config/stream.rs index 28c929dc0..bc5288ad7 100644 --- a/shared/src/model/config/stream.rs +++ b/shared/src/model/config/stream.rs @@ -3,6 +3,8 @@ use crate::info_err; use crate::utils::{default_grace_period_millis, default_grace_period_timeout_secs, parse_to_kbps}; const STREAM_QUEUE_SIZE: usize = 1024; // mpsc channel holding messages. with 8192byte chunks and 2Mbit/s approx 8MB +const MIN_SHARED_BURST_BUFFER_MB: u64 = 1; +const fn default_shared_burst_buffer_mb() -> u64 { 12 } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] #[serde(deny_unknown_fields)] @@ -42,6 +44,8 @@ pub struct StreamConfigDto { pub forced_retry_interval_secs: u32, #[serde(default, skip)] pub throttle_kbps: u64, + #[serde(default = "default_shared_burst_buffer_mb")] + pub shared_burst_buffer_mb: u64, } impl Default for StreamConfigDto { @@ -54,6 +58,7 @@ impl Default for StreamConfigDto { grace_period_timeout_secs: default_grace_period_timeout_secs(), forced_retry_interval_secs: 0, throttle_kbps: 0, + shared_burst_buffer_mb: default_shared_burst_buffer_mb(), } } } @@ -68,6 +73,7 @@ impl StreamConfigDto { && self.grace_period_timeout_secs == empty.grace_period_timeout_secs && self.forced_retry_interval_secs == empty.forced_retry_interval_secs && self.throttle_kbps == empty.throttle_kbps + && self.shared_burst_buffer_mb == default_shared_burst_buffer_mb() } @@ -90,6 +96,15 @@ impl StreamConfigDto { } } + if self.shared_burst_buffer_mb < MIN_SHARED_BURST_BUFFER_MB { + return Err(TuliproxError::new( + TuliproxErrorKind::Info, + format!( + "`shared_burst_buffer_mb` must be at least {MIN_SHARED_BURST_BUFFER_MB} MB" + ), + )); + } + Ok(()) } } diff --git a/shared/src/model/config/target.rs b/shared/src/model/config/target.rs index 60bdd783b..d7fc62bfe 100644 --- a/shared/src/model/config/target.rs +++ b/shared/src/model/config/target.rs @@ -70,10 +70,14 @@ impl Default for XtreamTargetOutputDto { } impl XtreamTargetOutputDto { - pub fn prepare(&mut self) { + pub fn prepare(&mut self, templates: Option<&Vec>) -> Result<(), TuliproxError> { + if let Some(raw_filter) = &self.filter { + self.t_filter = Some(get_filter(raw_filter, templates)?); + } if let Some(trakt) = &mut self.trakt { trakt.prepare(); } + Ok(()) } pub fn has_any_option(&self) -> bool { @@ -103,6 +107,14 @@ pub struct M3uTargetOutputDto { } impl M3uTargetOutputDto { + + pub fn prepare(&mut self, templates: Option<&Vec>) -> Result<(), TuliproxError> { + if let Some(raw_filter) = &self.filter { + self.t_filter = Some(get_filter(raw_filter, templates)?); + } + Ok(()) + } + pub fn has_any_option(&self) -> bool { self.filename.is_some() || self.include_type_in_url @@ -134,6 +146,16 @@ pub struct StrmTargetOutputDto { #[serde(default)] pub add_quality_to_filename: bool, } + +impl StrmTargetOutputDto { + pub fn prepare(&mut self, templates: Option<&Vec>) -> Result<(), TuliproxError> { + if let Some(raw_filter) = &self.filter { + self.t_filter = Some(get_filter(raw_filter, templates)?); + } + Ok(()) + } +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct HdHomeRunTargetOutputDto { @@ -163,12 +185,12 @@ pub enum TargetOutputDto { } impl TargetOutputDto { - pub fn prepare(&mut self) { + pub fn prepare(&mut self, templates: Option<&Vec>) -> Result<(), TuliproxError> { match self { - TargetOutputDto::Xtream(output) => output.prepare(), - TargetOutputDto::M3u(_) - | TargetOutputDto::Strm(_) - | TargetOutputDto::HdHomeRun(_) => {} + TargetOutputDto::Xtream(output) => output.prepare(templates), + TargetOutputDto::M3u(output) => output.prepare(templates), + TargetOutputDto::Strm(output) => output.prepare(templates), + TargetOutputDto::HdHomeRun(_) => Ok(()) } } } @@ -246,11 +268,11 @@ impl ConfigTargetDto { let mut hdhomerun_needs_m3u = false; let mut hdhomerun_needs_xtream = false; - let mut strm_export_styles = vec![]; + //let mut strm_export_styles = vec![]; let mut strm_directories: Vec<&str> = vec![]; for target_output in &mut self.output { - target_output.prepare(); + target_output.prepare(templates)?; match target_output { TargetOutputDto::Xtream(_) => { xtream_cnt += 1; @@ -276,10 +298,10 @@ impl ConfigTargetDto { if has_username { strm_needs_xtream = true; } - if strm_export_styles.contains(&strm_output.style) { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "strm outputs with same export style are not allowed: {}", self.name); - } - strm_export_styles.push(strm_output.style); + // if strm_export_styles.contains(&strm_output.style) { + // return create_tuliprox_error_result!(TuliproxErrorKind::Info, "strm outputs with same export style are not allowed: {}", self.name); + // } + // strm_export_styles.push(strm_output.style); if strm_directories.contains(&strm_output.directory.as_str()) { return create_tuliprox_error_result!(TuliproxErrorKind::Info, "strm outputs with same export directory are not allowed: {}", self.name); } diff --git a/shared/src/model/mapping.rs b/shared/src/model/mapping.rs index 0739041c8..26345c8fc 100644 --- a/shared/src/model/mapping.rs +++ b/shared/src/model/mapping.rs @@ -169,10 +169,7 @@ impl MapperDto { /// /// Will panic if default `RegEx` gets invalid pub fn prepare(&mut self, templates: Option<&Vec>) -> Result<(), TuliproxError> { - match get_filter(&self.filter, templates) { - Ok(filter) => self.t_filter = Some(filter), - Err(err) => return Err(err), - } + self.t_filter = Some(get_filter(&self.filter, templates)?); let script = if templates.is_some() { apply_templates_to_pattern_single(&self.script, templates)? } else { diff --git a/shared/src/model/web_socket.rs b/shared/src/model/web_socket.rs index 23b3607a9..60b53deb2 100644 --- a/shared/src/model/web_socket.rs +++ b/shared/src/model/web_socket.rs @@ -83,7 +83,9 @@ pub enum ProtocolMessage { // Responses StatusResponse(StatusCheck), ActiveUserResponse(ActiveUserConnectionChange), - ActiveProviderResponse(String, usize), + ActiveProviderResponse(String, usize), // single provider + ActiveProviderCountRequest(String), + ActiveProviderCountResponse(usize), ConfigChangeResponse(ConfigType), PlaylistUpdateResponse(PlaylistUpdateState), PlaylistUpdateProgressResponse(String, String), diff --git a/shared/src/utils/default_utils.rs b/shared/src/utils/default_utils.rs index f48827e4a..54e641016 100644 --- a/shared/src/utils/default_utils.rs +++ b/shared/src/utils/default_utils.rs @@ -10,4 +10,7 @@ pub const fn default_resolve_delay_secs() -> u16 { 2 } // helping avoid triggering hard max_connection enforcement. pub const fn default_grace_period_millis() -> u64 { 2000 } pub const fn default_grace_period_timeout_secs() -> u64 { 4 } -pub const fn default_connect_timeout_secs() -> u32 { 6 } \ No newline at end of file +pub const fn default_connect_timeout_secs() -> u32 { 6 } +pub const fn default_resource_retry_attempts() -> u32 { 3 } +pub const fn default_resource_retry_backoff_ms() -> u64 { 250 } +pub const fn default_resource_retry_backoff_multiplier() -> f64 { 1.0 } diff --git a/shared/src/utils/mod.rs b/shared/src/utils/mod.rs index 1d18e8550..3723eb1f5 100644 --- a/shared/src/utils/mod.rs +++ b/shared/src/utils/mod.rs @@ -10,6 +10,7 @@ mod json_utils; mod serde_utils; mod hdhomerun_utils; mod net_utils; +mod number_utils; use std::fmt::Display; pub use self::default_utils::*; @@ -24,6 +25,7 @@ pub use self::json_utils::*; pub use self::serde_utils::*; pub use self::hdhomerun_utils::*; pub use self::net_utils::*; +pub use self::number_utils::*; #[macro_export] macro_rules! write_if_some { diff --git a/shared/src/utils/number_utils.rs b/shared/src/utils/number_utils.rs new file mode 100644 index 000000000..21cd199bd --- /dev/null +++ b/shared/src/utils/number_utils.rs @@ -0,0 +1,177 @@ +/// Parses various localized numeric inputs. +/// Examples: +/// "1.234,56" -> 1234.56 +/// "1,234.56" -> 1234.56 +/// "1,000,23" -> 1000.23 (last comma as decimal separator) +/// "1_000,23" -> 1000.23 (underscore as thousands separator) +pub fn parse_localized_float(s: &str) -> Option { + let s = s.trim(); + if s.is_empty() { + return None; + } + + // Find the last position of ',' or '.' (the decimal separator) + let last_comma = s.rfind(','); + let last_dot = s.rfind('.'); + let sep_pos = match (last_comma, last_dot) { + (Some(c), Some(d)) => Some(c.max(d)), + (Some(c), None) => Some(c), + (None, Some(d)) => Some(d), + (None, None) => None, + }; + + if let Some(pos) = sep_pos { + // Everything to the left of the last separator: integer part + let left = s[..pos] + .chars() + .filter(|c| c.is_ascii_digit() || *c == '-' ) + .collect::(); + + // Everything to the right of the last separator: fractional part + let right = s[pos + 1..] + .chars() + .filter(|c| c.is_ascii_digit()) + .collect::(); + + // If left is empty (e.g., ",5"), assume "0" + let left = if left.is_empty() { + "0".to_string() + } else { + left + }; + + let combined = if right.is_empty() { + left + } else { + format!("{}.{}", left, right) // '.' for Rust parsing + }; + + combined.parse::().ok() + } else { + // No decimal separator → clean out all grouping chars and parse as integer + let cleaned = s + .chars() + .filter(|c| c.is_ascii_digit() || *c == '-' ) + .collect::(); + cleaned.parse::().ok() + } +} + +/// Formats an `f64` with a fixed number of decimal places (`decimals`). +/// Thousand grouping (`groups`) is optional — if `true`, groups are separated by underscore. +/// Always uses a comma (`,`) as the decimal separator. +pub fn format_float_localized(value: f64, decimals: usize, groups: bool) -> String { + let s = format!("{:.*}", decimals, value); // "1234.5678" + let parts: Vec<&str> = s.split('.').collect(); + let int_part = parts[0]; + let frac_part = parts.get(1).unwrap_or(&""); + + let grouped = if groups { + let rev_chars: Vec = int_part.chars().rev().collect(); + let mut out = String::new(); + for (i, c) in rev_chars.iter().enumerate() { + if i > 0 && i % 3 == 0 { + out.push('_'); + } + out.push(*c); + } + out.chars().rev().collect::() + } else { + int_part.to_string() + }; + + if frac_part.is_empty() { + grouped + } else { + format!("{},{}", grouped, frac_part) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_localized_number_basic() { + // simple integers + assert_eq!(parse_localized_float("1234"), Some(1234.0)); + assert_eq!(parse_localized_float("0"), Some(0.0)); + assert_eq!(parse_localized_float("-42"), Some(-42.0)); + } + + #[test] + fn test_parse_localized_number_decimal_variants() { + // commas as decimal separator + assert_eq!(parse_localized_float("1234,56"), Some(1234.56)); + assert_eq!(parse_localized_float("0,5"), Some(0.5)); + assert_eq!(parse_localized_float(",5"), Some(0.5)); + + // dots as decimal separator + assert_eq!(parse_localized_float("1234.56"), Some(1234.56)); + assert_eq!(parse_localized_float(".5"), Some(0.5)); + assert_eq!(parse_localized_float("-.5"), Some(-0.5)); + + // multiple separators (last is decimal) + assert_eq!(parse_localized_float("1,000,23"), Some(1000.23)); + assert_eq!(parse_localized_float("1.000.23"), Some(1000.23)); + assert_eq!(parse_localized_float("1.000,23"), Some(1000.23)); + assert_eq!(parse_localized_float("1,000.23"), Some(1000.23)); + } + + #[test] + fn test_parse_localized_number_grouping_with_underscores() { + // underscores as thousand separators + assert_eq!(parse_localized_float("1_000"), Some(1000.0)); + assert_eq!(parse_localized_float("1_000,5"), Some(1000.5)); + assert_eq!(parse_localized_float("12_345,6789"), Some(12345.6789)); + assert_eq!(parse_localized_float("-12_345,6789"), Some(-12345.6789)); + } + + #[test] + fn test_parse_localized_number_mixed_grouping() { + // mixed grouping symbols + assert_eq!(parse_localized_float("1_000.000,25"), Some(1000000.25)); + assert_eq!(parse_localized_float("1.000_000,25"), Some(1000000.25)); + assert_eq!(parse_localized_float("1,000_000.25"), Some(1000000.25)); + assert_eq!(parse_localized_float(" 1_000,25 "), Some(1000.25)); // with spaces + } + + #[test] + fn test_format_number_localized_basic() { + // without grouping + assert_eq!(format_float_localized(1234.56, 2, false), "1234,56"); + assert_eq!(format_float_localized(0.5, 2, false), "0,50"); + assert_eq!(format_float_localized(12.0, 0, false), "12"); + } + + #[test] + fn test_format_number_localized_grouped() { + // with grouping and underscores + assert_eq!(format_float_localized(1234.56, 2, true), "1_234,56"); + assert_eq!(format_float_localized(1234567.89, 2, true), "1_234_567,89"); + assert_eq!(format_float_localized(1000.0, 0, true), "1_000"); + assert_eq!(format_float_localized(0.5, 3, true), "0,500"); + } + + #[test] + fn test_format_and_parse_roundtrip() { + // check that formatted values can be parsed back exactly + let values = [ + 0.0, + 1.0, + 12.34, + 1234.56, + 1000.0, + 1000000.25, + -42.75, + 0.0001, + 1_234.56 + ]; + for &v in &values { + let formatted = format_float_localized(v, 4, true); + let parsed = parse_localized_float(&formatted).unwrap(); + let diff = (parsed - v).abs(); + assert!(diff < 1e-10, "roundtrip failed for {v}: {formatted} -> {parsed}"); + } + } +}