diff --git a/Cargo.lock b/Cargo.lock index d94daa087..5e14f4d7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,19 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -47,6 +60,16 @@ dependencies = [ "libc", ] +[[package]] +name = "annotate-snippets" +version = "0.12.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15580ece6ea97cbf832d60ba19c021113469480852c6a2a6beb0db28f097bf1f" +dependencies = [ + "anstyle", + "unicode-width", +] + [[package]] name = "anstream" version = "0.6.21" @@ -83,7 +106,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -94,7 +117,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -118,6 +141,12 @@ dependencies = [ "rustversion", ] +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + [[package]] name = "arrayref" version = "0.3.9" @@ -132,9 +161,9 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "async-compression" -version = "0.4.33" +version = "0.4.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93c1f86859c1af3d514fa19e8323147ff10ea98684e6c7b307912509f50e67b2" +checksum = "98ec5f6c2f8bc326c994cb9e241cc257ddaba9afa8555a43cffbb5dd86efaa37" dependencies = [ "compression-codecs", "compression-core", @@ -151,7 +180,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -168,9 +197,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "axum" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ "axum-core", "axum-macros", @@ -178,7 +207,7 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http 1.3.1", + "http 1.4.0", "http-body", "http-body-util", "hyper", @@ -205,13 +234,13 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.5.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http 1.3.1", + "http 1.4.0", "http-body", "http-body-util", "mime", @@ -230,7 +259,7 @@ checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -247,9 +276,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.8.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" [[package]] name = "bincode" @@ -349,9 +378,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "bytes" @@ -367,9 +396,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.46" +version = "1.2.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36" +checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" dependencies = [ "find-msvc-tools", "jobserver", @@ -441,9 +470,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.51" +version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" dependencies = [ "clap_builder", "clap_derive", @@ -451,9 +480,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.51" +version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" dependencies = [ "anstream", "anstyle", @@ -470,7 +499,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -487,9 +516,9 @@ checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "compression-codecs" -version = "0.4.32" +version = "0.4.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "680dc087785c5230f8e8843e2e57ac7c1c90488b6a91b88caa265410568f441b" +checksum = "b0f7ac3e5b97fdce45e8922fb05cae2c37f7bbd63d30dd94821dacfd8f3f2bf2" dependencies = [ "brotli", "compression-core", @@ -501,9 +530,9 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.30" +version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a9b614a5787ef0c8802a55766480563cb3a93b435898c422ed2a359cf811582" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" [[package]] name = "console_error_panic_hook" @@ -641,7 +670,7 @@ dependencies = [ "dtoa-short", "itoa", "phf 0.11.3", - "smallvec", + "smallvec 1.15.1", ] [[package]] @@ -651,7 +680,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -678,7 +707,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -702,7 +731,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -713,7 +742,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -774,7 +803,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -784,27 +813,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] name = "derive_more" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ "derive_more-impl", ] [[package]] name = "derive_more-impl" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "rustc_version", + "syn 2.0.112", ] [[package]] @@ -839,7 +869,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -853,9 +883,9 @@ dependencies = [ [[package]] name = "dtoa" -version = "1.0.10" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" [[package]] name = "dtoa-short" @@ -940,6 +970,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "encoding_rs_io" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83" +dependencies = [ + "encoding_rs", +] + [[package]] name = "enum-iterator" version = "2.3.0" @@ -957,7 +996,7 @@ checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -996,7 +1035,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1049,9 +1088,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" [[package]] name = "flate2" @@ -1069,6 +1108,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1123,9 +1168,9 @@ dependencies = [ "futures-signals", "gloo-render 0.2.0", "gloo-storage 0.3.0", - "gloo-timers 0.2.6", + "gloo-timers 0.3.0", "gloo-utils 0.2.0", - "implicit-clone", + "implicit-clone 0.6.0", "js-sys", "log", "prost", @@ -1221,7 +1266,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -1295,7 +1340,7 @@ checksum = "913dce4c5f06c2ea40fc178c06f777ac89fc6b1383e90c254fafb1abe4ba3c82" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", "uuid", ] @@ -1646,8 +1691,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c" dependencies = [ - "futures-channel", - "futures-core", "js-sys", "wasm-bindgen", ] @@ -1754,14 +1797,14 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] name = "governor" -version = "0.10.2" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e23d5986fd4364c2fb7498523540618b4b8d92eec6c36a02e565f66748e2f79" +checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" dependencies = [ "cfg-if", "dashmap", @@ -1769,13 +1812,13 @@ dependencies = [ "futures-timer", "futures-util", "getrandom 0.3.4", - "hashbrown 0.16.0", + "hashbrown 0.16.1", "nonzero_ext", "parking_lot", "portable-atomic", "quanta", "rand 0.9.2", - "smallvec", + "smallvec 1.15.1", "spinning_top", "web-time", ] @@ -1802,7 +1845,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http 1.3.1", + "http 1.4.0", "indexmap", "slab", "tokio", @@ -1829,13 +1872,31 @@ checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", ] [[package]] @@ -1887,12 +1948,11 @@ dependencies = [ [[package]] name = "http" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -1903,7 +1963,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.3.1", + "http 1.4.0", ] [[package]] @@ -1914,7 +1974,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.3.1", + "http 1.4.0", "http-body", "pin-project-lite", ] @@ -1948,14 +2008,14 @@ dependencies = [ "futures-channel", "futures-core", "h2", - "http 1.3.1", + "http 1.4.0", "http-body", "httparse", "httpdate", "itoa", "pin-project-lite", "pin-utils", - "smallvec", + "smallvec 1.15.1", "tokio", "want", ] @@ -1966,7 +2026,7 @@ version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http 1.3.1", + "http 1.4.0", "hyper", "hyper-util", "rustls", @@ -2008,16 +2068,16 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ "base64", "bytes", "futures-channel", "futures-core", "futures-util", - "http 1.3.1", + "http 1.4.0", "http-body", "hyper", "ipnet", @@ -2092,7 +2152,7 @@ dependencies = [ "icu_normalizer_data", "icu_properties", "icu_provider", - "smallvec", + "smallvec 1.15.1", "zerovec", ] @@ -2104,9 +2164,9 @@ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -2118,9 +2178,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" @@ -2150,7 +2210,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", - "smallvec", + "smallvec 1.15.1", "utf8_iter", ] @@ -2174,6 +2234,15 @@ dependencies = [ "indexmap", ] +[[package]] +name = "implicit-clone" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1689b939ee35e3a075b0834b5672efd43aec8a6e81a1c6002b76a5ca2f211ae0" +dependencies = [ + "implicit-clone-derive", +] + [[package]] name = "implicit-clone-derive" version = "0.1.2" @@ -2181,17 +2250,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "699c1b6d335e63d0ba5c1e1c7f647371ce989c3bcbe1f7ed2b85fa56e3bd1a21" dependencies = [ "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] name = "indexmap" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.16.1", ] [[package]] @@ -2222,9 +2291,9 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "iri-string" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" dependencies = [ "memchr", "serde", @@ -2247,15 +2316,15 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jiff" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35" +checksum = "a87d9b8105c23642f50cbbae03d1f75d8422c5cb98ce7ee9271f7ff7505be6b8" dependencies = [ "jiff-static", "log", @@ -2266,13 +2335,13 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69" +checksum = "b787bebb543f8969132630c51fd0afab173a86c6abae56ff3b9e5e3e3f9f6e58" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -2349,9 +2418,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.178" +version = "0.2.179" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f" [[package]] name = "libm" @@ -2361,13 +2430,13 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libredox" -version = "0.1.10" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ "bitflags 2.10.0", "libc", - "redox_syscall", + "redox_syscall 0.7.0", ] [[package]] @@ -2399,9 +2468,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lol_html" @@ -2413,7 +2482,7 @@ dependencies = [ "cfg-if", "cssparser", "encoding_rs", - "hashbrown 0.16.0", + "hashbrown 0.16.1", "memchr", "mime", "precomputed-hash", @@ -2475,9 +2544,9 @@ dependencies = [ [[package]] name = "minicov" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f27fe9f1cc3c22e1687f9446c2083c4c5fc7f0bcf1c7a86bdbded14985895b4b" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" dependencies = [ "cc", "walkdir", @@ -2495,9 +2564,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "log", @@ -2528,6 +2597,12 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "nom" version = "8.0.0" @@ -2575,9 +2650,9 @@ checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d" [[package]] name = "ntapi" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +checksum = "c70f219e21142367c70c0b30c6a9e3a14d55b4d12a204d897fbec83a0363f081" dependencies = [ "winapi", ] @@ -2588,7 +2663,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2613,7 +2688,7 @@ dependencies = [ "num-iter", "num-traits", "rand 0.8.5", - "smallvec", + "smallvec 1.15.1", "zeroize", ] @@ -2732,7 +2807,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -2805,8 +2880,8 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", - "smallvec", + "redox_syscall 0.5.18", + "smallvec 1.15.1", "windows-link 0.2.1", ] @@ -2849,9 +2924,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.3" +version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989e7521a040efde50c3ab6bbadafbe15ab6dc042686926be59ac35d74607df4" +checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" dependencies = [ "memchr", "ucd-trie", @@ -2859,9 +2934,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.3" +version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "187da9a3030dbafabbbfb20cb323b976dc7b7ce91fcd84f2f74d6e31d378e2de" +checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" dependencies = [ "pest", "pest_generator", @@ -2869,22 +2944,22 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.3" +version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49b401d98f5757ebe97a26085998d6c0eecec4995cad6ab7fc30ffdf4b052843" +checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] name = "pest_meta" -version = "2.8.3" +version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f27a2cfee9f9039c4d86faa5af122a0ac3851441a34865b8a043b46be0065a" +checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" dependencies = [ "pest", "sha2", @@ -2939,7 +3014,7 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -2977,7 +3052,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -3032,9 +3107,9 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "portable-atomic" -version = "1.11.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" [[package]] name = "portable-atomic-util" @@ -3082,7 +3157,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -3130,9 +3205,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" dependencies = [ "unicode-ident", ] @@ -3174,7 +3249,7 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -3369,6 +3444,15 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_syscall" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "regex" version = "1.12.2" @@ -3409,9 +3493,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.24" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", @@ -3420,7 +3504,7 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http 1.3.1", + "http 1.4.0", "http-body", "http-body-util", "hyper", @@ -3597,7 +3681,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3616,9 +3700,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" dependencies = [ "web-time", "zeroize", @@ -3643,9 +3727,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.21" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62049b2877bf12821e8f9ad256ee38fdc31db7387ec2d3b3f403024de2034aea" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" [[package]] name = "same-file" @@ -3656,6 +3740,16 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "saphyr-parser" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb771b59f6b1985d1406325ec28f97cfb14256abcec4fdfb37b36a1766d6af7" +dependencies = [ + "arraydeque", + "hashlink", +] + [[package]] name = "schannel" version = "0.1.28" @@ -3724,7 +3818,7 @@ dependencies = [ "precomputed-hash", "rustc-hash", "servo_arc", - "smallvec", + "smallvec 1.15.1", ] [[package]] @@ -3743,6 +3837,25 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-saphyr" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef7e021087b4b525edcbb93045ca3f1dafeff7800b3c7960a90a75f197e257da" +dependencies = [ + "ahash", + "annotate-snippets", + "base64", + "encoding_rs_io", + "nohash-hasher", + "num-traits", + "ryu", + "saphyr-parser", + "serde", + "serde_json", + "smallvec 2.0.0-alpha.12", +] + [[package]] name = "serde-wasm-bindgen" version = "0.5.0" @@ -3782,20 +3895,21 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.148" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" dependencies = [ + "indexmap", "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] @@ -3821,19 +3935,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - [[package]] name = "servo_arc" version = "0.4.3" @@ -3878,6 +3979,7 @@ dependencies = [ "deunicode", "enum-iterator", "fastrand", + "getrandom 0.3.4", "hex", "indexmap", "js-sys", @@ -3888,6 +3990,7 @@ dependencies = [ "pest_derive", "regex", "serde", + "serde-saphyr", "serde_json", "url", "zeroize", @@ -3911,9 +4014,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "simple_asn1" @@ -3945,6 +4048,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smallvec" +version = "2.0.0-alpha.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef784004ca8777809dcdad6ac37629f0a97caee4c685fcea805278d81dd8b857" + [[package]] name = "socket2" version = "0.6.1" @@ -4010,9 +4119,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.111" +version = "2.0.112" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "21f182278bf2d2bcb3c88b1b08a37df029d71ce3d3ae26168e3c653b213b99d4" dependencies = [ "proc-macro2", "quote", @@ -4036,7 +4145,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -4084,7 +4193,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4113,7 +4222,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -4124,7 +4233,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -4187,9 +4296,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.48.0" +version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "libc", @@ -4209,7 +4318,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -4297,7 +4406,7 @@ dependencies = [ "base64", "bytes", "h2", - "http 1.3.1", + "http 1.4.0", "http-body", "http-body-util", "hyper", @@ -4336,9 +4445,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "async-compression", "base64", @@ -4346,7 +4455,7 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http 1.3.1", + "http 1.4.0", "http-body", "http-body-util", "http-range-header", @@ -4385,7 +4494,7 @@ dependencies = [ "axum", "forwarded-header-value", "governor", - "http 1.3.1", + "http 1.4.0", "pin-project", "thiserror 2.0.17", "tonic", @@ -4395,9 +4504,9 @@ dependencies = [ [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -4407,20 +4516,20 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", ] @@ -4482,8 +4591,8 @@ dependencies = [ "rphonetic", "rust-argon2", "serde", + "serde-saphyr", "serde_json", - "serde_yaml", "shared", "socket2", "strsim", @@ -4510,7 +4619,7 @@ checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", - "http 1.3.1", + "http 1.4.0", "httparse", "log", "rand 0.9.2", @@ -4550,10 +4659,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] -name = "unsafe-libyaml" -version = "0.2.11" +name = "unicode-width" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "untrusted" @@ -4725,7 +4834,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", "wasm-bindgen-shared", ] @@ -4767,7 +4876,7 @@ checksum = "7150335716dce6028bead2b848e72f47b45e7b9422f64cccdc23bedca89affc1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -4816,9 +4925,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" dependencies = [ "rustls-pki-types", ] @@ -4845,7 +4954,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4921,7 +5030,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -4932,7 +5041,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -5217,7 +5326,7 @@ dependencies = [ "console_error_panic_hook", "futures", "gloo 0.10.0", - "implicit-clone", + "implicit-clone 0.4.9", "indexmap", "js-sys", "prokio", @@ -5271,7 +5380,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -5301,7 +5410,7 @@ checksum = "42bfd190a07ca8cfde7cd4c52b3ac463803dc07323db8c34daa697e86365978c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -5323,28 +5432,28 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.27" +version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.27" +version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] [[package]] @@ -5364,7 +5473,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", "synstructure", ] @@ -5404,9 +5513,15 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.112", ] +[[package]] +name = "zmij" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317f17ff091ac4515f17cc7a190d2769a8c9a96d227de5d64b500b01cda8f2cd" + [[package]] name = "zstd" version = "0.13.3" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index dbc861699..fb1550222 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -10,7 +10,7 @@ rust-version = "1.87.0" lol_html = "2.7" shared = { version = "3", path = "../shared" } serde = { version = "1.0", features = ["derive", "rc"] } -serde_yaml = "0.9" +serde-saphyr = "0.0.12" serde_json = { version = "1", features = ["raw_value"] } quick-xml = { version = "0.38", features = ["async-tokio"] } regex = "1.12" diff --git a/backend/src/api/config_file.rs b/backend/src/api/config_file.rs new file mode 100644 index 000000000..f92624167 --- /dev/null +++ b/backend/src/api/config_file.rs @@ -0,0 +1,122 @@ +use crate::api::model::{update_app_state_config, update_app_state_sources, AppState, EventMessage}; +use crate::model::{Config, SourcesConfig}; +use crate::utils; +use crate::utils::{prepare_sources_batch, read_config_file, read_sources_file}; +use arc_swap::access::Access; +use arc_swap::ArcSwap; +use log::{debug, error, info}; +use shared::error::TuliproxError; +use shared::model::{ConfigPaths, ConfigType}; +use std::path::Path; +use std::sync::Arc; + +pub enum ConfigFile { + Config, + ApiProxy, + Mapping, + Sources, + SourceFile, +} + +impl ConfigFile { + fn load_mapping(app_state: &Arc) -> Result<(), TuliproxError> { + let paths = > as Access>::load(&app_state.app_config.paths); + if let Some(mapping_file_path) = paths.mapping_file_path.as_ref() { + match utils::read_mappings(mapping_file_path, true) { + Ok(Some(mappings_cfg)) => { + app_state.app_config.set_mappings(mapping_file_path, &mappings_cfg); + info!("Loaded mapping file {mapping_file_path}"); + } + Ok(None) => { + info!("No mapping file loaded {mapping_file_path}"); + } + Err(err) => { + error!("Failed to load mapping file {err}"); + return Err(err); + } + } + } + Ok(()) + } + + 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); + info!("Loaded Api Proxy File: {:?}", &paths.api_proxy_file_path); + } + Ok(None) => { + let paths = > as Access>::load(&app_state.app_config.paths); + info!("Could not load Api Proxy File: {:?}", &paths.api_proxy_file_path); + } + Err(err) => { + error!("Failed to load api-proxy file {err}"); + return Err(err); + } + } + Ok(()) + } + + async fn load_config(app_state: &Arc) -> Result<(), TuliproxError> { + let paths = > as Access>::load(&app_state.app_config.paths); + let config_file = paths.config_file_path.as_str(); + let config_dto = read_config_file(config_file, true, true)?; + let mapping_changed = paths.mapping_file_path.as_ref() != config_dto.mapping_path.as_ref(); + let mut config: Config = Config::from(config_dto); + config.prepare(paths.config_path.as_str())?; + info!("Loaded config file {config_file}"); + update_app_state_config(app_state, config).await?; + if mapping_changed { + Self::load_mapping(app_state)?; + } + Ok(()) + } + + pub async fn load_sources(app_state: &Arc) -> Result<(), TuliproxError> { + let paths = > as Access>::load(&app_state.app_config.paths); + let sources_file = paths.sources_file_path.as_str(); + let mut sources_dto = { + let config = > as Access>::load(&app_state.app_config.config); + read_sources_file(sources_file, true, true, config.get_hdhr_device_overview().as_ref())? + }; + prepare_sources_batch(&mut sources_dto, true).await?; + let sources: SourcesConfig = SourcesConfig::try_from(sources_dto)?; + info!("Loaded sources file {sources_file}"); + update_app_state_sources(app_state, sources).await?; + // mappings are not stored, so we need to reload and apply them if sources change. + Self::load_mapping(app_state) + } + + async fn reload_source_file(app_state: &Arc, file: &Path) -> Result<(), TuliproxError> { + info!("Loaded sources file {}", file.display()); + // TODO selective update and not complete sources update ? + ConfigFile::load_sources(app_state).await + } + + pub(crate) async fn reload(&self, file_path: &Path, app_state: &Arc) -> Result<(), TuliproxError> { + debug!("File change detected {}", file_path.display()); + match self { + ConfigFile::ApiProxy => { + app_state.event_manager.send_event(EventMessage::ConfigChange(ConfigType::ApiProxy)); + ConfigFile::load_api_proxy(app_state).await + } + ConfigFile::Mapping => { + app_state.event_manager.send_event(EventMessage::ConfigChange(ConfigType::Mapping)); + ConfigFile::load_mapping(app_state) + } + ConfigFile::Config => { + app_state.event_manager.send_event(EventMessage::ConfigChange(ConfigType::Config)); + ConfigFile::load_config(app_state).await + } + ConfigFile::Sources => { + app_state.event_manager.send_event(EventMessage::ConfigChange(ConfigType::Sources)); + ConfigFile::load_sources(app_state).await + } + ConfigFile::SourceFile => { + app_state.event_manager.send_event(EventMessage::ConfigChange(ConfigType::Sources)); + ConfigFile::reload_source_file(app_state, file_path).await + } + } + } +} diff --git a/backend/src/api/config_watch.rs b/backend/src/api/config_watch.rs index 183ddfc93..eb829c764 100644 --- a/backend/src/api/config_watch.rs +++ b/backend/src/api/config_watch.rs @@ -1,128 +1,20 @@ -use crate::api::model::{update_app_state_config, update_app_state_sources, AppState, EventMessage}; +use crate::api::config_file::ConfigFile; +use crate::api::model::{AppState, EventMessage}; use crate::model::{Config, SourcesConfig}; use crate::utils; -use crate::utils::{is_directory, prepare_sources_batch, read_config_file, read_sources_file}; -use log::{debug, error, info}; +use crate::utils::is_directory; +use arc_swap::access::Access; +use arc_swap::ArcSwap; +use log::{error, info}; use notify::event::{AccessKind, AccessMode}; use notify::{recommended_watcher, EventKind, RecursiveMode, Watcher}; use shared::error::{TuliproxError, TuliproxErrorKind}; -use shared::model::{ConfigPaths, ConfigType}; +use shared::model::ConfigPaths; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; -use arc_swap::access::Access; -use arc_swap::ArcSwap; use tokio_util::sync::CancellationToken; -pub enum ConfigFile { - Config, - ApiProxy, - Mapping, - Sources, - SourceFile, -} - -impl ConfigFile { - fn load_mapping(app_state: &Arc) -> Result<(), TuliproxError> { - let paths = > as Access>::load(&app_state.app_config.paths); - if let Some(mapping_file_path) = paths.mapping_file_path.as_ref() { - match utils::read_mappings(mapping_file_path, true) { - Ok(Some(mappings_cfg)) => { - app_state.app_config.set_mappings(mapping_file_path, &mappings_cfg); - info!("Loaded mapping file {mapping_file_path}"); - } - Ok(None) => { - info!("No mapping file loaded {mapping_file_path}"); - } - Err(err) => { - error!("Failed to load mapping file {err}"); - return Err(err); - } - } - } - Ok(()) - } - - 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); - info!("Loaded Api Proxy File: {:?}", &paths.api_proxy_file_path); - } - Ok(None) => { - let paths = > as Access>::load(&app_state.app_config.paths); - info!("Could not load Api Proxy File: {:?}", &paths.api_proxy_file_path); - } - Err(err) => { - error!("Failed to load api-proxy file {err}"); - return Err(err); - } - } - Ok(()) - } - - async fn load_config(app_state: &Arc) -> Result<(), TuliproxError> { - let paths = > as Access>::load(&app_state.app_config.paths); - let config_file = paths.config_file_path.as_str(); - let config_dto = read_config_file(config_file, true, true)?; - let mapping_changed = paths.mapping_file_path.as_ref() != config_dto.mapping_path.as_ref(); - let mut config: Config = Config::from(config_dto); - config.prepare(paths.config_path.as_str())?; - info!("Loaded config file {config_file}"); - update_app_state_config(app_state, config).await?; - if mapping_changed { - Self::load_mapping(app_state)?; - } - Ok(()) - } - - pub async fn load_sources(app_state: &Arc) -> Result<(), TuliproxError> { - let paths = > as Access>::load(&app_state.app_config.paths); - let sources_file = paths.sources_file_path.as_str(); - let config = > as Access>::load(&app_state.app_config.config); - let mut sources_dto = read_sources_file(sources_file, true, true, config.get_hdhr_device_overview().as_ref())?; - prepare_sources_batch(&mut sources_dto, true).await?; - let sources: SourcesConfig = SourcesConfig::try_from(sources_dto)?; - info!("Loaded sources file {sources_file}"); - update_app_state_sources(app_state, sources).await?; - // mappings are not stored, so we need to reload and apply them if sources change. - Self::load_mapping(app_state) - } - - async fn load_source_file(app_state: &Arc, file: &Path) -> Result<(), TuliproxError> { - info!("Loaded sources file {}", file.display()); - // TODO selective update and not complete sources update ? - ConfigFile::load_sources(app_state).await - } - - pub(crate) async fn reload(&self, file_path: &Path, app_state: &Arc) -> Result<(), TuliproxError> { - debug!("File change detected {}", file_path.display()); - match self { - ConfigFile::ApiProxy => { - app_state.event_manager.send_event(EventMessage::ConfigChange(ConfigType::ApiProxy)); - ConfigFile::load_api_proxy(app_state).await - } - ConfigFile::Mapping => { - app_state.event_manager.send_event(EventMessage::ConfigChange(ConfigType::Mapping)); - ConfigFile::load_mapping(app_state) - } - ConfigFile::Config => { - app_state.event_manager.send_event(EventMessage::ConfigChange(ConfigType::Config)); - ConfigFile::load_config(app_state).await - } - ConfigFile::Sources => { - app_state.event_manager.send_event(EventMessage::ConfigChange(ConfigType::Sources)); - ConfigFile::load_sources(app_state).await - } - ConfigFile::SourceFile => { - app_state.event_manager.send_event(EventMessage::ConfigChange(ConfigType::Sources)); - ConfigFile::load_source_file(app_state, file_path).await - } - } - } -} - fn start_config_watch(app_state: &Arc, cancel_token: &CancellationToken) -> Result<(), TuliproxError> { // let (tx, mut rx) = mpsc::channel::>(100); let (std_tx, std_rx) = std::sync::mpsc::channel(); diff --git a/backend/src/api/endpoints/api_playlist_utils.rs b/backend/src/api/endpoints/api_playlist_utils.rs index 05a2624e7..e4f8e861d 100644 --- a/backend/src/api/endpoints/api_playlist_utils.rs +++ b/backend/src/api/endpoints/api_playlist_utils.rs @@ -150,7 +150,7 @@ pub(in crate::api::endpoints) async fn get_playlist(client: &reqwest::Client, cf let (result, errors) = match input.input_type { InputType::M3u | InputType::M3uBatch => m3u::download_m3u_playlist(client, cfg, input).await, - InputType::Xtream | InputType::XtreamBatch => xtream::download_xtream_playlist(cfg, client, input).await, + InputType::Xtream | InputType::XtreamBatch => xtream::download_xtream_playlist(cfg, client, input, None).await, InputType::Library => { return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({ "error": "Library inputs are not supported on this endpoint"}))).into_response(); } diff --git a/backend/src/api/endpoints/v1_api.rs b/backend/src/api/endpoints/v1_api.rs index 37889172f..1e010bab7 100644 --- a/backend/src/api/endpoints/v1_api.rs +++ b/backend/src/api/endpoints/v1_api.rs @@ -129,7 +129,6 @@ async fn geoip_update(axum::extract::State(app_state): axum::extract::State>) -> axum::response::Response { if let Some((ipv4, ipv6)) = create_ipinfo_check(&app_state).await { let ipcheck = IpCheckDto { diff --git a/backend/src/api/endpoints/v1_api_config.rs b/backend/src/api/endpoints/v1_api_config.rs index 0e17476b0..358448ec7 100644 --- a/backend/src/api/endpoints/v1_api_config.rs +++ b/backend/src/api/endpoints/v1_api_config.rs @@ -5,7 +5,7 @@ use axum::Router; use serde_json::json; use shared::model::{ApiProxyConfigDto, ApiProxyServerInfoDto, ConfigDto, SourcesConfigDto}; use std::sync::Arc; -use log::error; +use log::{error}; use shared::error::TuliproxError; use crate::api::api_utils::try_unwrap_body; use crate::{utils}; @@ -59,20 +59,17 @@ async fn save_config_sources( if let Err(err) = sources.prepare(false, None) { return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response(); } - let paths = app_state.app_config.paths.load(); - let file_path = paths.sources_file_path.as_str(); - let config = app_state.app_config.config.load(); - let backup_dir = config.get_backup_dir(); - match utils::save_sources_config(file_path, backup_dir.as_ref(), &sources).await { - Ok(()) => {} + + let sources_config = match utils::validate_and_persist_source_config(&app_state, sources).await { + Ok(value) => value, Err(err) => { error!("Failed to save source.yml {err}"); return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response(); } - } + }; - // update runtime (hot reload semantics like panel_api reload) - match crate::model::SourcesConfig::try_from(&sources) { + // update runtime + match crate::model::SourcesConfig::try_from(&sources_config) { Ok(src) => { if let Err(err) = app_state.app_config.set_sources(src) { return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response(); diff --git a/backend/src/api/mod.rs b/backend/src/api/mod.rs index 1741f4570..891ade21c 100644 --- a/backend/src/api/mod.rs +++ b/backend/src/api/mod.rs @@ -9,3 +9,4 @@ mod serve; pub(crate) mod hdhomerun_ssdp; pub(crate) mod hdhomerun_proprietary; mod sys_usage; +mod config_file; diff --git a/backend/src/api/model/active_provider_manager.rs b/backend/src/api/model/active_provider_manager.rs index c33744865..748ad3ab4 100644 --- a/backend/src/api/model/active_provider_manager.rs +++ b/backend/src/api/model/active_provider_manager.rs @@ -67,8 +67,7 @@ impl ActiveProviderManager { } fn get_config_inputs(cfg: &AppConfig) -> Vec> { - cfg.sources.load().sources - .iter().flat_map(|s| s.inputs.iter().map(Arc::clone)).collect() + cfg.sources.load().inputs.iter().map(Arc::clone).collect() } fn get_grace_options(cfg: &AppConfig) -> (u64, u64) { diff --git a/backend/src/api/model/xtream.rs b/backend/src/api/model/xtream.rs index 553e13768..e26835283 100644 --- a/backend/src/api/model/xtream.rs +++ b/backend/src/api/model/xtream.rs @@ -1,3 +1,4 @@ +use shared::utils::serialize_number_as_string; use crate::model::{ApiProxyServerInfo, ProxyUserCredentials}; use chrono::{Duration, Local}; use serde::{Deserialize, Serialize}; @@ -11,7 +12,9 @@ pub struct XtreamUserInfoResponse { pub active_cons: String, pub allowed_output_formats: Vec, pub auth: u16, // 0 | 1 + #[serde(serialize_with ="serialize_number_as_string")] pub created_at: i64, //1623429679, + #[serde(serialize_with ="serialize_number_as_string")] pub exp_date: i64, //1628755200, pub is_trial: String, // 0 | 1 pub max_connections: String, @@ -37,17 +40,6 @@ pub struct XtreamAuthorizationResponse { pub server_info: XtreamServerInfoResponse, } -// #[derive(Serialize)] -// pub struct XtreamServerInfoDto { -// pub url: String, -// pub port: String, -// pub path: Option, -// pub protocol: String, // http, https -// pub timezone: String, -// pub timestamp_now: i64, -// pub time_now: String, //"2021-06-28 17:07:37" -// } - impl XtreamAuthorizationResponse { pub fn new(server_info: &ApiProxyServerInfo, user: &ProxyUserCredentials, active_connections: u32, access_control: bool) -> Self { let now = Local::now(); diff --git a/backend/src/api/panel_api.rs b/backend/src/api/panel_api.rs index acc6b5987..f4bb32fe8 100644 --- a/backend/src/api/panel_api.rs +++ b/backend/src/api/panel_api.rs @@ -1,16 +1,16 @@ +use crate::api::config_file::ConfigFile; use crate::api::model::AppState; -use crate::model::{ConfigInput, is_input_expired, PanelApiQueryParam, PanelApiConfig}; -use crate::utils::{debug_if_enabled, save_sources_config}; -use crate::utils::{get_csv_file_path}; +use crate::model::{is_input_expired, ConfigInput, PanelApiConfig, PanelApiQueryParam}; +use crate::utils::{debug_if_enabled, persist_source_config, read_sources_file_from_path}; +use crate::utils::get_csv_file_path; use log::{debug, error, warn}; use serde_json::Value; use shared::error::{create_tuliprox_error_result, info_err, TuliproxError, TuliproxErrorKind}; -use shared::model::{InputType}; +use shared::model::{ConfigInputAliasDto, InputType}; use shared::utils::{get_credentials_from_url, parse_timestamp, sanitize_sensitive_info, trim_last_slash}; use std::path::{Path, PathBuf}; use std::sync::Arc; use url::Url; -use crate::api::config_watch::ConfigFile; #[derive(Debug, Clone)] struct AccountCredentials { @@ -300,79 +300,29 @@ async fn patch_source_yml_add_alias( password: &str, exp_date: Option, ) -> Result<(), TuliproxError> { - let raw = tokio::fs::read_to_string(source_file_path) - .await - .map_err(|e| TuliproxError::new(TuliproxErrorKind::Info, format!("panel_api: failed to read source file: {e}")))?; - let mut doc: serde_yaml::Value = serde_yaml::from_str(&raw) - .map_err(|e| TuliproxError::new(TuliproxErrorKind::Info, format!("panel_api: failed to parse source file yaml: {e}")))?; - - let Some(root) = doc.as_mapping_mut() else { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: source.yml root is not a mapping"); - }; - let sources = root.get_mut(serde_yaml::Value::String("sources".to_string())).and_then(|v| v.as_sequence_mut()); - let Some(sources) = sources else { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: source.yml missing 'sources' list"); + let mut sources = match read_sources_file_from_path(source_file_path, false, false, None) { + Ok(sources) => sources, + Err(e) => return Err(TuliproxError::new(TuliproxErrorKind::Info, format!("panel_api: failed to read source file: {e}"))), }; - let mut found_input = None; - for src in sources.iter_mut() { - let Some(src_map) = src.as_mapping_mut() else { continue; }; - let Some(inputs) = src_map.get_mut(serde_yaml::Value::String("inputs".to_string())).and_then(|v| v.as_sequence_mut()) else { continue; }; - for inp in inputs.iter_mut() { - let Some(inp_map) = inp.as_mapping_mut() else { continue; }; - let name = inp_map.get(serde_yaml::Value::String("name".to_string())).and_then(|v| v.as_str()); - if name == Some(input_name) { - found_input = Some(inp_map); - break; - } - } - if found_input.is_some() { - break; - } - } - let Some(inp_map) = found_input else { + let Some(input) = sources.inputs.iter_mut().find(|i| i.name == input_name) else { return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: could not find input '{input_name}' in source.yml"); }; - let aliases_key = serde_yaml::Value::String("aliases".to_string()); - let aliases_value = inp_map - .entry(aliases_key.clone()) - .or_insert_with(|| serde_yaml::Value::Sequence(vec![])); - match aliases_value { - serde_yaml::Value::Sequence(_) => {} - serde_yaml::Value::Null => { - *aliases_value = serde_yaml::Value::Sequence(vec![]); - } - serde_yaml::Value::Mapping(_) => { - // Some users might have a single alias stored as a mapping instead of a list. - // Normalize to a list so we can append safely. - let old = std::mem::replace(aliases_value, serde_yaml::Value::Sequence(vec![])); - if let serde_yaml::Value::Mapping(map) = old { - if let serde_yaml::Value::Sequence(seq) = aliases_value { - seq.push(serde_yaml::Value::Mapping(map)); - } - } - } - _ => { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: input.aliases is not a list in source.yml"); - } - } - let Some(alias_seq) = aliases_value.as_sequence_mut() else { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: input.aliases is not a list in source.yml"); + let alias = ConfigInputAliasDto { + id: 0, + name: alias_name.to_string(), + url: base_url.to_string(), + username: Some(username.to_string()), + password: Some(password.to_string()), + priority: 0, + max_connections: 0, + exp_date, }; - let mut alias_map = serde_yaml::Mapping::new(); - alias_map.insert(serde_yaml::Value::String("name".to_string()), serde_yaml::Value::String(alias_name.to_string())); - alias_map.insert(serde_yaml::Value::String("url".to_string()), serde_yaml::Value::String(base_url.to_string())); - alias_map.insert(serde_yaml::Value::String("username".to_string()), serde_yaml::Value::String(username.to_string())); - alias_map.insert(serde_yaml::Value::String("password".to_string()), serde_yaml::Value::String(password.to_string())); - alias_map.insert(serde_yaml::Value::String("max_connections".to_string()), serde_yaml::Value::Number(1.into())); - if let Some(ts) = exp_date { - alias_map.insert(serde_yaml::Value::String("exp_date".to_string()), serde_yaml::Value::Number(ts.into())); - } - alias_seq.push(serde_yaml::Value::Mapping(alias_map)); + input.upsert_alias(alias)?; - persist_source_config(app_state, source_file_path, &doc).await?; + persist_source_config(app_state, Some(source_file_path), sources).await?; Ok(()) } @@ -383,65 +333,21 @@ async fn patch_source_yml_update_exp_date( account_name: &str, exp_date: i64, ) -> Result<(), TuliproxError> { - let raw = tokio::fs::read_to_string(source_file_path) - .await - .map_err(|e| TuliproxError::new(TuliproxErrorKind::Info, format!("panel_api: failed to read source file: {e}")))?; - let mut doc: serde_yaml::Value = serde_yaml::from_str(&raw) - .map_err(|e| TuliproxError::new(TuliproxErrorKind::Info, format!("panel_api: failed to parse source file yaml: {e}")))?; - let Some(root) = doc.as_mapping_mut() else { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: source.yml root is not a mapping"); + let mut sources = match read_sources_file_from_path(source_file_path, false, false, None) { + Ok(sources) => sources, + Err(e) => return Err(TuliproxError::new(TuliproxErrorKind::Info, format!("panel_api: failed to read source file: {e}"))), }; - let sources = root.get_mut(serde_yaml::Value::String("sources".to_string())).and_then(|v| v.as_sequence_mut()); - let Some(sources) = sources else { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: source.yml missing 'sources' list"); - }; - for src in sources.iter_mut() { - let Some(src_map) = src.as_mapping_mut() else { continue; }; - let Some(inputs) = src_map.get_mut(serde_yaml::Value::String("inputs".to_string())).and_then(|v| v.as_sequence_mut()) else { continue; }; - for inp in inputs.iter_mut() { - let Some(inp_map) = inp.as_mapping_mut() else { continue; }; - let name = inp_map.get(serde_yaml::Value::String("name".to_string())).and_then(|v| v.as_str()); - if name != Some(input_name) { - continue; - } - if account_name == input_name { - inp_map.insert(serde_yaml::Value::String("exp_date".to_string()), serde_yaml::Value::Number(exp_date.into())); - inp_map.insert(serde_yaml::Value::String("enabled".to_string()), serde_yaml::Value::Bool(true)); - } else if let Some(aliases_value) = inp_map.get_mut(serde_yaml::Value::String("aliases".to_string())) { - // Normalize aliases to a sequence if the file contains a single mapping. - if matches!(aliases_value, serde_yaml::Value::Mapping(_)) { - let old = std::mem::replace(aliases_value, serde_yaml::Value::Sequence(vec![])); - if let serde_yaml::Value::Mapping(map) = old { - if let serde_yaml::Value::Sequence(seq) = aliases_value { - seq.push(serde_yaml::Value::Mapping(map)); - } - } - } - if let Some(aliases) = aliases_value.as_sequence_mut() { - for a in aliases.iter_mut() { - let Some(a_map) = a.as_mapping_mut() else { continue; }; - let a_name = a_map.get(serde_yaml::Value::String("name".to_string())).and_then(|v| v.as_str()); - if a_name == Some(account_name) { - a_map.insert(serde_yaml::Value::String("exp_date".to_string()), serde_yaml::Value::Number(exp_date.into())); - } - } - } - } - persist_source_config(app_state, source_file_path, &doc).await?; - return Ok(()); - } + let Some(input) = sources.inputs.iter_mut().find(|i| i.name == input_name) else { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: could not find input '{input_name}' in source.yml"); + }; + + if input.update_account_expiration_date(input_name, account_name, exp_date).is_err() { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: could not find account '{account_name}' under input '{input_name}' in source.yml"); } - create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: could not find account '{account_name}' under input '{input_name}' in source.yml") -} -async fn persist_source_config(app_state: &Arc, source_file_path: &Path, doc: &serde_yaml::Value) -> Result<(), TuliproxError> { - - let paths = app_state.app_config.paths.load(); - let source_file = paths.sources_file_path.as_str(); - let config = app_state.app_config.config.load(); - let backup_dir = config.get_backup_dir(); - save_sources_config(source_file_path.to_str().unwrap_or(source_file), &backup_dir, &doc).await + persist_source_config(app_state, Some(source_file_path), sources).await?; + Ok(()) } async fn patch_batch_csv_append( @@ -613,7 +519,7 @@ async fn try_renew_expired_account( } } - if let Err(err) = reload_sources(app_state).await { + if let Err(err) = ConfigFile::load_sources(app_state).await { debug_if_enabled!("panel_api reload sources failed: {}", err); } return true; @@ -689,7 +595,7 @@ async fn try_create_new_account( } } - if let Err(err) = reload_sources(app_state).await { + if let Err(err) = ConfigFile::load_sources(app_state).await { error!("panel_api reload sources failed: {err}"); return false; } @@ -752,81 +658,75 @@ pub(crate) async fn sync_panel_api_exp_dates_on_boot(app_state: &Arc) let mut any_change = false; let sources = app_state.app_config.sources.load(); - for source in &sources.sources { - for input in &source.inputs { - let Some(panel_cfg) = input.panel_api.as_ref() else { continue; }; - if !panel_cfg.enabled || panel_cfg.url.trim().is_empty() { - continue; - } + for input in &sources.inputs { + let Some(panel_cfg) = input.panel_api.as_ref() else { continue; }; + if !panel_cfg.enabled || panel_cfg.url.trim().is_empty() { + continue; + } - let is_batch = input.t_batch_url.as_ref().is_some_and(|u| !u.trim().is_empty()); - let batch_url = input.t_batch_url.as_deref().unwrap_or_default(); - let csv_path = if is_batch { get_csv_file_path(batch_url).ok() } else { None }; + let is_batch = input.t_batch_url.as_ref().is_some_and(|u| !u.trim().is_empty()); + let batch_url = input.t_batch_url.as_deref().unwrap_or_default(); + let csv_path = if is_batch { get_csv_file_path(batch_url).ok() } else { None }; - let mut accounts: Vec = vec![]; - if let Some((u, p)) = extract_account_creds_from_input(input.as_ref()) { - accounts.push(AccountCredentials { - name: input.name.clone(), - username: u, - password: p, - exp_date: input.exp_date, - }); - } - if let Some(aliases) = input.aliases.as_ref() { - for a in aliases { - if let (Some(u), Some(p)) = (a.username.as_deref(), a.password.as_deref()) { - accounts.push(AccountCredentials { - name: a.name.clone(), - username: u.to_string(), - password: p.to_string(), - exp_date: a.exp_date, - }); - } + let mut accounts: Vec = vec![]; + if let Some((u, p)) = extract_account_creds_from_input(input.as_ref()) { + accounts.push(AccountCredentials { + name: input.name.clone(), + username: u, + password: p, + exp_date: input.exp_date, + }); + } + if let Some(aliases) = input.aliases.as_ref() { + for a in aliases { + if let (Some(u), Some(p)) = (a.username.as_deref(), a.password.as_deref()) { + accounts.push(AccountCredentials { + name: a.name.clone(), + username: u.to_string(), + password: p.to_string(), + exp_date: a.exp_date, + }); } } + } - for acct in &accounts { - let new_exp = match panel_client_info(app_state.as_ref(), panel_cfg, &acct.username, &acct.password).await { - Ok(v) => v, - Err(err) => { - debug_if_enabled!( + for acct in &accounts { + let new_exp = match panel_client_info(app_state.as_ref(), panel_cfg, &acct.username, &acct.password).await { + Ok(v) => v, + Err(err) => { + debug_if_enabled!( "panel_api client_info failed for {}: {}", sanitize_sensitive_info(&acct.name), sanitize_sensitive_info(err.to_string().as_str()) ); - None - } - }; - let Some(new_exp) = new_exp else { continue; }; - if acct.exp_date == Some(new_exp) { + None + } + }; + let Some(new_exp) = new_exp else { continue; }; + if acct.exp_date == Some(new_exp) { + continue; + } + + if let Some(csv_path) = csv_path.as_ref() { + let _csv_lock = app_state.app_config.file_locks.write_lock(csv_path).await; + if let Err(err) = patch_batch_csv_update_exp_date(csv_path, &acct.name, &acct.username, &acct.password, new_exp).await { + debug_if_enabled!("panel_api boot sync failed to persist exp_date to csv: {}", err); continue; } - - if let Some(csv_path) = csv_path.as_ref() { - let _csv_lock = app_state.app_config.file_locks.write_lock(csv_path).await; - if let Err(err) = patch_batch_csv_update_exp_date(csv_path, &acct.name, &acct.username, &acct.password, new_exp).await { - debug_if_enabled!("panel_api boot sync failed to persist exp_date to csv: {}", err); - continue; - } - } else { - let _src_lock = app_state.app_config.file_locks.write_lock(&sources_path).await; - if let Err(err) = patch_source_yml_update_exp_date(app_state, &sources_path, &input.name, &acct.name, new_exp).await { - debug_if_enabled!("panel_api boot sync failed to persist exp_date to source.yml: {}", err); - continue; - } + } else { + let _src_lock = app_state.app_config.file_locks.write_lock(&sources_path).await; + if let Err(err) = patch_source_yml_update_exp_date(app_state, &sources_path, &input.name, &acct.name, new_exp).await { + debug_if_enabled!("panel_api boot sync failed to persist exp_date to source.yml: {}", err); + continue; } - any_change = true; } + any_change = true; } } if any_change { - if let Err(err) = reload_sources(app_state).await { + if let Err(err) = ConfigFile::load_sources(app_state).await { debug_if_enabled!("panel_api boot sync reload sources failed: {}", err); } } } - -async fn reload_sources(app_state: &Arc) -> Result<(), TuliproxError> { - ConfigFile::load_sources(app_state).await -} \ No newline at end of file diff --git a/backend/src/library/metadata_resolver.rs b/backend/src/library/metadata_resolver.rs index 289b9caae..aa3ed211d 100644 --- a/backend/src/library/metadata_resolver.rs +++ b/backend/src/library/metadata_resolver.rs @@ -1,9 +1,9 @@ use log::{debug, error, info, warn}; - +use shared::utils::TMDB_API_KEY; use crate::library::metadata::{MediaMetadata, MetadataSource, MovieMetadata, SeriesMetadata}; use crate::library::scanner::ScannedMediaFile; use crate::library::tmdb_client::TmdbClient; -use crate::library::{MediaGroup, MetadataStorage, TMDB_API_KEY}; +use crate::library::{MediaGroup, MetadataStorage}; use crate::model::LibraryConfig; use crate::ptt::{ptt_parse_title, PttMetadata}; diff --git a/backend/src/library/tmdb_client.rs b/backend/src/library/tmdb_client.rs index b4e2bfaab..3a79bf603 100644 --- a/backend/src/library/tmdb_client.rs +++ b/backend/src/library/tmdb_client.rs @@ -7,8 +7,6 @@ use std::collections::HashSet; use tokio::time::{sleep, Duration}; use url::Url; -pub const TMDB_API_KEY: &str = "4219e299c89411838049ab0dab19ebd5"; - // TODO make this configurable in Library tmdb config const TMDB_API_BASE_URL: &str = "https://api.themoviedb.org/3"; diff --git a/backend/src/main.rs b/backend/src/main.rs index f0310c5a6..2910634d3 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -259,7 +259,7 @@ async fn healthcheck(config_file: &str) -> bool { let path = std::path::PathBuf::from(config_file); match File::open(path) { Ok(file) => { - match serde_yaml::from_reader::<_, HealthcheckConfig>(config_file_reader(file, true)) { + match serde_saphyr::from_reader::<_, HealthcheckConfig>(config_file_reader(file, true)) { Ok(config) => { match reqwest::Client::new() .get(format!("http://localhost:{}/healthcheck", config.api.port)) diff --git a/backend/src/model/config/app.rs b/backend/src/model/config/app.rs index 7ae2f2d64..207eca44a 100644 --- a/backend/src/model/config/app.rs +++ b/backend/src/model/config/app.rs @@ -1,19 +1,19 @@ +use crate::api::model::TransportStreamBuffer; +use crate::model::{ApiProxyConfig, ApiProxyServerInfo, Config, ConfigInput, ConfigInputOptions, ConfigTarget, CustomStreamResponse, HdHomeRunConfig, Mappings, ProxyUserCredentials, SourcesConfig, TargetOutput}; +use crate::utils; +use arc_swap::access::Access; +use arc_swap::{ArcSwap, ArcSwapOption}; +use log::{error, warn}; +use rand::Rng; +use shared::create_tuliprox_error_result; +use shared::error::{TuliproxError, TuliproxErrorKind}; +use shared::model::ConfigPaths; use std::borrow::Cow; use std::collections::HashSet; use std::fs::File; use std::io::Read; use std::path::{Path, PathBuf}; use std::sync::Arc; -use arc_swap::{ArcSwap, ArcSwapOption}; -use arc_swap::access::Access; -use log::{error, warn}; -use rand::Rng; -use shared::create_tuliprox_error_result; -use shared::error::{TuliproxError, TuliproxErrorKind}; -use shared::model::ConfigPaths; -use crate::api::model::TransportStreamBuffer; -use crate::model::{ApiProxyConfig, ApiProxyServerInfo, Config, ConfigInput, ConfigInputOptions, ConfigTarget, CustomStreamResponse, HdHomeRunConfig, Mappings, ProxyUserCredentials, SourcesConfig, TargetOutput}; -use crate::utils; const CHANNEL_UNAVAILABLE: &str = "channel_unavailable.ts"; const USER_CONNECTIONS_EXHAUSTED: &str = "user_connections_exhausted.ts"; @@ -41,7 +41,6 @@ pub struct AppConfig { } impl AppConfig { - pub fn set_config(&self, config: Config) -> Result<(), TuliproxError> { self.config.store(Arc::new(config)); self.prepare_paths(); @@ -165,10 +164,21 @@ impl AppConfig { None => None } } - + pub fn get_inputs_for_target(&self, target_name: &str) -> Option>> { let sources = > as Access>::load(&self.sources); - sources.sources.iter().find_map(|s| s.get_inputs_for_target(target_name)) + if let Some(inputs) = sources.get_source_inputs_by_target_by_name(target_name) { + let result: Vec> = sources + .inputs + .iter() + .filter(|s| inputs.contains(&s.name)) + .map(Arc::clone) + .collect(); + if !result.is_empty() { + return Some(result) + } + } + None } pub fn get_target_for_username(&self, username: &str) -> Option<(ProxyUserCredentials, Arc)> { @@ -193,11 +203,9 @@ impl AppConfig { pub fn get_input_by_name(&self, input_name: &str) -> Option> { let sources = > as Access>::load(&self.sources); - for source in &sources.sources { - for input in &source.inputs { - if input.name == input_name { - return Some(Arc::clone(input)); - } + for input in &sources.inputs { + if input.name == input_name { + return Some(Arc::clone(input)); } } None @@ -205,11 +213,9 @@ impl AppConfig { pub fn get_input_options_by_name(&self, input_name: &str) -> Option { let sources = > as Access>::load(&self.sources); - for source in &sources.sources { - for input in &source.inputs { - if input.name == input_name { - return input.options.clone(); - } + for input in &sources.inputs { + if input.name == input_name { + return input.options.clone(); } } None @@ -217,8 +223,7 @@ impl AppConfig { pub fn get_input_by_id(&self, input_id: u16) -> Option> { let sources = > as Access>::load(&self.sources); - for source in &sources.sources { - for input in &source.inputs { + for input in &sources.inputs { if input.id == input_id { return Some(Arc::clone(input)); } @@ -229,7 +234,6 @@ impl AppConfig { } } } - } } None } @@ -240,32 +244,31 @@ impl AppConfig { } fn check_unique_input_names(&self) -> Result<(), TuliproxError> { - let mut seen_names = HashSet::new(); + let mut seen_names: HashSet = HashSet::new(); let sources = > as Access>::load(&self.sources); - for source in &sources.sources { - for input in &source.inputs { - let input_name = input.name.trim().to_string(); - if input_name.is_empty() { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "input name required"); - } - if seen_names.contains(input_name.as_str()) { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "input names should be unique: {}", input_name); - } - seen_names.insert(input_name); - if let Some(aliases) = &input.aliases { - for alias in aliases { - let input_name = alias.name.trim().to_string(); - if input_name.is_empty() { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "input name required"); - } - if seen_names.contains(input_name.as_str()) { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "input and alias names should be unique: {}", input_name); - } - seen_names.insert(input_name); + for input in &sources.inputs { + let input_name = input.name.trim(); + if input_name.is_empty() { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "input name required"); + } + if seen_names.contains(input_name) { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "input names should be unique: {}", input_name); + } + seen_names.insert(input_name.to_string()); + if let Some(aliases) = &input.aliases { + for alias in aliases { + let input_name = alias.name.trim().to_string(); + if input_name.is_empty() { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "input name required"); } + if seen_names.contains(&input_name) { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "input and alias names should be unique: {}", input_name); + } + seen_names.insert(input_name.clone()); } } } + Ok(()) } @@ -290,7 +293,6 @@ impl AppConfig { * if `include_computed` set to true for `app_state` */ pub fn prepare(&mut self, include_computed: bool) -> Result<(), TuliproxError> { - if include_computed { self.access_token_secret = generate_secret(); self.encrypt_secret = <&[u8] as TryInto<[u8; 16]>>::try_into(&generate_secret()[0..16]).map_err(|err| TuliproxError::new(TuliproxErrorKind::Info, err.to_string()))?; @@ -409,7 +411,6 @@ impl AppConfig { let server_info_name = user.server.as_ref().map_or("default", |server_name| server_name.as_str()); self.get_server_info(server_info_name) } - } diff --git a/backend/src/model/config/input.rs b/backend/src/model/config/input.rs index f4d04ea71..229c9c466 100644 --- a/backend/src/model/config/input.rs +++ b/backend/src/model/config/input.rs @@ -144,15 +144,17 @@ pub struct ConfigInput { pub exp_date: Option, pub t_batch_url: Option, pub panel_api: Option, + pub cache_duration_seconds: u64, } impl ConfigInput { pub fn prepare(&mut self) -> Result, TuliproxError> { let batch_file_path = self.prepare_batch(); - check_input_credentials!(self, self.input_type, false); - check_input_connections!(self, self.input_type); + self.name = self.name.trim().to_string(); + check_input_credentials!(self, self.input_type, false, false); + check_input_connections!(self, self.input_type, false); if let Some(staged_input) = &mut self.staged { - check_input_credentials!(staged_input, staged_input.input_type, false); + check_input_credentials!(staged_input, staged_input.input_type, false, true); if !matches!(staged_input.input_type, InputType::M3u | InputType::Xtream) { return Err(info_err!("Staged input can only be from type m3u or xtream".to_owned())); } @@ -249,6 +251,7 @@ impl ConfigInput { exp_date: None, t_batch_url: None, panel_api: self.panel_api.clone(), + cache_duration_seconds: self.cache_duration_seconds, } } } @@ -276,6 +279,7 @@ impl From<&ConfigInputDto> for ConfigInput { staged: dto.staged.as_ref().map(StagedInput::from), t_batch_url: None, panel_api: dto.panel_api.as_ref().map(PanelApiConfig::from), + cache_duration_seconds: dto.cache_duration_seconds, } } } diff --git a/backend/src/model/config/library.rs b/backend/src/model/config/library.rs index 395496358..1ef46c5c0 100644 --- a/backend/src/model/config/library.rs +++ b/backend/src/model/config/library.rs @@ -1,8 +1,9 @@ use crate::model::macros; use shared::error::TuliproxError; -use shared::model::{default_metadata_path, LibraryConfigDto, LibraryContentType, LibraryMetadataFormat}; +use shared::model::{LibraryConfigDto, LibraryContentType, LibraryMetadataFormat}; use std::path::PathBuf; use shared::info_err; +use shared::utils::default_metadata_path; #[derive(Debug, Clone, Default)] pub struct LibraryScanDirectory { diff --git a/backend/src/model/config/source.rs b/backend/src/model/config/source.rs index e341a4e00..b3434e54a 100644 --- a/backend/src/model/config/source.rs +++ b/backend/src/model/config/source.rs @@ -8,24 +8,11 @@ use std::sync::Arc; #[derive(Debug, Clone)] pub struct ConfigSource { - pub batch_files: Vec, - pub inputs: Vec>, + pub inputs: Vec, pub targets: Vec>, } impl ConfigSource { - pub fn get_inputs_for_target(&self, target_name: &str) -> Option>> { - for target in &self.targets { - if target.name.eq(target_name) { - let inputs = self.inputs.iter().filter(|&i| i.enabled).map(Arc::clone).collect::>>(); - if !inputs.is_empty() { - return Some(inputs); - } - } - } - None - } - // Determines whether this source should be processed for the given user targets. // // Returns `true` if: @@ -39,20 +26,11 @@ impl ConfigSource { } } -macros::try_from_impl!(ConfigSource); -impl TryFrom<&ConfigSourceDto> for ConfigSource { - type Error = TuliproxError; - fn try_from(dto: &ConfigSourceDto) -> Result { - let mut batch_files = Vec::new(); - let mut inputs: Vec = dto.inputs.iter().map(ConfigInput::from).collect(); - for input in &mut inputs { - if let Some(batch_file_path) = input.prepare()? { - batch_files.push(batch_file_path); - } - } +// macros::try_from_impl!(ConfigSource); +impl ConfigSource { + pub fn from_dto(dto: &ConfigSourceDto) -> Result { Ok(Self { - batch_files, - inputs: inputs.into_iter().map(Arc::new).collect(), + inputs: dto.inputs.clone(), targets: dto.targets.iter().map(|c| Arc::new(ConfigTarget::from(c))).collect(), }) } @@ -60,7 +38,9 @@ impl TryFrom<&ConfigSourceDto> for ConfigSource { #[derive(Default, Debug, Clone)] pub struct SourcesConfig { + pub batch_files: Vec, pub templates: Option>, + pub inputs: Vec>, pub sources: Vec, } @@ -68,20 +48,33 @@ macros::try_from_impl!(SourcesConfig); impl TryFrom<&SourcesConfigDto> for SourcesConfig { type Error = TuliproxError; fn try_from(dto: &SourcesConfigDto) -> Result { - let sources = dto.sources - .iter() - .map(ConfigSource::try_from) - .collect::, _>>()?; + let mut inputs = Vec::>::new(); + let mut batch_files = Vec::::new(); + + for input_dto in &dto.inputs { + let mut input = ConfigInput::from(input_dto); + // Prepare input + if let Some(path) = input.prepare()? { + batch_files.push(path); + } + inputs.push(Arc::new(input)); + } + + let mut sources = Vec::new(); + for source_dto in &dto.sources { + sources.push(ConfigSource::from_dto(source_dto)?); + } Ok(Self { + batch_files, templates: dto.templates.clone(), + inputs, sources, }) } } impl SourcesConfig { - pub(crate) fn get_source_at(&self, idx: usize) -> Option<&ConfigSource> { self.sources.get(idx) } @@ -97,6 +90,17 @@ impl SourcesConfig { None } + pub fn get_source_inputs_by_target_by_name(&self, target_name: &str) -> Option> { + for source in &self.sources { + for target in &source.targets { + if target.name == target_name { + return Some(source.inputs.clone()); + } + } + } + None + } + /// Returns the targets that were specified as parameters. /// If invalid targets are found, the program will be terminated. /// The return value has `enabled` set to true, if selective targets should be processed, otherwise false. @@ -106,29 +110,24 @@ impl SourcesConfig { /// pub fn validate_targets(&self, target_args: Option<&Vec>) -> Result { let mut enabled = true; - let mut inputs: Vec = vec![]; + let inputs: Vec = self.inputs.iter().map(|i| i.id).collect(); let mut targets: Vec = vec![]; let mut target_names: Vec = vec![]; if let Some(user_targets) = target_args { let mut check_targets: HashMap = user_targets.iter().map(|t| (t.to_lowercase(), 0)).collect(); for source in &self.sources { - let mut target_added = false; for target in &source.targets { for user_target in user_targets { let key = user_target.to_lowercase(); if target.name.eq_ignore_ascii_case(key.as_str()) { targets.push(target.id); target_names.push(target.name.clone()); - target_added = true; if let Some(value) = check_targets.get(key.as_str()) { check_targets.insert(key, value + 1); } } } } - if target_added { - source.inputs.iter().map(|i| i.id).for_each(|id| inputs.push(id)); - } } let missing_targets: Vec = check_targets.iter().filter(|&(_, v)| *v == 0).map(|(k, _)| k.clone()).collect(); @@ -163,11 +162,13 @@ impl SourcesConfig { pub fn get_input_files(&self) -> HashSet { let mut file_names = HashSet::new(); - for source in &self.sources { - for file in &source.batch_files { - file_names.insert(file.clone()); - } + for file in &self.batch_files { + file_names.insert(file.clone()); } file_names } + + pub fn get_input_by_name(&self, name: &str) -> Option<&Arc> { + self.inputs.iter().find(|i| i.name == name) + } } diff --git a/backend/src/model/config/trakt_api.rs b/backend/src/model/config/trakt_api.rs index abb4540dc..d1434db3c 100644 --- a/backend/src/model/config/trakt_api.rs +++ b/backend/src/model/config/trakt_api.rs @@ -1,3 +1,4 @@ +use shared::utils::is_blank_optional_string; use serde::{Deserialize, Serialize}; use shared::model::TraktContentType; use crate::utils::normalize_title_for_matching; @@ -6,12 +7,16 @@ use crate::utils::normalize_title_for_matching; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TraktListItem { pub id: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] pub rank: Option, pub listed_at: String, + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub notes: Option, #[serde(rename = "type")] pub item_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] pub movie: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub show: Option, #[serde(skip)] pub content_type: TraktContentType, diff --git a/backend/src/model/healthcheck.rs b/backend/src/model/healthcheck.rs index c4b665bc5..d8c1e9f56 100644 --- a/backend/src/model/healthcheck.rs +++ b/backend/src/model/healthcheck.rs @@ -1,10 +1,11 @@ +use shared::utils::is_blank_optional_string; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Healthcheck { pub status: String, pub version: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub build_time: Option, pub server_time: String, } diff --git a/backend/src/model/xtream.rs b/backend/src/model/xtream.rs index ef45b4bdd..122b0039e 100644 --- a/backend/src/model/xtream.rs +++ b/backend/src/model/xtream.rs @@ -14,7 +14,7 @@ pub struct XtreamLoginInfo { #[derive(Deserialize, Default)] pub struct XtreamCategory { - #[serde(deserialize_with = "deserialize_number_from_string_or_zero")] + #[serde(deserialize_with = "deserialize_number_from_string_or_zero", serialize_with = "shared::utils::serialize_number_as_string")] pub category_id: u32, #[serde(deserialize_with = "deserialize_as_string")] pub category_name: String, diff --git a/backend/src/processing/input_cache.rs b/backend/src/processing/input_cache.rs new file mode 100644 index 000000000..75a101e86 --- /dev/null +++ b/backend/src/processing/input_cache.rs @@ -0,0 +1,99 @@ +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use serde::{Deserialize, Serialize}; +use std::time::{SystemTime, UNIX_EPOCH}; +use log::{error, warn}; + +use crate::repository::storage::get_input_storage_path; + +pub const STATUS_FILE: &str = "status.json"; + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)] +pub enum ClusterState { + #[default] + Ok, + Failed, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ClusterStatus { + pub status: ClusterState, + pub timestamp: u64, +} + +#[derive(Serialize, Deserialize, Debug, Default, Clone)] +pub struct InputStatus { + #[serde(default)] + pub clusters: HashMap, +} + +pub fn resolve_input_storage_path(working_dir: &str, input_name: &str) -> PathBuf { + match get_input_storage_path(input_name, working_dir) { + Ok(path) => path, + Err(_) => Path::new(working_dir).join("data").join(format!("input_{input_name}")), // Fallback + } +} + + +pub fn load_input_status(path: &Path) -> InputStatus { + let status_path = path.join(STATUS_FILE); + if status_path.exists() { + match fs::read_to_string(&status_path) { + Ok(content) => match serde_json::from_str(&content) { + Ok(status) => return status, + Err(e) => warn!("Failed to parse input status file {}: {e}", status_path.display()), + }, + Err(e) => warn!("Failed to read input status file {}: {e}", status_path.display()), + } + } + InputStatus::default() +} + +pub fn save_input_status(path: &Path, status: &InputStatus) { + let status_path = path.join(STATUS_FILE); + if let Some(parent) = status_path.parent() { + if let Err(e) = fs::create_dir_all(parent) { + error!("Failed to create input storage directory {}: {e}", parent.display()); + return; + } + } + match serde_json::to_string_pretty(status) { + Ok(content) => { + if let Err(e) = fs::write(&status_path, content) { + error!("Failed to write input status file {}: {e}", status_path.display()); + } + }, + Err(e) => error!("Failed to serialize input status: {e}"), + } +} + +pub fn is_cache_valid(status: &InputStatus, cluster: &str, cache_duration_seconds: u64) -> bool { + if cache_duration_seconds == 0 { + return false; + } + if let Some(cluster_status) = status.clusters.get(cluster) { + if cluster_status.status != ClusterState::Ok { + return false; + } + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); + if now > cluster_status.timestamp { + // check if age is within duration + // timestamp is creation time. + // wait, assuming timestamp is Last Update Time. + // now - timestamp < duration + return now - cluster_status.timestamp < cache_duration_seconds; + } + // Timestamp in future? Invalid. + return false; + } + false +} + +pub fn update_cluster_status(status: &mut InputStatus, cluster: &str, state: ClusterState) { + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); + status.clusters.insert(cluster.to_string(), ClusterStatus { + status: state, + timestamp: now, + }); +} diff --git a/backend/src/processing/mod.rs b/backend/src/processing/mod.rs index e1ff80590..f9d8bc5c0 100644 --- a/backend/src/processing/mod.rs +++ b/backend/src/processing/mod.rs @@ -1,3 +1,4 @@ mod playlist_watch; pub(crate) mod parser; pub(crate) mod processor; +pub(crate) mod input_cache; diff --git a/backend/src/processing/parser/m3u.rs b/backend/src/processing/parser/m3u.rs index f35520d6b..4dc42eb86 100644 --- a/backend/src/processing/parser/m3u.rs +++ b/backend/src/processing/parser/m3u.rs @@ -1,7 +1,7 @@ use crate::model::{Config, ConfigInput}; use crate::utils::request::DynReader; -use shared::model::{PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, XtreamCluster, DEFAULT_VIDEO_EXTENSIONS}; -use shared::utils::extract_id_from_url; +use shared::model::{PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, XtreamCluster}; +use shared::utils::{default_supported_video_extensions, extract_id_from_url}; use std::borrow::BorrowMut; use tokio::io::AsyncBufReadExt; @@ -108,7 +108,7 @@ macro_rules! process_header_fields { }; } -fn process_header(input_name: &str, video_suffixes: &[&str], content: &str, url: String) -> PlaylistItemHeader { +fn process_header(input_name: &str, video_suffixes: &[String], content: &str, url: String) -> PlaylistItemHeader { let url_id = extract_id_from_url(&url); let url_types = if video_suffixes.iter().any(|suffix| url.ends_with(suffix)) { // TODO find Series based on group or configured names @@ -204,9 +204,9 @@ pub async fn consume_m3u(cfg: &Config, input: &ConfigInp let video_suffixes = match cfg.video.as_ref() { Some(config) => { - config.extensions.iter().map(String::as_str).collect::>() + config.extensions.iter().map(Clone::clone).collect::>() } - None => DEFAULT_VIDEO_EXTENSIONS.to_vec() + None => default_supported_video_extensions() }; let mut lines = tokio::io::BufReader::new(lines).lines(); while let Ok(Some(line)) = lines.next_line().await { diff --git a/backend/src/processing/processor/library.rs b/backend/src/processing/processor/library.rs index d948fff71..5461062d4 100644 --- a/backend/src/processing/processor/library.rs +++ b/backend/src/processing/processor/library.rs @@ -6,7 +6,7 @@ use shared::utils::{generate_playlist_uuid}; use std::path::Path; use std::sync::Arc; -pub async fn download_library_playlist(_client: &reqwest::Client, app_config: &Arc, input: &Arc) -> (Vec, Vec) { +pub async fn download_library_playlist(_client: &reqwest::Client, app_config: &Arc, input: &ConfigInput) -> (Vec, Vec) { let config = &*app_config.config.load(); let Some(library_config) = config.library.as_ref() else { return (vec![], vec![]) }; if !library_config.enabled { return (vec![], vec![]); } diff --git a/backend/src/processing/processor/playlist.rs b/backend/src/processing/processor/playlist.rs index f7cc67457..7a2a80425 100644 --- a/backend/src/processing/processor/playlist.rs +++ b/backend/src/processing/processor/playlist.rs @@ -15,6 +15,8 @@ use crate::model::FetchedPlaylist; use crate::model::Mapping; use crate::model::{ConfigTarget, ProcessTargets}; use crate::model::{InputStats, PlaylistStats, SourceStats, TargetStats}; +use crate::processing::input_cache; +use crate::processing::input_cache::ClusterState; use crate::processing::parser::xmltv::flatten_tvguide; use crate::processing::playlist_watch::process_group_watch; use crate::processing::processor::epg::process_playlist_epg; @@ -23,7 +25,7 @@ use crate::processing::processor::sort::sort_playlist; use crate::processing::processor::trakt::process_trakt_categories_for_target; use crate::processing::processor::xtream_series::playlist_resolve_series; use crate::processing::processor::xtream_vod::playlist_resolve_vod; -use crate::repository::playlist_repository::{persist_input_playlist, persist_playlist}; +use crate::repository::playlist_repository::{load_input_playlist, persist_input_playlist, persist_playlist}; use crate::utils::StepMeasure; use crate::utils::{debug_if_enabled, trace_if_enabled}; use futures::StreamExt; @@ -31,6 +33,7 @@ use indexmap::IndexMap; use log::{debug, error, info, log_enabled, trace, warn, Level}; use shared::error::{get_errors_notify_message, notify_err, TuliproxError}; use shared::foundation::filter::{get_field_value, set_field_value, Filter, ValueAccessor, ValueProvider}; +use shared::model::xtream_const::XTREAM_CLUSTER; use shared::model::{CounterModifier, FieldGetAccessor, FieldSetAccessor, InputType, ItemField, MsgKind, PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemType, PlaylistUpdateState, ProcessingOrder, UUIDType, XtreamCluster}; use shared::utils::{create_alias_uuid, default_as_default}; use std::time::Instant; @@ -244,14 +247,90 @@ fn is_target_enabled(target: &ConfigTarget, user_targets: &ProcessTargets) -> bo (!user_targets.enabled && target.enabled) || (user_targets.enabled && user_targets.has_target(target.id)) } -async fn playlist_download_from_input(client: &reqwest::Client, app_config: &Arc, input: &Arc) -> (Vec, Vec) { +async fn playlist_download_from_input(client: &reqwest::Client, app_config: &Arc, input: &ConfigInput) -> (Vec, Vec, bool) { let config = &*app_config.config.load(); - match input.input_type { + let working_dir = &config.working_dir; + + // Check Status + let storage_path = input_cache::resolve_input_storage_path(working_dir, &input.name); + let mut status = input_cache::load_input_status(&storage_path); + let cache_duration = input.cache_duration_seconds; + + // Ensure data directory exists + if !storage_path.exists() { + let _ = std::fs::create_dir_all(&storage_path); + } + + let (clusters_to_download, fully_cached) = match input.input_type { + InputType::Xtream => { + let mut to_download = vec![]; + for c in XTREAM_CLUSTER { + if !input_cache::is_cache_valid(&status, &c.to_string(), cache_duration) { + to_download.push(c); + } + } + if to_download.is_empty() { + (None, true) // Everything cached + } else { + (Some(to_download), false) + } + } + _ => { + // M3U / Library + if input_cache::is_cache_valid(&status, "default", cache_duration) { + (None, true) + } else { + (None, false) // Download all + } + } + }; + + if fully_cached { + return (vec![], vec![], true); + } + + let (playlist, errors) = match input.input_type { InputType::M3u => m3u::download_m3u_playlist(client, config, input).await, - InputType::Xtream => xtream::download_xtream_playlist(config, client, input).await, + InputType::Xtream => xtream::download_xtream_playlist(config, client, input, clusters_to_download.as_deref()).await, InputType::M3uBatch | InputType::XtreamBatch => (vec![], vec![]), InputType::Library => library::download_library_playlist(client, app_config, input).await, + }; + + // Update Status + if errors.is_empty() { + if let InputType::Xtream = input.input_type { + if let Some(clusters) = clusters_to_download { + for c in clusters { + input_cache::update_cluster_status(&mut status, &c.to_string(), ClusterState::Ok); + } + } else { + // All clusters logic if None passed (implies all were invalid or first run) + for c in XTREAM_CLUSTER { + input_cache::update_cluster_status(&mut status, &c.to_string(), ClusterState::Ok); + } + } + } else { + input_cache::update_cluster_status(&mut status, "default", ClusterState::Ok); + } + input_cache::save_input_status(&storage_path, &status); + } else { + // Mark failed? + // We could mark specific clusters as failed if we knew which one failed. + // For simplicity, if error, we don't update the timestamp (so it stays expired/invalid). + // Or we mark as Failed. + if let InputType::Xtream = input.input_type { + if let Some(clusters) = clusters_to_download { + for c in clusters { + // Optimistic: Only mark failed if we are sure? + // Currently just don't update the status to OK. + input_cache::update_cluster_status(&mut status, &c.to_string(), ClusterState::Failed); + } + } + input_cache::save_input_status(&storage_path, &status); + } } + + (playlist, errors, false) } async fn process_source(client: &reqwest::Client, app_config: Arc, source_idx: usize, @@ -264,23 +343,36 @@ async fn process_source(client: &reqwest::Client, app_config: Arc, so let mut target_stats = Vec::::new(); if let Some(source) = sources.get_source_at(source_idx) { let mut source_playlists = Vec::with_capacity(128); - let broadcast_step = create_broadcast_callback(event_manager.as_ref()); - // Download the sources let mut source_downloaded = false; - for input in &source.inputs { + for input_name in &source.inputs { + let Some(input) = sources.get_input_by_name(input_name) else { + error!("Input {input_name} referenced by source {source_idx} does not exist"); + continue; + }; if is_input_enabled(input, &user_targets) { source_downloaded = true; + let start_time = Instant::now(); - // Download playlist for input + // Download the playlist for input let (playlist_groups, mut error_list) = { broadcast_step("Playlist download", &format!("Downloading input '{}'", input.name)); - let (downloaded_playlist, mut download_err) = playlist_download_from_input(client, &app_config, input).await; - broadcast_step("Playlist download", &format!("Persisting input '{}' playlist", input.name)); - let (playlist, error) = persist_input_playlist(&app_config, input, downloaded_playlist).await; + // Caching Logic Integrated + let (downloaded_playlist, mut download_err, was_cached) = playlist_download_from_input(client, &app_config, input).await; + + let (playlist, error) = if was_cached { + match load_input_playlist(&app_config, input, None).await { + Ok(pl) => (pl, None), + Err(e) => (vec![], Some(e)), + } + } else { + broadcast_step("Playlist download", &format!("Persisting input '{}' playlist", input.name)); + persist_input_playlist(&app_config, input, downloaded_playlist).await + }; + if let Some(err) = error { - broadcast_step("Playlist download", &format!("Failed to persist input '{}' playlist", input.name)); + broadcast_step("Playlist download", &format!("Failed to persist/load input '{}' playlist", input.name)); error!("Failed to persist input playlist {}", input.name); download_err.push(err); } @@ -309,6 +401,17 @@ async fn process_source(client: &reqwest::Client, app_config: Arc, so source_playlists.push( FetchedPlaylist { input, + // If I create Arc here, it drops at end of loop iteration! + // SAFETY ISSUE. + // `source_playlists` stores `FetchedPlaylist`. + // `FetchedPlaylist` struct definition: + // pub struct FetchedPlaylist<'a> { pub input: &'a ConfigInput, ... } + // It holds a REFERENCE. + // If `input` comes from `sources` (guard), it lives as long as `sources` guard lives. + // `sources` guard is alive in this function scope. + // So `&input` is valid. + // `playlist_download_from_input` takes `&Arc`. I pass formatted Arc. + // `FetchedPlaylist` needs reference. playlist_groups, epg: tvguide, } @@ -322,7 +425,7 @@ async fn process_source(client: &reqwest::Client, app_config: Arc, so if source_downloaded { if source_playlists.is_empty() { debug!("Source at index {source_idx} is empty"); - errors.push(notify_err!(format!("Source at index {source_idx} is empty: {}", source.inputs.iter().map(|i| i.name.as_str()).collect::>().join(", ")))); + errors.push(notify_err!(format!("Source at index {source_idx} is empty: {}", source.inputs.iter().map(std::string::String::as_str).collect::>().join(", ")))); } else { debug_if_enabled!("Source has {} groups", source_playlists.iter().map(|fpl| fpl.playlist_groups.len()).sum::()); let event_manager_clone = event_manager.clone(); diff --git a/backend/src/repository/playlist_repository.rs b/backend/src/repository/playlist_repository.rs index 5149e76f5..7e8690180 100644 --- a/backend/src/repository/playlist_repository.rs +++ b/backend/src/repository/playlist_repository.rs @@ -8,7 +8,7 @@ use crate::repository::m3u_repository::{m3u_get_file_path, m3u_write_playlist}; use crate::repository::storage::{ensure_target_storage_path, get_input_storage_path, get_target_id_mapping_file, get_target_storage_path}; use crate::repository::strm_repository::write_strm_playlist; use crate::repository::target_id_mapping::{TargetIdMapping, VirtualIdRecord}; -use crate::repository::xtream_repository::{persist_input_xtream_playlist, xtream_get_file_path, xtream_get_storage_path, xtream_write_playlist}; +use crate::repository::xtream_repository::{load_input_xtream_playlist, persist_input_xtream_playlist, xtream_get_file_path, xtream_get_storage_path, xtream_write_playlist}; use crate::utils; use log::info; use shared::create_tuliprox_error; @@ -17,8 +17,10 @@ use shared::error::{info_err, TuliproxErrorKind}; use shared::model::{InputType, M3uPlaylistItem, PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, StreamProperties, XtreamCluster, XtreamPlaylistItem}; use shared::utils::{is_dash_url, is_hls_url}; use std::collections::HashMap; -use std::path::Path; +use std::path::{Path}; use std::sync::Arc; +use shared::model::xtream_const::XTREAM_CLUSTER; +use crate::utils::json_write_documents_to_file; struct LocalEpisodeKey { path: String, @@ -331,7 +333,7 @@ pub async fn load_target_into_memory_cache(app_state: &AppState, target: &Arc, input: &Arc, mut playlist: Vec) -> (Vec, Option) { +pub async fn persist_input_playlist(app_config: &Arc, input: &ConfigInput, mut playlist: Vec) -> (Vec, Option) { playlist.iter_mut().for_each(PlaylistGroup::on_load); let (result, err) = match input.input_type { @@ -345,8 +347,54 @@ pub async fn persist_input_playlist(app_config: &Arc, input: &Arc (playlist, None), + + _ => { + // Persist M3U/Other types + let working_dir = &app_config.config.load().working_dir; + let storage_path = match get_input_storage_path(&input.name, working_dir) { + Ok(storage_path) => storage_path, + Err(err) => { + return (playlist, Some(create_tuliprox_error!( TuliproxErrorKind::Info, "Error creating input storage directory for input '{}' failed: {err}", input.name))); + } + }; + let file_path = storage_path.join("playlist.json"); + match json_write_documents_to_file(&file_path, &playlist) { + Ok(()) => (playlist, None), + Err(e) => (playlist, Some(info_err!(format!("Failed to persist input playlist: {e}")))), + } + }, }; (result, err) +} + +pub async fn load_input_playlist(app_config: &Arc, input: &ConfigInput, clusters: Option<&[XtreamCluster]>) -> Result, TuliproxError> { + let working_dir = &app_config.config.load().working_dir; + let storage_path = get_input_storage_path(&input.name, working_dir) + .map_err(|e| create_tuliprox_error!(TuliproxErrorKind::Info, "Error getting input path: {e}"))?; + + match input.input_type { + InputType::Xtream | InputType::XtreamBatch => { + let clusters_to_load = if let Some(c) = clusters { + c + } else { + &XTREAM_CLUSTER + }; + + load_input_xtream_playlist(app_config, &storage_path, clusters_to_load).await + } + _ => { + // Load JSON for M3U + let file_path = storage_path.join("playlist.json"); + if file_path.exists() { + let content = tokio::fs::read_to_string(&file_path).await + .map_err(|e| info_err!(format!("Failed to read input playlist cache: {e}")))?; + let playlist: Vec = serde_json::from_str(&content) + .map_err(|e| info_err!(format!("Failed to parse input playlist cache: {e}")))?; + Ok(playlist) + } else { + Ok(vec![]) + } + } + } } \ No newline at end of file diff --git a/backend/src/repository/playlist_scratch.rs b/backend/src/repository/playlist_scratch.rs index 788301467..e42bea245 100644 --- a/backend/src/repository/playlist_scratch.rs +++ b/backend/src/repository/playlist_scratch.rs @@ -21,7 +21,6 @@ pub(in crate::repository) trait IsEmpty { fn is_empty(&self) -> bool; } - impl IsEmpty for Vec { fn is_empty(&self) -> bool { Vec::is_empty(self) diff --git a/backend/src/repository/storage.rs b/backend/src/repository/storage.rs index 132328f88..87f149aaa 100644 --- a/backend/src/repository/storage.rs +++ b/backend/src/repository/storage.rs @@ -28,8 +28,11 @@ pub fn get_target_storage_path(cfg: &Config, target_name: &str) -> Option std::io::Result { - let name = format!("input_{input_name}"); - let path = Path::new(working_dir).join(name); + let sanitized_name: String = input_name.chars() + .map(|c| if c.is_alphanumeric() { c } else { '_' }) + .collect(); + let name = format!("input_{sanitized_name}"); + let path = Path::new(working_dir).join("data").join(name); // Create the directory and return the path or propagate the error std::fs::create_dir_all(&path).map(|()| path) } diff --git a/backend/src/repository/strm_repository.rs b/backend/src/repository/strm_repository.rs index 529a7eff3..85bf85782 100644 --- a/backend/src/repository/strm_repository.rs +++ b/backend/src/repository/strm_repository.rs @@ -1,4 +1,5 @@ // Import the new MediaQuality struct +use shared::utils::is_blank_optional_string; use crate::model::MediaQuality; use crate::model::{ApiProxyServerInfo, AppConfig, ProxyUserCredentials}; use crate::model::{ConfigTarget, StrmTargetOutput}; @@ -132,15 +133,22 @@ struct StrmItemInfo { group: String, title: String, item_type: PlaylistItemType, + #[serde(default, skip_serializing_if = "Option::is_none")] provider_id: Option, virtual_id: u32, input_name: String, url: String, + #[serde(default, skip_serializing_if = "is_blank_optional_string")] series_name: Option, + #[serde(default, skip_serializing_if = "is_blank_optional_string")] release_date: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] season: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] episode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] added: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] tmdb_id: Option, } diff --git a/backend/src/repository/xtream_repository.rs b/backend/src/repository/xtream_repository.rs index 7e6069223..2a229c7c0 100644 --- a/backend/src/repository/xtream_repository.rs +++ b/backend/src/repository/xtream_repository.rs @@ -15,7 +15,7 @@ use bytes::Bytes; use futures::{stream, Stream, StreamExt}; use indexmap::IndexMap; use log::error; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use shared::error::{create_tuliprox_error, create_tuliprox_error_result, info_err, notify_err, string_to_io_error, TuliproxError, TuliproxErrorKind}; use shared::model::xtream_const::XTREAM_CLUSTER; @@ -239,7 +239,7 @@ pub fn xtream_get_file_path(storage_path: &Path, cluster: XtreamCluster) -> Path xtream_get_file_path_for_name(storage_path, &cluster.as_str().to_lowercase()) } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] struct CategoryEntry { category_id: u32, category_name: String, @@ -798,3 +798,52 @@ pub async fn persists_input_series_info(app_config: &Arc, storage_pat persist_input_info(app_config, storage_path, cluster, input_name, provider_id, StreamProperties::Series(Box::new(props.clone()))).await } +pub async fn load_input_xtream_playlist(app_config: &Arc, storage_path: &Path, clusters: &[XtreamCluster]) -> Result, TuliproxError> { + let mut groups: IndexMap = IndexMap::new(); + + for &cluster in clusters { + let xtream_path = xtream_get_file_path(storage_path, cluster); + if xtream_path.exists() { + let cat_col_name = match cluster { + XtreamCluster::Live => storage_const::COL_CAT_LIVE, + XtreamCluster::Video => storage_const::COL_CAT_VOD, + XtreamCluster::Series => storage_const::COL_CAT_SERIES, + }; + let cat_path = get_collection_path(storage_path, cat_col_name); + + if cat_path.exists() { + if let Ok(content) = tokio::fs::read_to_string(&cat_path).await { + if let Ok(cats) = serde_json::from_str::>(&content) { + for cat in cats { + groups.insert(cat.category_id, PlaylistGroup { + id: cat.category_id, + title: cat.category_name, + channels: Vec::new(), + xtream_cluster: cluster, + }); + } + } + } + } + + // Load Items + let _file_lock = app_config.file_locks.read_lock(&xtream_path).await; + if let Ok(mut query) = BPlusTreeQuery::::try_new(&xtream_path) { + for (_, ref item) in query.iter() { + let cat_id = item.category_id; + groups.entry(cat_id) + .or_insert_with(|| PlaylistGroup { + id: cat_id, + title: String::from("Unknown"), + channels: Vec::new(), + xtream_cluster: cluster, + }) + .channels.push(PlaylistItem::from(item)); + } + } + } + } + + Ok(groups.into_values().collect()) +} + diff --git a/backend/src/utils/file/config_reader.rs b/backend/src/utils/file/config_reader.rs index 088664fc7..2ea49897f 100644 --- a/backend/src/utils/file/config_reader.rs +++ b/backend/src/utils/file/config_reader.rs @@ -1,23 +1,24 @@ +use crate::api::model::AppState; use crate::model::Config; use crate::model::{ApiProxyConfig, AppConfig, SourcesConfig}; -use crate::{utils}; +use crate::repository::user_repository::{get_api_user_db_path, load_api_user}; use crate::utils::file_reader; use crate::utils::sys_utils::exit; use crate::utils::{open_file, read_mappings_file, EnvResolvingReader, FileLockManager}; +use crate::utils; +use arc_swap::access::Access; use arc_swap::{ArcSwap, ArcSwapAny}; use chrono::Local; use log::{error, info, warn}; use serde::Serialize; use shared::error::{create_tuliprox_error, info_err, TuliproxError, TuliproxErrorKind}; use shared::model::{ApiProxyConfigDto, AppConfigDto, ConfigDto, ConfigInputAliasDto, ConfigPaths, HdHomeRunDeviceOverview, InputType, SourcesConfigDto, TargetUserDto}; -use shared::utils::{CONSTANTS}; +use shared::utils::CONSTANTS; use std::env; use std::fs::File; use std::io::{self, Read}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; -use arc_swap::access::{Access}; -use crate::repository::user_repository::{get_api_user_db_path, load_api_user}; use tokio::fs; enum EitherReader { @@ -63,30 +64,35 @@ pub async fn read_api_proxy_config(config: &AppConfig, resolve_env: bool) -> Res } } -pub fn read_sources_file(sources_file: &str, resolve_env: bool, include_computed: bool, hdhr_config: Option<&HdHomeRunDeviceOverview>) -> Result { - match open_file(&std::path::PathBuf::from(sources_file)) { +pub fn read_sources_file_from_path(sources_file: &Path, resolve_env: bool, include_computed: bool, hdhr_config: Option<&HdHomeRunDeviceOverview>) -> Result { + match open_file(sources_file) { Ok(file) => { - let maybe_sources: Result = serde_yaml::from_reader(config_file_reader(file, resolve_env)); + let maybe_sources: Result = serde_saphyr::from_reader(config_file_reader(file, resolve_env)); match maybe_sources { Ok(mut sources) => { if resolve_env { if let Err(err) = sources.prepare(include_computed, hdhr_config) { - return Err(info_err!(format!("Can't read the sources-config file: {sources_file}: {err}"))); + return Err(info_err!(format!("Can't read the sources-config file: {}: {err}", sources_file.display()))); } } Ok(sources) } - Err(err) => Err(info_err!(format!("Can't read the sources-config file: {sources_file}: {err}"))) + Err(err) => Err(info_err!(format!("Can't read the sources-config file: {}: {err}", sources_file.display()))) } } - Err(err) => Err(info_err!(format!("Can't read the sources-config file: {sources_file}: {err}"))) + Err(err) => Err(info_err!(format!("Can't read the sources-config file: {}: {err}", sources_file.display()))) } } + +pub fn read_sources_file(sources_file: &str, resolve_env: bool, include_computed: bool, hdhr_config: Option<&HdHomeRunDeviceOverview>) -> Result { + read_sources_file_from_path(&PathBuf::from(sources_file), resolve_env, include_computed, hdhr_config) +} + pub fn read_config_file(config_file: &str, resolve_env: bool, include_computed: bool) -> Result { match open_file(&std::path::PathBuf::from(config_file)) { Ok(file) => { - let maybe_config: Result = serde_yaml::from_reader(config_file_reader(file, resolve_env)); + let maybe_config: Result = serde_saphyr::from_reader(config_file_reader(file, resolve_env)); match maybe_config { Ok(mut config) => { if resolve_env { @@ -127,43 +133,37 @@ pub fn read_app_config_dto(paths: &ConfigPaths, } pub async fn prepare_sources_batch(sources: &mut SourcesConfigDto, include_computed: bool) -> Result<(), TuliproxError> { - let mut current_index = 0; + let max_id_in_source = sources.inputs.iter() + .flat_map(|item| { + std::iter::once(item.id).chain( + item.aliases + .as_ref() + .into_iter() + .flatten() + .map(|alias| alias.id), + ) + }) + .max() + .unwrap_or(0); - for source in &mut sources.sources { - let max_id_in_source = source.inputs.iter() - .flat_map(|item| { - std::iter::once(item.id).chain( - item.aliases - .as_ref() - .into_iter() - .flatten() - .map(|alias| alias.id), - ) - }) - .max() - .unwrap_or(0); + current_index = std::cmp::max(current_index, max_id_in_source); - current_index = std::cmp::max(current_index, max_id_in_source); - } - - for source in &mut sources.sources { - for input in &mut source.inputs { - match get_batch_aliases(input.input_type, input.url.as_str()).await { - Ok(Some((_, aliases))) => { - if let Some(idx) = input.prepare_batch(aliases, current_index)? { - current_index = idx; - } - } - Ok(None) => {} - Err(err) => { - error!("Failed to read config files aliases: {err}"); - return Err(err); + for input in &mut sources.inputs { + match get_batch_aliases(input.input_type, input.url.as_str()).await { + Ok(Some((_, aliases))) => { + if let Some(idx) = input.prepare_batch(aliases, current_index)? { + current_index = idx; } } - // we need to prepare epg after alias, because epg `auto` depends on the first input url. - input.prepare_epg(include_computed)?; + Ok(None) => {} + Err(err) => { + error!("Failed to read config files aliases: {err}"); + return Err(err); + } } + // we need to prepare epg after alias, because epg `auto` depends on the first input url. + input.prepare_epg(include_computed)?; } Ok(()) } @@ -205,16 +205,16 @@ pub async fn prepare_users(app_config_dto: &mut AppConfigDto, app_config: &AppCo } pub async fn read_initial_app_config(paths: &mut ConfigPaths, - resolve_env: bool, - include_computed: bool, - server_mode: bool) -> Result { + resolve_env: bool, + include_computed: bool, + server_mode: bool) -> Result { let config_path = paths.config_path.as_str(); let config_file = paths.config_file_path.as_str(); let sources_file = paths.sources_file_path.as_str(); let config_dto = read_config_file(config_file, resolve_env, include_computed)?; let mut sources_dto = read_sources_file(sources_file, resolve_env, include_computed, config_dto.get_hdhr_device_overview().as_ref())?; - prepare_sources_batch(&mut sources_dto, include_computed).await?; + prepare_sources_batch(&mut sources_dto, include_computed).await?; let sources: SourcesConfig = SourcesConfig::try_from(sources_dto)?; let mut config: Config = Config::from(config_dto); config.prepare(config_path)?; @@ -263,7 +263,7 @@ pub async fn read_initial_app_config(paths: &mut ConfigPaths, pub fn read_api_proxy_file(api_proxy_file: &str, resolve_env: bool) -> Result, TuliproxError> { open_file(&std::path::PathBuf::from(api_proxy_file)).map_or(Ok(None), |file| { - let maybe_api_proxy: Result = serde_yaml::from_reader(config_file_reader(file, resolve_env)); + let maybe_api_proxy: Result = serde_saphyr::from_reader(config_file_reader(file, resolve_env)); match maybe_api_proxy { Ok(mut api_proxy_dto) => { if resolve_env { @@ -302,7 +302,7 @@ pub async fn read_api_proxy(config: &AppConfig, resolve_env: bool) -> Option(file_path: &str, backup_dir: &str, config: &T, default_name: &str, formatter: Option<&(dyn Fn(&str) -> String + Send + Sync)>) -> Result<(), TuliproxError> +async fn write_config_file(file_path: &str, backup_dir: &str, config: &T, default_name: &str) -> Result<(), TuliproxError> where T: ?Sized + Serialize, { @@ -317,213 +317,84 @@ where } info!("Saving file to {}", &path.to_str().unwrap_or("?")); - let mut serialized = serde_yaml::to_string(config) - .map_err(|err| create_tuliprox_error!(TuliproxErrorKind::Info, "Could not serialize file {}: {}", &path.to_str().unwrap_or("?"), err))?; - if let Some(format) = formatter { - serialized = format(&serialized); - } + let mut serialized = String::new(); + let options = serde_saphyr::SerializerOptions { + prefer_block_scalars: false, + ..Default::default() + }; + serde_saphyr::to_fmt_writer_with_options(&mut serialized, &config, options) + .map_err(|err| create_tuliprox_error!(TuliproxErrorKind::Info, "Could not serialize config: {}", err))?; fs::write(&path, serialized) .await .map_err(|err| create_tuliprox_error!(TuliproxErrorKind::Info, "Could not write file {}: {}", &path.to_str().unwrap_or("?"), err)) } -fn format_sources_yaml_panel_api_query_params_flow_style_impl(yaml: &str) -> String { - let has_trailing_newline = yaml.ends_with('\n'); - let lines: Vec<&str> = yaml.split_terminator('\n').collect(); - let mut out: Vec = Vec::with_capacity(lines.len()); - - let mut panel_api_indent: Option = None; - let mut query_indent: Option = None; - let mut section_indent: Option = None; - - let mut i = 0usize; - while i < lines.len() { - let line = lines[i]; - let trimmed = line.trim_start(); - let indent = line.len().saturating_sub(trimmed.len()); - - if !trimmed.is_empty() && !trimmed.starts_with('#') { - if let Some(pi) = panel_api_indent { - if indent <= pi && trimmed != "panel_api:" { - panel_api_indent = None; - query_indent = None; - section_indent = None; - } - } - if let Some(qi) = query_indent { - if indent <= qi && trimmed != "query_parameter:" { - query_indent = None; - section_indent = None; - } - } - if let Some(si) = section_indent { - if indent < si { - section_indent = None; - } - } - - if panel_api_indent.is_none() && trimmed == "panel_api:" { - panel_api_indent = Some(indent); - } else if panel_api_indent.is_some() && query_indent.is_none() && trimmed == "query_parameter:" { - query_indent = Some(indent); - } else if query_indent.is_some() - && matches!(trimmed, "client_info:" | "client_new:" | "client_renew:") - { - section_indent = Some(indent); - } - } - - let in_target_section = panel_api_indent.is_some() && query_indent.is_some() && section_indent.is_some(); - if in_target_section - && indent == section_indent.unwrap_or(usize::MAX) - && trimmed.starts_with("- key:") - && !trimmed.starts_with("- {") - && i + 1 < lines.len() - { - let next = lines[i + 1]; - let next_trimmed = next.trim_start(); - let next_indent = next.len().saturating_sub(next_trimmed.len()); - - if next_trimmed.starts_with("value:") - && next_indent == indent.saturating_add(2) - { - let key_val = trimmed - .strip_prefix("- key:") - .unwrap_or_default() - .trim(); - let value_val = next_trimmed - .strip_prefix("value:") - .unwrap_or_default() - .trim(); - - let new_line = format!( - "{}- {{ key: {}, value: {} }}", - " ".repeat(indent), - key_val, - value_val - ); - out.push(new_line); - i += 2; - continue; - } - } - - out.push(line.to_string()); - i += 1; - } - - if has_trailing_newline { - out.join("\n") + "\n" - } else { - out.join("\n") - } -} - -fn parse_yaml_key_value_line(s: &str) -> Option<(String, String)> { - let (key, value) = s.split_once(':')?; - let key = key.trim(); - if key.is_empty() { - return None; - } - let value = value.trim(); - let value = if value.is_empty() { "null" } else { value }; - Some((key.to_string(), value.to_string())) -} - -fn format_sources_yaml_aliases_flow_style_impl(yaml: &str) -> String { - let has_trailing_newline = yaml.ends_with('\n'); - let lines: Vec<&str> = yaml.split_terminator('\n').collect(); - let mut out: Vec = Vec::with_capacity(lines.len()); - - let mut aliases_indent: Option = None; - - let mut i = 0usize; - while i < lines.len() { - let line = lines[i]; - let trimmed = line.trim_start(); - let indent = line.len().saturating_sub(trimmed.len()); - - if !trimmed.is_empty() && !trimmed.starts_with('#') { - if let Some(ai) = aliases_indent { - if indent < ai || (indent == ai && trimmed != "aliases:" && !trimmed.starts_with("- ")) { - aliases_indent = None; - } - } - if trimmed == "aliases:" { - aliases_indent = Some(indent); - } - } - - // Convert: - // aliases: - // - name: foo - // url: http://... - // Into: - // - { name: foo, url: http://... } - if aliases_indent.is_some() && trimmed.starts_with("- ") && !trimmed.starts_with("- {") { - let item_indent = indent; - let first = trimmed.strip_prefix("- ").unwrap_or(trimmed); - if let Some((k, v)) = parse_yaml_key_value_line(first) { - let mut parts: Vec<(String, String)> = vec![(k, v)]; - let mut j = i + 1; - while j < lines.len() { - let l2 = lines[j]; - let t2 = l2.trim_start(); - let ind2 = l2.len().saturating_sub(t2.len()); - if t2.is_empty() || t2.starts_with('#') { - break; - } - if ind2 == item_indent.saturating_add(2) && !t2.starts_with("- ") { - if let Some((k2, v2)) = parse_yaml_key_value_line(t2) { - parts.push((k2, v2)); - j += 1; - continue; - } - } - break; - } - - let formatted = parts - .iter() - .map(|(kk, vv)| format!("{kk}: {vv}")) - .collect::>() - .join(", "); - out.push(format!("{}- {{ {} }}", " ".repeat(item_indent), formatted)); - i = j; - continue; - } - } - - out.push(line.to_string()); - i += 1; - } - - if has_trailing_newline { - out.join("\n") + "\n" - } else { - out.join("\n") - } -} - -pub fn format_sources_yaml_panel_api_query_params_flow_style(yaml: &str) -> String { - let yaml = format_sources_yaml_panel_api_query_params_flow_style_impl(yaml); - format_sources_yaml_aliases_flow_style_impl(&yaml) -} - pub async fn save_api_proxy(file_path: &str, backup_dir: &str, config: &ApiProxyConfigDto) -> Result<(), TuliproxError> { - write_config_file(file_path, backup_dir, config, "api-proxy.yml", None).await + write_config_file(file_path, backup_dir, config, "api-proxy.yml").await } pub async fn save_main_config(file_path: &str, backup_dir: &str, config: &ConfigDto) -> Result<(), TuliproxError> { - write_config_file(file_path, backup_dir, config, "config.yml", None).await + write_config_file(file_path, backup_dir, config, "config.yml").await } pub async fn save_sources_config(file_path: &str, backup_dir: &str, config: &T) -> Result<(), TuliproxError> where - T: ?Sized + Serialize + T: ?Sized + Serialize, { - write_config_file(file_path, backup_dir, config, "source.yml", Some(&format_sources_yaml_panel_api_query_params_flow_style)).await + write_config_file(file_path, backup_dir, config, "source.yml").await +} + +pub async fn persist_source_config(app_state: &Arc, source_file_path: Option<&Path>, doc: SourcesConfigDto) -> Result +{ + let source_file = { + source_file_path + .and_then(|p| p.to_str()) + .map_or_else(|| { + let paths = > as Access>::load(&app_state.app_config.paths); + paths.sources_file_path.clone() + }, ToString::to_string) + }; + let backup_dir = { + let config = > as Access>::load(&app_state.app_config.config); + config.get_backup_dir().to_string() + }; + + let mut source_config = doc.clone(); + for input in &mut source_config.inputs { + if matches!(input.input_type, InputType::XtreamBatch | InputType::M3uBatch) && utils::is_csv_file(input.url.as_str()) { + if let Some(aliases) = &input.aliases { + if let Err(err) = utils::csv_write_inputs(input.url.as_str(), aliases).await { + error!("Could not persist aliases to csv {}: {}", input.url, err); + } + } + input.aliases = None; + } + input.id = 0; + if let Some(aliases) = input.aliases.as_mut() { + for alias in aliases { + alias.id = 0; + } + } + } + for source in &mut source_config.sources { + for target in &mut source.targets { + target.id = 0; + } + } + + save_sources_config(&source_file, &backup_dir, &source_config).await?; + Ok(doc) +} + +pub async fn validate_and_persist_source_config(app_state: &Arc, dto: SourcesConfigDto) -> Result { + { + let mut new_dto = dto.clone(); + let config = > as Access>::load(&app_state.app_config.config); + new_dto.prepare(true, config.get_hdhr_device_overview().as_ref())?; + } + + persist_source_config(app_state, None, dto).await } pub fn resolve_env_var(value: &str) -> String { @@ -542,56 +413,10 @@ pub fn resolve_env_var(value: &str) -> String { #[cfg(test)] mod tests { use crate::utils::resolve_env_var; - use crate::utils::format_sources_yaml_panel_api_query_params_flow_style; #[test] fn test_resolve() { let resolved = resolve_env_var("${env:HOME}"); assert_eq!(resolved, std::env::var("HOME").unwrap()); } - - #[test] - fn test_sources_yaml_panel_api_query_params_flow_style() { - let input = r"sources: -- inputs: - - name: demo - panel_api: - url: https://example.invalid/api.php - query_parameter: - client_info: - - key: api_key - value: auto - - key: sub - value: '1' - client_new: - - key: type - value: m3u -"; - let out = format_sources_yaml_panel_api_query_params_flow_style(input); - assert!(out.contains("- { key: api_key, value: auto }")); - assert!(out.contains("- { key: sub, value: '1' }")); - assert!(out.contains("- { key: type, value: m3u }")); - assert!(!out.contains("- key: api_key\n")); - } - - #[test] - fn test_sources_yaml_aliases_flow_style() { - let input = r"sources: -- inputs: - - name: demo - aliases: - - name: demo-u1 - url: http://line.example.invalid - username: u1 - password: p1 - priority: 0 - max_connections: 1 - exp_date: 123 -"; - let out = format_sources_yaml_panel_api_query_params_flow_style(input); - assert!( - out.contains("- { name: demo-u1, url: http://line.example.invalid, username: u1, password: p1, priority: 0, max_connections: 1, exp_date: 123 }"), - "out:\n{out}" - ); - } } diff --git a/backend/src/utils/file/csv_input_reader.rs b/backend/src/utils/file/csv_input_reader.rs index 37f50f6d5..669ee1342 100644 --- a/backend/src/utils/file/csv_input_reader.rs +++ b/backend/src/utils/file/csv_input_reader.rs @@ -12,6 +12,10 @@ use crate::utils::request::get_local_file_content; const CSV_SEPARATOR: char = ';'; const HEADER_PREFIX: char = '#'; + +pub fn is_csv_file(url: &str) -> bool { + url.to_lowercase().ends_with(".csv") +} const FIELD_MAX_CON: &str = "max_connections"; const FIELD_PRIO: &str = "priority"; const FIELD_URL: &str = "url"; @@ -187,6 +191,43 @@ pub fn get_csv_file_path(file_uri: &str) -> Result { } } +pub async fn csv_write_inputs(file_uri: &str, aliases: &[ConfigInputAliasDto]) -> Result<(), Error> { + let file_path = get_csv_file_path(file_uri)?; + let mut content = String::new(); + content.push(HEADER_PREFIX); + content.push_str(FIELD_NAME); + content.push(CSV_SEPARATOR); + content.push_str(FIELD_USERNAME); + content.push(CSV_SEPARATOR); + content.push_str(FIELD_PASSWORD); + content.push(CSV_SEPARATOR); + content.push_str(FIELD_URL); + content.push(CSV_SEPARATOR); + content.push_str(FIELD_MAX_CON); + content.push(CSV_SEPARATOR); + content.push_str(FIELD_EXP_DATE); + content.push('\n'); + + for alias in aliases { + content.push_str(&alias.name); + content.push(CSV_SEPARATOR); + content.push_str(alias.username.as_deref().unwrap_or("")); + content.push(CSV_SEPARATOR); + content.push_str(alias.password.as_deref().unwrap_or("")); + content.push(CSV_SEPARATOR); + content.push_str(&alias.url); + content.push(CSV_SEPARATOR); + content.push_str(&alias.max_connections.to_string()); + content.push(CSV_SEPARATOR); + if let Some(exp) = alias.exp_date { + content.push_str(&shared::utils::unix_ts_to_str_with_format(exp, "%Y-%m-%d %H:%M:%S").unwrap_or_default()); + } + content.push('\n'); + } + + tokio::fs::write(file_path, content).await.map_err(to_io_error) +} + #[cfg(test)] mod tests { use crate::utils::file::csv_input_reader::csv_read_inputs_from_reader; diff --git a/backend/src/utils/file/mapping_reader.rs b/backend/src/utils/file/mapping_reader.rs index ca4282bef..fb55259d1 100644 --- a/backend/src/utils/file/mapping_reader.rs +++ b/backend/src/utils/file/mapping_reader.rs @@ -9,7 +9,7 @@ use shared::model::{MappingDefinitionDto, MappingDto, MappingsDto, PatternTempla fn read_mapping(mapping_file: &Path, resolve_var: bool, prepare_mappings: bool) -> Result, TuliproxError> { if let Ok(file) = open_file(mapping_file) { - let maybe_mapping: Result = serde_yaml::from_reader(config_file_reader(file, resolve_var)); + let maybe_mapping: Result = serde_saphyr::from_reader(config_file_reader(file, resolve_var)); return match maybe_mapping { Ok(mut mapping) => { if prepare_mappings { diff --git a/backend/src/utils/logging.rs b/backend/src/utils/logging.rs index f82cb8c36..d38dcd3a5 100644 --- a/backend/src/utils/logging.rs +++ b/backend/src/utils/logging.rs @@ -43,7 +43,7 @@ pub fn init_logger(user_log_level: Option<&str>, config_file: &str) { .or(env_log_level) // env .or_else(|| { // config File::open(config_file).ok() - .and_then(|file| serde_yaml::from_reader::<_, LogLevelConfig>(config_file_reader(file, true)) + .and_then(|file| serde_saphyr::from_reader::<_, LogLevelConfig>(config_file_reader(file, true)) .map_err(|e| error!("Failed to parse log config file: {e}")) .ok()) .and_then(|cfg| cfg.log.and_then(|l| l.log_level)) diff --git a/backend/src/utils/network/m3u.rs b/backend/src/utils/network/m3u.rs index 5a468d6b7..6e311a9db 100644 --- a/backend/src/utils/network/m3u.rs +++ b/backend/src/utils/network/m3u.rs @@ -6,11 +6,11 @@ use crate::processing::parser::m3u; use crate::utils::prepare_file_path; use crate::utils::request; -pub async fn download_m3u_playlist(client: &reqwest::Client, cfg: &Arc, input: &Arc) -> (Vec, Vec) { +pub async fn download_m3u_playlist(client: &reqwest::Client, cfg: &Arc, input: &ConfigInput) -> (Vec, Vec) { let working_dir = &cfg.working_dir; let input_source: InputSource = { match input.staged.as_ref() { - None => input.as_ref().into(), + None => input.into(), Some(staged) => staged.into(), } }; diff --git a/backend/src/utils/network/xtream.rs b/backend/src/utils/network/xtream.rs index ff874de17..153230a3a 100644 --- a/backend/src/utils/network/xtream.rs +++ b/backend/src/utils/network/xtream.rs @@ -329,11 +329,11 @@ pub async fn notify_account_expire(exp_date: Option, cfg: &Config, client: } } -pub async fn download_xtream_playlist(cfg: &Arc, client: &reqwest::Client, input: &Arc) +pub async fn download_xtream_playlist(cfg: &Arc, client: &reqwest::Client, input: &ConfigInput, clusters: Option<&[XtreamCluster]>) -> (Vec, Vec) { let input_source: InputSource = { match input.staged.as_ref() { - None => input.as_ref().into(), + None => input.into(), Some(staged) => staged.into(), } }; @@ -358,7 +358,8 @@ pub async fn download_xtream_playlist(cfg: &Arc, client: &reqwest::Clien let mut errors = vec![]; for (xtream_cluster, category, stream) in &ACTIONS { - if !skip_cluster.contains(xtream_cluster) { + let is_requested = clusters.is_none_or(|c| c.contains(xtream_cluster)); + if is_requested && !skip_cluster.contains(xtream_cluster) { let input_source_category = input_source.with_url(format!("{base_url}&action={category}")); let input_source_stream = input_source.with_url(format!("{base_url}&action={stream}")); let category_file_path = crate::utils::prepare_file_path(input.persist.as_deref(), @@ -400,7 +401,7 @@ pub async fn download_xtream_playlist(cfg: &Arc, client: &reqwest::Clien (playlist_groups, errors) } -async fn check_alias_user_state(cfg: &Arc, client: &reqwest::Client, input: &Arc) { +async fn check_alias_user_state(cfg: &Arc, client: &reqwest::Client, input: &ConfigInput) { if let Some(aliases) = input.aliases.as_ref() { for alias in aliases { if is_input_expired(alias.exp_date) { diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index 8c1730e08..111049068 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -355,7 +355,8 @@ "PANEL_STATUS_INVALID": "Invalid", "PANEL_STATUS_DISABLED": "Disabled", "VALIDATION": "Validation", - "TEMPLATES": "Templates" + "TEMPLATES": "Templates", + "CACHE_DURATION": "Cache duration" }, "TITLE": { "USER_BOUQUET_EDITOR": "Playlist Category Selection" @@ -373,6 +374,7 @@ "CLIPBOARD_NOT_SUPPORTED": "Clipboard not supported.\nYour browser or current context does not allow clipboard access.\nPlease use HTTPS or localhost.", "FAILED_TO_KICK_USER_STREAM": "Failed to kick user stream", "FAILED_TO_RETRIEVE_WEBPLAYER_URL": "Failed to retrieve webplayer URL", + "CONFIRM_SOURCES_SAVE": "Warning: This is an experimental feature.\nUse at your own risk.\n\nProceed?", "DOWNLOAD": { "SUCCESS": "Successfully downloaded", "FAIL": "Failed to download!", diff --git a/frontend/scss/_theme.scss b/frontend/scss/_theme.scss index 40bdfc6cb..cf08ef4c0 100644 --- a/frontend/scss/_theme.scss +++ b/frontend/scss/_theme.scss @@ -169,6 +169,9 @@ --popup-border-color: rgba(80, 100, 160, 0.25); --popup-color: #ffffff; + --tooltip-background-color: rgba(30, 34, 48, 0.9); + --tooltip-color: #ffffff; + --attention-color: #f57170; --warn-color: #e1b842; --active-color: #3ae093; diff --git a/frontend/scss/app/components/source_editor/_source_editor.scss b/frontend/scss/app/components/source_editor/_source_editor.scss index e7ff885ac..7f41e3324 100644 --- a/frontend/scss/app/components/source_editor/_source_editor.scss +++ b/frontend/scss/app/components/source_editor/_source_editor.scss @@ -1,13 +1,34 @@ .tp__source-editor { position: relative; display: flex; - flex-flow: row nowrap; + flex-flow: column; box-sizing: border-box; width: 100%; height: 100%; gap: var(--gap-default); font-size: 1rem; + &__header { + position: relative; + display: flex; + flex-flow: row nowrap; + box-sizing: border-box; + width: 100%; + gap: var(--gap-default); + font-size: 1rem; + } + + &__content { + position: relative; + display: flex; + flex-flow: row nowrap; + box-sizing: border-box; + width: 100%; + height: 100%; + gap: var(--gap-default); + font-size: 1rem; + } + &__sidebar { display: flex; flex-flow: column; diff --git a/frontend/src/app/components/config/panel_config_view.rs b/frontend/src/app/components/config/panel_config_view.rs index 8796d4dcb..35764c5f6 100644 --- a/frontend/src/app/components/config/panel_config_view.rs +++ b/frontend/src/app/components/config/panel_config_view.rs @@ -172,7 +172,9 @@ fn ensure_required_params(params: &mut Vec, section: Pane fn with_input_mut(form: &mut SourcesConfigDto, source_idx: usize, input_idx: usize, f: impl FnOnce(&mut ConfigInputDto)) { if let Some(source) = form.sources.get_mut(source_idx) { if let Some(input) = source.inputs.get_mut(input_idx) { - f(input); + if let Some(input) = form.inputs.iter_mut().find(|i| &i.name == input) { + f(input); + } } } } @@ -523,7 +525,12 @@ pub fn PanelConfigView() -> Html { .sources .iter() .enumerate() - .flat_map(|(sidx, s)| s.inputs.iter().enumerate().map(move |(iidx, inp)| (sidx, iidx, inp))) + .flat_map(|(sidx, s)| { + let form = &form_state.form; + s.inputs.iter().enumerate().filter_map(move |(iidx, name)| { + form.inputs.iter().find(|i| &i.name == name).map(|inp| (sidx, iidx, inp)) + }) + }) .collect::>(); html! { diff --git a/frontend/src/app/components/confirm_dialog.rs b/frontend/src/app/components/confirm_dialog.rs index c778acc51..fc0c051a1 100644 --- a/frontend/src/app/components/confirm_dialog.rs +++ b/frontend/src/app/components/confirm_dialog.rs @@ -42,6 +42,15 @@ pub fn ConfirmDialog(props: &ConfirmDialogProps) -> Html { let on_result = on_result.clone(); Callback::from(move |()| on_result(DialogResult::Cancel)) }; + + let title_html: Html = props.title + .split('\n') + .map(|line| html! { <> + { line } +
+ }) + .collect::(); + html! { Html { close_on_backdrop_click={props.close_on_backdrop_click} on_close={Some(on_close)} > -

{ &props.title }

+

{ title_html }

diff --git a/frontend/src/app/components/home.rs b/frontend/src/app/components/home.rs index efb71e9e7..66cb14700 100644 --- a/frontend/src/app/components/home.rs +++ b/frontend/src/app/components/home.rs @@ -2,7 +2,8 @@ use crate::app::components::{AppIcon, DashboardView, EpgView, IconButton, InputR use crate::app::context::{ConfigContext, PlaylistContext, StatusContext}; use crate::hooks::{use_server_status, use_service_context}; use crate::model::{EventMessage, ViewType}; -use shared::model::{AppConfigDto, LibraryScanSummaryStatus, PlaylistUpdateState, StatusCheck, SystemInfo}; +use shared::model::{AppConfigDto, ConfigInputDto, LibraryScanSummaryStatus, PlaylistUpdateState, StatusCheck, SystemInfo}; +use std::collections::HashMap; use std::future; use std::rc::Rc; use yew::prelude::*; @@ -103,15 +104,22 @@ pub fn Home() -> Html { let sources = use_memo(config.clone(), |config_ctx| { if let Some(cfg) = config_ctx.as_ref() { let mut sources = vec![]; + // Create a map for a faster lookup of global inputs by name + let inputs_map: HashMap = cfg.sources.inputs.iter().map(|i| (i.name.clone(), i)).collect(); + for source in &cfg.sources.sources { let mut inputs = vec![]; - for input_cfg in &source.inputs { - let input = Rc::new(input_cfg.clone()); - inputs.push(Rc::new(InputRow::Input(Rc::clone(&input)))); - if let Some(aliases) = input_cfg.aliases.as_ref() { - for alias in aliases { - inputs.push(Rc::new(InputRow::Alias(Rc::new(alias.clone()), Rc::clone(&input)))); + for input_name in &source.inputs { + if let Some(input_cfg) = inputs_map.get(input_name) { + let input = Rc::new((*input_cfg).clone()); + inputs.push(Rc::new(InputRow::Input(Rc::clone(&input)))); + if let Some(aliases) = input_cfg.aliases.as_ref() { + for alias in aliases { + inputs.push(Rc::new(InputRow::Alias(Rc::new(alias.clone()), Rc::clone(&input)))); + } } + } else { + log::error!("Input '{}' not found in global inputs", input_name); } } let mut targets = vec![]; diff --git a/frontend/src/app/components/playlist/input_table.rs b/frontend/src/app/components/playlist/input_table.rs index 161d2074c..46f579b3d 100644 --- a/frontend/src/app/components/playlist/input_table.rs +++ b/frontend/src/app/components/playlist/input_table.rs @@ -44,6 +44,10 @@ pub enum InputRow { #[derive(Properties, PartialEq, Clone)] pub struct InputTableProps { pub inputs: Option>>, + #[prop_or_default] + pub on_edit: Option>>, + #[prop_or_default] + pub on_delete: Option>, } #[function_component] @@ -181,19 +185,34 @@ pub fn InputTable(props: &InputTableProps) -> Html { let popup_is_open_state = popup_is_open.clone(); let confirm = dialog.clone(); let translate = translate.clone(); - // let selected_dto = selected_dto.clone(); + let on_edit = props.on_edit.clone(); + let on_delete = props.on_delete.clone(); + let selected_dto = selected_dto.clone(); + Callback::from(move |(name, _): (String, _)| { if let Ok(action) = TableAction::from_str(&name) { match action { - TableAction::Edit => {} + TableAction::Edit => { + if let (Some(on_edit), Some(selected)) = (&on_edit, &*selected_dto) { + if let InputRow::Input(dto) = &**selected { + on_edit.emit(dto.clone()); + } + } + } TableAction::Refresh => {} TableAction::Delete => { let confirm = confirm.clone(); let translator = translate.clone(); + let on_delete = on_delete.clone(); + let selected_dto = selected_dto.clone(); spawn_local(async move { let result = confirm.confirm(&translator.t("MESSAGES.CONFIRM_DELETE")).await; if result == DialogResult::Ok { - // TODO edit + if let (Some(on_delete), Some(selected)) = (on_delete, &*selected_dto) { + if let InputRow::Input(dto) = &**selected { + on_delete.emit(dto.name.clone()); + } + } } }); } diff --git a/frontend/src/app/components/source_editor/block_view.rs b/frontend/src/app/components/source_editor/block_view.rs index de6c315e5..830114562 100644 --- a/frontend/src/app/components/source_editor/block_view.rs +++ b/frontend/src/app/components/source_editor/block_view.rs @@ -93,7 +93,7 @@ pub fn BlockView(props: &BlockProps) -> Html { html! {
+ style={style} title={title.clone()}>
// Block handle (drag)
diff --git a/frontend/src/app/components/source_editor/editor_view.rs b/frontend/src/app/components/source_editor/editor_view.rs index ab05478d3..ab4e89ea3 100644 --- a/frontend/src/app/components/source_editor/editor_view.rs +++ b/frontend/src/app/components/source_editor/editor_view.rs @@ -1,13 +1,19 @@ 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, InputRow, PortStatus, SourceEditorContext, SourceEditorForm, SourceEditorSidebar, TextButton, BLOCK_HEADER_HEIGHT, BLOCK_HEIGHT, BLOCK_PORT_HEIGHT, BLOCK_WIDTH}; +use crate::app::{ConfigContext, PlaylistContext}; +use crate::hooks::use_service_context; +use shared::model::{ConfigInputDto, ConfigSourceDto, ConfigTargetDto, HdHomeRunTargetOutputDto, + M3uTargetOutputDto, StrmTargetOutputDto, TargetOutputDto, XtreamTargetOutputDto}; 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::platform::spawn_local; use yew::prelude::*; +use yew_i18n::use_translation; +use crate::model::DialogResult; +use crate::services::DialogService; const PENDING_LINE: &str = "pending-line"; const SELECTION_RECT: &str = "selection-rect"; @@ -15,6 +21,9 @@ 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; +const LABEL_SOURCE_EDITOR: &str = "LABEL.SOURCE_EDITOR"; +const LABEL_SAVE: &str = "LABEL.SAVE"; + type Position = (f32, f32); type MoveBlockParams = (f32, f32, Position, Vec<(BlockId, Position)>); @@ -219,6 +228,11 @@ fn create_output_instance(output: &TargetOutputDto) -> (BlockInstance, BlockType pub fn SourceEditor() -> Html { let canvas_ref = use_node_ref(); let playlist_ctx = use_context::().expect("Playlist context not found"); + let config_ctx = use_context::().expect("ConfigContext not found"); + let dialog = use_context::().expect("Dialog service not found"); + let services = use_service_context(); + let translate = use_translation(); + let force_update = use_state(|| 0); // ----------------- virtual canvas offset ----------------- let editor_state_ref = use_mut_ref(EditorState::default); @@ -235,17 +249,24 @@ pub fn SourceEditor() -> Html { let mut current_id = 1; let mut gen_blocks = Vec::new(); let mut gen_connections = Vec::new(); + let mut added_inputs = HashMap::::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()))); + let block_id = if let Some(&existing_id) = added_inputs.get(&input_config.id) { + existing_id + } else { + let id = current_id; + current_id += 1; + added_inputs.insert(input_config.id, id); + gen_blocks.push(create_block(id, + BlockType::from(input_config.input_type), + BlockInstance::Input(input_config.clone()))); + id + }; + input_ids.push(block_id); } InputRow::Alias(_, _) => {} } @@ -330,6 +351,109 @@ pub fn SourceEditor() -> Html { }) }; + let handle_save = { + let config_ctx = config_ctx.clone(); + let editor_state_ref = editor_state_ref.clone(); + let services = services.clone(); + let translate = translate.clone(); + Callback::from(move |_| { + let mut sources_config = config_ctx.config.as_ref().map(|c| c.sources.clone()).unwrap_or_default(); + let editor_state = editor_state_ref.borrow(); + + // Convert diagram blocks to sources + let mut gen_sources: Vec = Vec::new(); + let mut gen_inputs: Vec = Vec::new(); + + //find all inputs + let input_blocks: Vec<&Block> = editor_state.blocks.iter().filter(|b| b.block_type.is_input()).collect(); + for block in &input_blocks { + if let BlockInstance::Input(input) = &block.instance { + gen_inputs.push(input.as_ref().clone()); + } + }; + + // Find all targets + let target_blocks: Vec<&Block> = editor_state.blocks.iter().filter(|b| b.block_type.is_target()).collect(); + + for target_block in target_blocks { + if let BlockInstance::Target(target_dto) = &target_block.instance { + let mut source_dto = ConfigSourceDto { + targets: vec![(**target_dto).clone()], + inputs: Vec::new(), + }; + + // Find incoming inputs + for conn in &editor_state.connections { + if conn.to == target_block.id { + if let Some(input_block) = editor_state.get_block(conn.from) { + if input_block.block_type.is_input() { + if let BlockInstance::Input(input_dto) = &input_block.instance { + source_dto.inputs.push(input_dto.name.clone()); + } + } + } + } + } + + // Find connected outputs for this target + // Actually, the current ConfigTargetDto in the diagram might already have outputs in its `output` field if we edited it there. + // But in the diagram, we have separate Output blocks connected to Target. + // We need to collect them. + + let mut outputs = Vec::new(); + for conn in &editor_state.connections { + if conn.from == target_block.id { + if let Some(output_block) = editor_state.get_block(conn.to) { + if output_block.block_type.is_output() { + if let BlockInstance::Output(output_dto) = &output_block.instance { + outputs.push((**output_dto).clone()); + } + } + } + } + } + + if let Some(target) = source_dto.targets.get_mut(0) { + target.output = outputs; + } + + gen_sources.push(source_dto); + } + } + + sources_config.inputs = gen_inputs; + sources_config.sources = gen_sources; + + web_sys::console::log_1(&format!("save {:?}", sources_config).into()); + + let services = services.clone(); + let translate = translate.clone(); + wasm_bindgen_futures::spawn_local(async move { + match services.config.save_sources(sources_config).await { + Ok(()) => services.toastr.success(translate.t("MESSAGES.SAVE.SOURCES_CONFIG.SUCCESS")), + Err(err) => services.toastr.error(err.to_string()), + } + }); + }) + }; + + let handle_confirm_save = { + let confirm = dialog.clone(); + let on_save = handle_save.clone(); + let translator = translate.clone(); + Callback::from(move |_| { + let confirm = confirm.clone(); + let on_save = on_save.clone(); + let translator = translator.clone(); + spawn_local(async move { + let result = confirm.confirm(&translator.t("MESSAGES.CONFIRM_SOURCES_SAVE")).await; + if result == DialogResult::Ok { + on_save.emit("dave"); + } + }); + }) + }; + // ----------------- Drag Start from Sidebar ----------------- let handle_drag_start = { let editor_state_ref = editor_state_ref.clone(); @@ -913,8 +1037,18 @@ pub fn SourceEditor() -> Html { // ----------------- Render ----------------- html! { context={editor_context}> - {"WORK IN PROGRESS - NOT FINALIZED !!!"}
+
+

{ translate.t(LABEL_SOURCE_EDITOR) }

+
+
+ +
+
Html {
+
> } diff --git a/frontend/src/app/components/source_editor/input_form.rs b/frontend/src/app/components/source_editor/input_form.rs index e8ca22386..d4642a54d 100644 --- a/frontend/src/app/components/source_editor/input_form.rs +++ b/frontend/src/app/components/source_editor/input_form.rs @@ -33,6 +33,7 @@ const LABEL_XTREAM_SKIP_VOD: &str = "LABEL.VOD"; const LABEL_XTREAM_SKIP_SERIES: &str = "LABEL.SERIES"; const LABEL_XTREAM_LIVE_STREAM_USE_PREFIX: &str = "LABEL.LIVE_STREAM_USE_PREFIX"; const LABEL_XTREAM_LIVE_STREAM_WITHOUT_EXTENSION: &str = "LABEL.LIVE_STREAM_WITHOUT_EXTENSION"; +const LABEL_CACHE_DURATION: &str = "LABEL.CACHE_DURATION"; #[derive(Copy, Clone, PartialEq, Eq)] @@ -93,19 +94,25 @@ generate_form_reducer!( MaxConnections => max_connections: u16, Method => method: InputFetchMethod, ExpDate => exp_date: Option, + CacheDuration => cache_duration: Option, } ); #[derive(Properties, PartialEq, Clone)] pub struct ConfigInputViewProps { - pub(crate) block_id: BlockId, + #[prop_or_default] + pub(crate) block_id: Option, pub(crate) input: Option>, + #[prop_or_default] + pub(crate) on_apply: Option>, + #[prop_or_default] + pub(crate) on_cancel: Option>, } #[function_component] pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { let translate = use_translation(); - let source_editor_ctx = use_context::().expect("SourceEditorContext not found"); + let source_editor_ctx = use_context::(); let fetch_methods = use_memo((), |_| { [InputFetchMethod::GET, InputFetchMethod::POST] .iter() @@ -376,6 +383,7 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { { edit_field_number_u16!(input_form_state, translate.t(LABEL_MAX_CONNECTIONS), max_connections, ConfigInputFormAction::MaxConnections) } { edit_field_number_i16!(input_form_state, translate.t(LABEL_PRIORITY), priority, ConfigInputFormAction::Priority) } { edit_field_date!(input_form_state, translate.t(LABEL_EXP_DATE), exp_date, ConfigInputFormAction::ExpDate) } + { edit_field_text_option!(input_form_state, translate.t(LABEL_CACHE_DURATION), cache_duration, ConfigInputFormAction::CacheDuration) } { config_field_child!(translate.t(LABEL_FETCH_METHOD), { html! { Html { } }; - let handle_apply_input = { // source_editor_ctx.on_form_change.emit(); + let handle_apply_input = { + let on_apply = props.on_apply.clone(); + let block_id = props.block_id; let source_editor_ctx = source_editor_ctx.clone(); let input_form_state = input_form_state.clone(); let input_options_state = input_options_state.clone(); @@ -503,7 +513,7 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { let headers_state = headers_state.clone(); let epg_sources_state = epg_sources_state.clone(); let aliases_state = aliases_state.clone(); - let block_id = props.block_id; + Callback::from(move |_| { let mut input = input_form_state.data().clone(); @@ -548,14 +558,23 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { Some(aliases) }; - source_editor_ctx.on_form_change.emit((block_id, BlockInstance::Input(Rc::new(input)))); - source_editor_ctx.edit_mode.set(EditMode::Inactive); + if let Some(on_apply) = &on_apply { + on_apply.emit(input); + } else if let (Some(ctx), Some(block_id)) = (&source_editor_ctx, block_id) { + ctx.on_form_change.emit((block_id, BlockInstance::Input(Rc::new(input)))); + ctx.edit_mode.set(EditMode::Inactive); + } }) }; let handle_cancel = { let source_editor_ctx = source_editor_ctx.clone(); + let on_cancel = props.on_cancel.clone(); Callback::from(move |_| { - source_editor_ctx.edit_mode.set(EditMode::Inactive); + if let Some(on_cancel) = &on_cancel { + on_cancel.emit(()); + } else if let Some(ctx) = &source_editor_ctx { + ctx.edit_mode.set(EditMode::Inactive); + } }) }; diff --git a/frontend/src/app/components/source_editor/sidebar.rs b/frontend/src/app/components/source_editor/sidebar.rs index dbd3520e4..327e021aa 100644 --- a/frontend/src/app/components/source_editor/sidebar.rs +++ b/frontend/src/app/components/source_editor/sidebar.rs @@ -1,4 +1,5 @@ -use crate::app::components::{BlockType, CollapsePanel, IconButton}; +use crate::app::components::{CollapsePanel, IconButton}; +use crate::app::components::source_editor::editor_model::BlockType; use yew::prelude::*; use yew_i18n::use_translation; diff --git a/shared/Cargo.toml b/shared/Cargo.toml index 409454a0b..d4ca5e701 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -15,7 +15,7 @@ url = "2.5.7" indexmap = "2" pest = "2" pest_derive = "2" -serde_json = { version = "1", features = ["raw_value"] } +serde_json = "1" base64 = "0" blake3 = "1" fastrand = { version = "2", features = ["js"] } @@ -26,6 +26,8 @@ ciborium = "0.2.2" hex = "0.4.3" lz4_flex = "0.12.0" deunicode = "1.6.2" +serde-saphyr = "0.0.12" [target.'cfg(target_arch = "wasm32")'.dependencies] js-sys = "0.3.83" +getrandom = { version = "0.3", features = ["wasm_js"] } \ No newline at end of file diff --git a/shared/src/foundation/mapper.rs b/shared/src/foundation/mapper.rs index 81b12da65..3aa4203c8 100644 --- a/shared/src/foundation/mapper.rs +++ b/shared/src/foundation/mapper.rs @@ -1440,6 +1440,7 @@ mod tests { let mut accessor = ValueAccessor { pli, virtual_items: vec![], + match_as_ascii: false, }; mapper.eval(&mut accessor, None); println!("Result: {pli:?}"); @@ -1536,6 +1537,7 @@ mod tests { let mut accessor = ValueAccessor { pli, virtual_items: vec![], + match_as_ascii: false, }; mapper.eval(&mut accessor, None); println!("Result: {pli:?}"); @@ -1559,7 +1561,7 @@ mod tests { ..Default::default() } }; - let mut accessor = ValueAccessor { pli: &mut video, virtual_items: vec![] }; + let mut accessor = ValueAccessor { pli: &mut video, virtual_items: vec![], match_as_ascii: false }; mapper.eval(&mut accessor, None); assert_eq!(accessor.virtual_items.len(), 1); assert_eq!(accessor.virtual_items[0].1.header.group, "My Favs"); diff --git a/shared/src/model/config/api.rs b/shared/src/model/config/api.rs index 6597c8f85..2f9ab37b7 100644 --- a/shared/src/model/config/api.rs +++ b/shared/src/model/config/api.rs @@ -3,7 +3,7 @@ pub struct ConfigApiDto { pub host: String, pub port: u16, - #[serde(default)] + #[serde(default, skip_serializing_if = "String::is_empty")] pub web_root: String, } diff --git a/shared/src/model/config/api_proxy.rs b/shared/src/model/config/api_proxy.rs index a6a462451..48540003b 100644 --- a/shared/src/model/config/api_proxy.rs +++ b/shared/src/model/config/api_proxy.rs @@ -1,3 +1,5 @@ +use crate::utils::is_false; +use crate::utils::is_blank_optional_string; use std::collections::HashSet; use crate::error::{info_err, TuliproxError}; use crate::model::{ProxyUserCredentialsDto}; @@ -14,20 +16,22 @@ pub struct ApiProxyServerInfoDto { pub name: String, pub protocol: String, pub host: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub port: Option, pub timezone: String, pub message: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub path: Option, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct ApiProxyConfigDto { + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub server: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub user: Vec, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub use_user_db: bool, } diff --git a/shared/src/model/config/api_user.rs b/shared/src/model/config/api_user.rs index 68a9a5d35..923feca59 100644 --- a/shared/src/model/config/api_user.rs +++ b/shared/src/model/config/api_user.rs @@ -1,4 +1,4 @@ -use crate::utils::{default_as_true, deserialize_timestamp}; +use crate::utils::{is_blank_optional_string, is_true, default_as_true, deserialize_timestamp}; use crate::error::{TuliproxError, TuliproxErrorKind}; use crate::model::{ProxyType, ProxyUserStatus}; @@ -14,25 +14,25 @@ pub enum UserConnectionPermission { pub struct ProxyUserCredentialsDto { pub username: String, pub password: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub token: Option, #[serde(default = "ProxyType::default")] pub proxy: ProxyType, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub server: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub epg_timeshift: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub created_at: Option, - #[serde(default, deserialize_with = "deserialize_timestamp")] + #[serde(default, deserialize_with = "deserialize_timestamp", skip_serializing_if = "Option::is_none")] pub exp_date: Option, #[serde(default)] pub max_connections: u32, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub status: Option, - #[serde(default = "default_as_true")] + #[serde(default = "default_as_true", skip_serializing_if = "is_true")] pub ui_enabled: bool, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub comment: Option, } diff --git a/shared/src/model/config/app_config.rs b/shared/src/model/config/app_config.rs index 415db32b6..06591b6a0 100644 --- a/shared/src/model/config/app_config.rs +++ b/shared/src/model/config/app_config.rs @@ -7,4 +7,4 @@ pub struct AppConfigDto { pub sources: SourcesConfigDto, pub mappings: Option, pub api_proxy: Option, -} \ No newline at end of file +} diff --git a/shared/src/model/config/base.rs b/shared/src/model/config/base.rs index 3d93968f8..ffd9b1ff8 100644 --- a/shared/src/model/config/base.rs +++ b/shared/src/model/config/base.rs @@ -1,6 +1,8 @@ use crate::error::{TuliproxError, TuliproxErrorKind}; -use crate::model::{ConfigApiDto, HdHomeRunConfigDto, IpCheckConfigDto, LibraryConfigDto, LogConfigDto, MessagingConfigDto, ProxyConfigDto, ReverseProxyConfigDto, ScheduleConfigDto, VideoConfigDto, WebUiConfigDto, DEFAULT_VIDEO_EXTENSIONS}; -use crate::utils::default_connect_timeout_secs; +use crate::model::{ConfigApiDto, HdHomeRunConfigDto, IpCheckConfigDto, LibraryConfigDto, LogConfigDto, MessagingConfigDto, + ProxyConfigDto, ReverseProxyConfigDto, ScheduleConfigDto, VideoConfigDto, WebUiConfigDto}; +use crate::utils::{is_false, default_connect_timeout_secs, is_default_connect_timeout_secs, is_blank_optional_string, + default_supported_video_extensions}; pub const DEFAULT_USER_AGENT: &str = "VLC/3.0.16 LibVLC/3.0.16"; @@ -8,49 +10,49 @@ pub const DEFAULT_USER_AGENT: &str = "VLC/3.0.16 LibVLC/3.0.16"; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] #[serde(deny_unknown_fields)] pub struct ConfigDto { - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub process_parallel: bool, pub api: ConfigApiDto, pub working_dir: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub backup_dir: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub user_config_dir: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub mapping_path: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub custom_stream_response_path: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub video: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub schedules: Option>, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub log: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub user_access_control: bool, - #[serde(default = "default_connect_timeout_secs")] + #[serde(default = "default_connect_timeout_secs", skip_serializing_if = "is_default_connect_timeout_secs")] pub connect_timeout_secs: u32, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub sleep_timer_mins: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub update_on_boot: bool, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub config_hot_reload: bool, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub accept_insecure_ssl_certificates: bool, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub web_ui: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub messaging: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub reverse_proxy: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub hdhomerun: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub proxy: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub ipcheck: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub library: Option, } @@ -58,28 +60,28 @@ pub struct ConfigDto { // It has no other purpose than editing and saving the simple config values #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] pub struct MainConfigDto { - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub process_parallel: bool, pub working_dir: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub backup_dir: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub user_config_dir: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub mapping_path: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub custom_stream_response_path: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub user_access_control: bool, - #[serde(default = "default_connect_timeout_secs")] + #[serde(default = "default_connect_timeout_secs", skip_serializing_if = "is_default_connect_timeout_secs")] pub connect_timeout_secs: u32, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub sleep_timer_mins: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub update_on_boot: bool, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub config_hot_reload: bool, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub accept_insecure_ssl_certificates: bool, } @@ -195,7 +197,7 @@ impl ConfigDto { match &mut self.video { None => { self.video = Some(VideoConfigDto { - extensions: DEFAULT_VIDEO_EXTENSIONS.iter().map(ToString::to_string).collect(), + extensions: default_supported_video_extensions(), download: None, web_search: None, }); diff --git a/shared/src/model/config/cache.rs b/shared/src/model/config/cache.rs index c27da6ec7..12818f2dd 100644 --- a/shared/src/model/config/cache.rs +++ b/shared/src/model/config/cache.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use crate::error::{TuliproxError}; use crate::info_err; -use crate::utils::{is_blank_optional_string, parse_size_base_2}; +use crate::utils::{is_blank_optional_str, is_blank_optional_string, parse_size_base_2}; use path_clean::PathClean; @@ -10,15 +10,15 @@ use path_clean::PathClean; pub struct CacheConfigDto { #[serde(default)] pub enabled: bool, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub size: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub dir: Option, } impl CacheConfigDto { pub fn is_empty(&self) -> bool { - !self.enabled && is_blank_optional_string(self.size.as_deref()) && is_blank_optional_string(self.dir.as_deref()) + !self.enabled && is_blank_optional_str(self.size.as_deref()) && is_blank_optional_str(self.dir.as_deref()) } pub(crate) fn prepare(&mut self, working_dir: &str) -> Result<(), TuliproxError> { diff --git a/shared/src/model/config/epg.rs b/shared/src/model/config/epg.rs index 4b63f0233..a7c3cf062 100644 --- a/shared/src/model/config/epg.rs +++ b/shared/src/model/config/epg.rs @@ -1,6 +1,7 @@ use crate::{info_err}; use crate::error::{TuliproxError}; use crate::model::EpgSmartMatchConfigDto; +use crate::utils::{is_false}; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] #[serde(deny_unknown_fields)] @@ -8,7 +9,7 @@ pub struct EpgSourceDto { pub url: String, #[serde(default)] pub priority: i16, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub logo_override: bool, } @@ -27,9 +28,9 @@ impl EpgSourceDto { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Default)] #[serde(deny_unknown_fields)] pub struct EpgConfigDto { - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub sources: Option>, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub smart_match: Option, #[serde(skip)] pub t_sources: Vec, diff --git a/shared/src/model/config/epg_smart_match.rs b/shared/src/model/config/epg_smart_match.rs index 149ee11ad..0c74897af 100644 --- a/shared/src/model/config/epg_smart_match.rs +++ b/shared/src/model/config/epg_smart_match.rs @@ -1,6 +1,9 @@ +use crate::utils::is_blank_optional_string; use std::fmt::Display; use log::warn; use crate::error::{create_tuliprox_error_result, TuliproxError, TuliproxErrorKind}; +use crate::utils::{is_false, default_match_threshold, default_best_match_threshold, + is_default_match_threshold, is_default_best_match_threshold}; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "lowercase")] @@ -32,18 +35,19 @@ impl Display for EpgNamePrefix { pub struct EpgSmartMatchConfigDto { #[serde(default)] pub enabled: bool, + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub normalize_regex: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub strip: Option>, #[serde(default)] pub name_prefix: EpgNamePrefix, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub name_prefix_separator: Option>, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub fuzzy_matching: bool, - #[serde(default)] + #[serde(default = "default_match_threshold", skip_serializing_if = "is_default_match_threshold")] pub match_threshold: u16, - #[serde(default)] + #[serde(default = "default_best_match_threshold", skip_serializing_if = "is_default_best_match_threshold")] pub best_match_threshold: u16, } impl Default for EpgSmartMatchConfigDto { @@ -55,8 +59,8 @@ impl Default for EpgSmartMatchConfigDto { name_prefix: EpgNamePrefix::default(), name_prefix_separator: None, fuzzy_matching: false, - match_threshold: 80, - best_match_threshold: 95, + match_threshold: default_match_threshold(), + best_match_threshold: default_best_match_threshold(), } } } diff --git a/shared/src/model/config/hdhomerun.rs b/shared/src/model/config/hdhomerun.rs index 289d2d526..33ee89c2a 100644 --- a/shared/src/model/config/hdhomerun.rs +++ b/shared/src/model/config/hdhomerun.rs @@ -1,37 +1,35 @@ use crate::create_tuliprox_error_result; use crate::error::{TuliproxError, TuliproxErrorKind}; -use crate::utils::{default_as_true, generate_hdhr_device_id, generate_hdhr_device_id_from_base, hash_string, validate_hdhr_device_id}; +use crate::utils::{is_false, is_true, default_as_true, default_device_type, default_device_udn, + default_firmware_name, default_firmware_version, default_friendly_name, + default_manufacturer, default_model_name, generate_hdhr_device_id, + generate_hdhr_device_id_from_base, hash_string, is_default_device_type, + is_default_device_udn, is_default_firmware_name, is_default_firmware_version, + is_default_friendly_name, is_default_manufacturer, is_default_model_name, + validate_hdhr_device_id}; use log::warn; use std::collections::HashSet; -fn default_friendly_name() -> String { String::from("TuliproxTV") } -fn default_manufacturer() -> String { String::from("Silicondust") } -fn default_model_name() -> String { String::from("HDTC-2US") } -fn default_firmware_name() -> String { String::from("hdhomeruntc_atsc") } -fn default_firmware_version() -> String { String::from("20170930") } -fn default_device_type() -> String { String::from("urn:schemas-upnp-org:device:MediaServer:1") } -fn default_device_udn() -> String { String::from("uuid:12345678-90ab-cdef-1234-567890abcdef::urn:dial-multicast:com.silicondust.hdhomerun") } - #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct HdHomeRunDeviceConfigDto { - #[serde(default = "default_friendly_name")] + #[serde(default = "default_friendly_name", skip_serializing_if = "is_default_friendly_name")] pub friendly_name: String, - #[serde(default = "default_manufacturer")] + #[serde(default = "default_manufacturer", skip_serializing_if = "is_default_manufacturer")] pub manufacturer: String, - #[serde(default = "default_model_name")] + #[serde(default = "default_model_name", skip_serializing_if = "is_default_model_name")] pub model_name: String, - #[serde(default = "default_model_name")] + #[serde(default = "default_model_name", skip_serializing_if = "is_default_model_name")] pub model_number: String, - #[serde(default = "default_firmware_name")] + #[serde(default = "default_firmware_name", skip_serializing_if = "is_default_firmware_name")] pub firmware_name: String, - #[serde(default = "default_firmware_version")] + #[serde(default = "default_firmware_version", skip_serializing_if = "is_default_firmware_version")] pub firmware_version: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "String::is_empty")] pub device_id: String, - #[serde(default = "default_device_type")] + #[serde(default = "default_device_type", skip_serializing_if = "is_default_device_type")] pub device_type: String, - #[serde(default = "default_device_udn")] + #[serde(default = "default_device_udn", skip_serializing_if = "is_default_device_udn")] pub device_udn: String, pub name: String, #[serde(default)] @@ -116,11 +114,11 @@ impl HdHomeRunDeviceConfigDto { pub struct HdHomeRunConfigDto { #[serde(default)] pub enabled: bool, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub auth: bool, - #[serde(default = "default_as_true")] + #[serde(default = "default_as_true", skip_serializing_if = "is_true")] pub ssdp_discovery: bool, - #[serde(default = "default_as_true")] + #[serde(default = "default_as_true", skip_serializing_if = "is_true")] pub proprietary_discovery: bool, pub devices: Vec, } diff --git a/shared/src/model/config/input.rs b/shared/src/model/config/input.rs index 59c9ddc1d..41c126628 100644 --- a/shared/src/model/config/input.rs +++ b/shared/src/model/config/input.rs @@ -1,8 +1,11 @@ +use crate::utils::is_blank_optional_string; use crate::error::{TuliproxError, TuliproxErrorKind}; use crate::model::{EpgConfigDto}; -use crate::utils::{default_as_true, get_credentials_from_url_str, get_trimmed_string, sanitize_sensitive_info, trim_last_slash, deserialize_timestamp}; +use crate::utils::{is_false, is_true, default_as_true, get_credentials_from_url_str, get_trimmed_string, + sanitize_sensitive_info, trim_last_slash, deserialize_timestamp, is_zero_u16}; use super::PanelApiConfigDto; -use crate::{check_input_credentials, check_input_connections, create_tuliprox_error_result, handle_tuliprox_error_result_list, info_err}; +use crate::{check_input_credentials, check_input_connections, create_tuliprox_error_result, info_err}; + use enum_iterator::Sequence; use std::collections::{HashMap, HashSet}; use std::fmt::Display; @@ -27,14 +30,14 @@ macro_rules! apply_batch_aliases { } else { $source.aliases = Some($batch_aliases); } - if let Some(index) = $index { - let mut idx = index; + if let Some(index) = $index { + let mut idx = index + 1; // set to the same id as the first alias, because the first alias is copied into this input $source.id = idx; if let Some(aliases) = $source.aliases.as_mut() { for alias in aliases { - alias.id = idx; idx += 1; + alias.id = idx; } } Some(idx) @@ -152,15 +155,15 @@ impl FromStr for InputFetchMethod { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct ConfigInputOptionsDto { - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub xtream_skip_live: bool, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub xtream_skip_vod: bool, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub xtream_skip_series: bool, - #[serde(default = "default_as_true")] + #[serde(default = "default_as_true", skip_serializing_if = "is_true")] pub xtream_live_stream_use_prefix: bool, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub xtream_live_stream_without_extension: bool, } @@ -200,15 +203,15 @@ impl ConfigInputOptionsDto { pub struct StagedInputDto { pub name: String, pub url: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub username: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub password: Option, #[serde(default)] pub method: InputFetchMethod, #[serde(default, rename = "type")] pub input_type: InputType, - #[serde(default)] + #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub headers: HashMap, } @@ -236,19 +239,19 @@ impl StagedInputDto { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] #[serde(deny_unknown_fields)] pub struct ConfigInputAliasDto { - #[serde(default)] + #[serde(default, skip_serializing_if = "is_zero_u16")] pub id: u16, pub name: String, pub url: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub username: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub password: Option, #[serde(default)] pub priority: i16, #[serde(default)] pub max_connections: u16, - #[serde(default, deserialize_with = "deserialize_timestamp")] + #[serde(default, deserialize_with = "deserialize_timestamp", skip_serializing_if = "Option::is_none")] pub exp_date: Option, } @@ -264,8 +267,8 @@ impl ConfigInputAliasDto { if self.url.is_empty() { return Err(info_err!("url for input is mandatory".to_string())); } - check_input_credentials!(self, input_type, true); - check_input_connections!(self, input_type); + check_input_credentials!(self, input_type, true, true); + check_input_connections!(self, input_type, true); Ok(self.id) } @@ -274,29 +277,33 @@ impl ConfigInputAliasDto { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct ConfigInputDto { - #[serde(default)] + #[serde(default, skip_serializing_if = "is_zero_u16")] pub id: u16, #[serde(default)] pub name: String, #[serde(default, rename = "type")] pub input_type: InputType, - #[serde(default)] + #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub headers: HashMap, #[serde(default)] pub url: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub epg: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub username: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub password: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub persist: Option, - #[serde(default = "default_as_true")] + #[serde(default = "default_as_true", skip_serializing_if = "is_true")] pub enabled: bool, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub options: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] + pub cache_duration: Option, + #[serde(skip, skip_serializing_if = "is_zero_u16")] + pub cache_duration_seconds: u64, + #[serde(default, skip_serializing_if = "Option::is_none", serialize_with = "crate::utils::serialize_option_slice_flow")] pub aliases: Option>, #[serde(default)] pub priority: i16, @@ -304,11 +311,11 @@ pub struct ConfigInputDto { pub max_connections: u16, #[serde(default)] pub method: InputFetchMethod, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub staged: Option, - #[serde(default, deserialize_with = "deserialize_timestamp")] + #[serde(default, deserialize_with = "deserialize_timestamp", skip_serializing_if = "Option::is_none")] pub exp_date: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub panel_api: Option, } @@ -326,6 +333,8 @@ impl Default for ConfigInputDto { persist: None, enabled: default_as_true(), options: None, + cache_duration: None, + cache_duration_seconds: 0, aliases: None, priority: 0, max_connections: 0, @@ -346,10 +355,36 @@ impl ConfigInputDto { return Err(info_err!("name for input is mandatory".to_owned())); } - check_input_credentials!(self, self.input_type, true); - check_input_connections!(self, self.input_type); + if let Some(duration_str) = &self.cache_duration { + self.cache_duration_seconds = match duration_str.parse::() { + Ok(secs) => secs, + Err(_) => { + let len = duration_str.len(); + if len > 1 { + let (num_str, unit) = duration_str.split_at(len - 1); + match num_str.parse::() { + Ok(val) => match unit { + "s" => val, + "m" => val * 60, + "h" => val * 3600, + "d" => val * 86400, + _ => return Err(info_err!(format!("Invalid cache_duration unit in '{}': {}", self.name, unit))), + }, + Err(_) => return Err(info_err!(format!("Invalid cache_duration format in '{}': {}", self.name, duration_str))), + } + } else { + return Err(info_err!(format!("Invalid cache_duration format in '{}'", self.name))); + } + } + }; + } else { + self.cache_duration_seconds = 0; + } + + check_input_credentials!(self, self.input_type, true, false); + check_input_connections!(self, self.input_type, false); if let Some(staged_input) = self.staged.as_mut() { - check_input_credentials!(staged_input, staged_input.input_type, true); + check_input_credentials!(staged_input, staged_input.input_type, true, true); if !matches!(staged_input.input_type, InputType::M3u | InputType::Xtream) { return Err(info_err!("Staged input can only be of type m3u or xtream".to_owned())); } @@ -361,15 +396,9 @@ impl ConfigInputDto { self.id = current_index; if let Some(aliases) = self.aliases.as_mut() { let input_type = &self.input_type; - handle_tuliprox_error_result_list!(TuliproxErrorKind::Info, aliases.iter_mut() - .map(|i| match i.prepare(current_index, input_type) { - Ok(new_idx) => { - current_index = new_idx; - Ok(()) - }, - Err(err) => Err(err) - })); - //} else if !matches!(self.input_type, InputType::M3uBatch | InputType::XtreamBatch) { + for alias in aliases { + current_index = alias.prepare(current_index, input_type)?; + } } Ok(current_index) } @@ -438,4 +467,39 @@ impl ConfigInputDto { let idx = apply_batch_aliases!(self, batch_aliases, Some(index)); Ok(idx) } + + pub fn upsert_alias(&mut self, mut alias: ConfigInputAliasDto) -> Result<(), TuliproxError> { + check_input_credentials!(alias, self.input_type, true, true); + check_input_connections!(alias, self.input_type, true); + let aliases = self.aliases.get_or_insert_with(Vec::new); + if let Some(existing) = aliases.iter_mut().find(|a| a.id == alias.id) { + *existing = alias; + } else { + aliases.push(alias); + } + Ok(()) + } + + pub fn update_account_expiration_date(&mut self, input_name: &str, username: &str, exp_date: i64) -> Result<(), TuliproxError> { + if self.name == input_name { + if let Some(input_username) = &self.username { + if input_username == username { + self.exp_date = Some(exp_date); + return Ok(()); + } + } + } + + if let Some(aliases) = &mut self.aliases { + if let Some(alias) = aliases + .iter_mut() + .find(|a| a.username.as_deref() == Some(username)) + { + alias.exp_date = Some(exp_date); + return Ok(()); + } + } + + Err(TuliproxError::new(TuliproxErrorKind::Info, format!("No matching input or alias found for input '{input_name}' with username '{username}'"))) + } } diff --git a/shared/src/model/config/ipcheck.rs b/shared/src/model/config/ipcheck.rs index 837c74615..d8c046822 100644 --- a/shared/src/model/config/ipcheck.rs +++ b/shared/src/model/config/ipcheck.rs @@ -1,38 +1,38 @@ use crate::error::{TuliproxError, TuliproxErrorKind}; -use crate::utils::is_blank_optional_string; +use crate::utils::{is_blank_optional_str, is_blank_optional_string}; use regex::Regex; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] #[serde(deny_unknown_fields)] pub struct IpCheckConfigDto { /// URL that may return both IPv4 and IPv6 in one response - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub url: Option, /// Dedicated URL to fetch only IPv4 - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub url_ipv4: Option, /// Dedicated URL to fetch only IPv6 - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub url_ipv6: Option, /// Optional regex pattern to extract IPv4 - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub pattern_ipv4: Option, /// Optional regex pattern to extract IPv6 - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub pattern_ipv6: Option, } impl IpCheckConfigDto { pub fn is_empty(&self) -> bool { - is_blank_optional_string(self.url.as_deref()) - && is_blank_optional_string(self.url_ipv4.as_deref()) - && is_blank_optional_string(self.url_ipv6.as_deref()) - && is_blank_optional_string(self.pattern_ipv4.as_deref()) - && is_blank_optional_string(self.pattern_ipv6.as_deref()) + is_blank_optional_str(self.url.as_deref()) + && is_blank_optional_str(self.url_ipv4.as_deref()) + && is_blank_optional_str(self.url_ipv6.as_deref()) + && is_blank_optional_str(self.pattern_ipv4.as_deref()) + && is_blank_optional_str(self.pattern_ipv6.as_deref()) } pub fn clean(&mut self) { diff --git a/shared/src/model/config/library.rs b/shared/src/model/config/library.rs index f22e9ec2d..8ab06abfc 100644 --- a/shared/src/model/config/library.rs +++ b/shared/src/model/config/library.rs @@ -1,39 +1,11 @@ +use crate::error::{info_err, TuliproxError}; +use crate::utils::{is_true, default_as_true, default_metadata_path, + default_movie_category, default_series_category, default_storage_formats, + default_supported_library_extensions, is_default_supported_library_extensions, + default_tmdb_api_key, default_tmdb_cache_duration_days, + default_tmdb_language, default_tmdb_rate_limit_ms, is_default_tmdb_cache_duration_days, + is_default_tmdb_language, is_default_tmdb_rate_limit_ms, is_tmdb_default_api_key}; use serde::{Deserialize, Serialize}; -use crate::error::{TuliproxError, info_err}; -use crate::utils::default_as_true; - -pub fn default_metadata_path() -> String { - "library_metadata".to_string() -} -fn default_tmdb_rate_limit_ms() -> u64 { - 250 -} -fn default_tmdb_cache_duration_days() -> u32 { - 30 -} -fn default_tmdb_language() -> String { - "en-US".to_string() -} -fn default_storage_formats() -> Vec { - vec![] -} -fn default_movie_category() -> String { - "Local Movies".to_string() -} -fn default_series_category() -> String { - "Local TV Shows".to_string() -} -fn default_supported_extensions() -> Vec { - [ - "mp4", - "mkv", - "avi", - "mov", - "ts", - "m4v", - "webm", - ].iter().map(ToString::to_string).collect() -} #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] #[serde(deny_unknown_fields)] @@ -42,7 +14,7 @@ pub struct LibraryConfigDto { pub enabled: bool, #[serde(default)] pub scan_directories: Vec, - #[serde(default="default_supported_extensions")] + #[serde(default = "default_supported_library_extensions", skip_serializing_if = "is_default_supported_library_extensions")] pub supported_extensions: Vec, #[serde(default)] pub metadata: LibraryMetadataConfigDto, @@ -53,12 +25,12 @@ pub struct LibraryConfigDto { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct LibraryScanDirectoryDto { - #[serde(default = "default_as_true")] + #[serde(default = "default_as_true", skip_serializing_if = "is_true")] pub enabled: bool, pub path: String, #[serde(default)] pub content_type: LibraryContentType, - #[serde(default = "default_as_true")] + #[serde(default = "default_as_true", skip_serializing_if = "is_true")] pub recursive: bool, } @@ -82,7 +54,7 @@ pub struct LibraryMetadataConfigDto { pub tmdb: LibraryTmdbConfigDto, #[serde(default = "default_as_true")] pub fallback_to_filename: bool, - #[serde(default = "default_storage_formats")] + #[serde(default = "default_storage_formats", skip_serializing_if = "Vec::is_empty")] pub formats: Vec, } @@ -102,13 +74,19 @@ pub struct LibraryMetadataReadConfigDto { pub struct LibraryTmdbConfigDto { #[serde(default)] pub enabled: bool, - #[serde(default)] + #[serde(default = "default_tmdb_api_key", skip_serializing_if = "is_tmdb_default_api_key")] pub api_key: Option, - #[serde(default = "default_tmdb_rate_limit_ms")] + #[serde( + default = "default_tmdb_rate_limit_ms", + skip_serializing_if = "is_default_tmdb_rate_limit_ms" + )] pub rate_limit_ms: u64, - #[serde(default = "default_tmdb_cache_duration_days")] + #[serde( + default = "default_tmdb_cache_duration_days", + skip_serializing_if = "is_default_tmdb_cache_duration_days" + )] pub cache_duration_days: u32, - #[serde(default = "default_tmdb_language")] + #[serde(default = "default_tmdb_language", skip_serializing_if = "is_default_tmdb_language")] pub language: String, } diff --git a/shared/src/model/config/log.rs b/shared/src/model/config/log.rs index 45902110f..87865309f 100644 --- a/shared/src/model/config/log.rs +++ b/shared/src/model/config/log.rs @@ -1,13 +1,13 @@ -use crate::utils::{default_as_true, is_blank_optional_string}; +use crate::utils::{is_true, is_false, default_as_true, is_blank_optional_string, is_blank_optional_str}; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct LogConfigDto { - #[serde(default = "default_as_true")] + #[serde(default = "default_as_true", skip_serializing_if = "is_true")] pub sanitize_sensitive_info: bool, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub log_active_user: bool, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub log_level: Option, } @@ -23,11 +23,11 @@ impl Default for LogConfigDto { impl LogConfigDto { pub fn is_empty(&self) -> bool { - self.sanitize_sensitive_info && !self.log_active_user && is_blank_optional_string(self.log_level.as_deref()) + self.sanitize_sensitive_info && !self.log_active_user && is_blank_optional_str(self.log_level.as_deref()) } pub fn clean(&mut self) { - if is_blank_optional_string(self.log_level.as_deref()) { + if is_blank_optional_str(self.log_level.as_deref()) { self.log_level = None; } } diff --git a/shared/src/model/config/macros.rs b/shared/src/model/config/macros.rs index aa7d35bbf..2043a6d6f 100644 --- a/shared/src/model/config/macros.rs +++ b/shared/src/model/config/macros.rs @@ -1,6 +1,6 @@ #[macro_export] macro_rules! check_input_credentials { - ($this:ident, $input_type:expr, $definition:expr ) => { + ($this:ident, $input_type:expr, $definition:expr, $alias:expr ) => { if !matches!($input_type, InputType::Library) { $this.url = $this.url.trim().to_string(); @@ -27,10 +27,11 @@ macro_rules! check_input_credentials { return Err(info_err!("for input type m3u-batch: url is mandatory".to_owned())); } } - if $this.username.is_some() || $this.password.is_some() { - // TODO only for initial check - // return Err(info_err!("Input types of m3u-batch should not define username or password".to_owned())); - } + + // if !$alias && ($this.username.is_some() || $this.password.is_some()) { + // // TODO only for initial check + // // return Err(info_err!("Input types of m3u-batch should not define username or password".to_owned())); + // } } InputType::Xtream => { if $this.username.is_none() || $this.password.is_none() { @@ -43,8 +44,9 @@ macro_rules! check_input_credentials { return Err(info_err!("for input type xtream-batch: url is mandatory".to_owned())); } } - if $this.username.is_some() || $this.password.is_some() { - return Err(info_err!("input type xtream-batch should not define username or password attribute ".to_owned())); + + if !$alias && ($this.username.is_some() || $this.password.is_some()) { + return Err(info_err!("input type xtream-batch should not define username or password attribute ".to_owned())); } } InputType::Library => { @@ -56,25 +58,29 @@ macro_rules! check_input_credentials { #[macro_export] macro_rules! check_input_connections { - ($this:ident, $input_type:expr) => { + ($this:ident, $input_type:expr, $alias:expr) => { match $input_type { InputType::M3u |InputType::Xtream => { } InputType::M3uBatch => { - if $this.max_connections > 0 { - return Err(info_err!("input type m3u-batch should not define max_connections attribute ".to_owned())); - } - if $this.priority != 0 { - return Err(info_err!("input type m3u-batch should not define priority attribute ".to_owned())); + if !$alias { + if $this.max_connections > 0 { + return Err(info_err!("input type m3u-batch should not define max_connections attribute ".to_owned())); + } + if $this.priority != 0 { + return Err(info_err!("input type m3u-batch should not define priority attribute ".to_owned())); + } } } InputType::XtreamBatch => { - if $this.max_connections > 0 { - return Err(info_err!("input type xtream-batch should not define max_connections attribute ".to_owned())); - } - if $this.priority != 0 { - return Err(info_err!("input type xtream-batch should not define priority attribute ".to_owned())); + if !$alias { + if $this.max_connections > 0 { + return Err(info_err!("input type xtream-batch should not define max_connections attribute ".to_owned())); + } + if $this.priority != 0 { + return Err(info_err!("input type xtream-batch should not define priority attribute ".to_owned())); + } } } InputType::Library => {} diff --git a/shared/src/model/config/messaging.rs b/shared/src/model/config/messaging.rs index 5f62221e9..d63d1abd9 100644 --- a/shared/src/model/config/messaging.rs +++ b/shared/src/model/config/messaging.rs @@ -1,12 +1,13 @@ use crate::model::MsgKind; -use crate::utils::is_blank_optional_string; +use crate::utils::{is_false, is_blank_optional_string, is_blank_optional_str}; #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct TelegramMessagingConfigDto { pub bot_token: String, + #[serde(skip_serializing_if = "Vec::is_empty")] pub chat_ids: Vec, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub markdown: bool, } @@ -31,6 +32,7 @@ impl RestMessagingConfigDto { #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct PushoverMessagingConfigDto { + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub url: Option, pub token: String, pub user: String, @@ -38,7 +40,7 @@ pub struct PushoverMessagingConfigDto { impl PushoverMessagingConfigDto { pub fn is_empty(&self) -> bool { - is_blank_optional_string(self.url.as_deref()) + is_blank_optional_str(self.url.as_deref()) && self.token.trim().is_empty() && self.user.trim().is_empty() } @@ -47,13 +49,13 @@ impl PushoverMessagingConfigDto { #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct MessagingConfigDto { - #[serde(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub notify_on: Vec, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub telegram: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub rest: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub pushover: Option, } diff --git a/shared/src/model/config/mod.rs b/shared/src/model/config/mod.rs index 8ee36bc6f..f8210060a 100644 --- a/shared/src/model/config/mod.rs +++ b/shared/src/model/config/mod.rs @@ -36,7 +36,6 @@ mod proxy_user_status; mod favourites; mod geoip; mod library; - pub use proxy_type::*; pub use proxy_user_status::*; pub use base::*; @@ -73,4 +72,4 @@ pub use config_type::*; pub use playlist_update_state::*; pub use favourites::*; pub use library::*; -pub use crate::apply_batch_aliases; +pub use crate::apply_batch_aliases; \ No newline at end of file diff --git a/shared/src/model/config/panel_api.rs b/shared/src/model/config/panel_api.rs index 265af4c39..9a8ea534b 100644 --- a/shared/src/model/config/panel_api.rs +++ b/shared/src/model/config/panel_api.rs @@ -1,4 +1,4 @@ -use crate::utils::{default_as_true}; +use crate::utils::{is_true, default_as_true}; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] #[serde(deny_unknown_fields)] @@ -21,10 +21,10 @@ pub struct PanelApiQueryParametersDto { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] #[serde(deny_unknown_fields)] pub struct PanelApiConfigDto { - #[serde(default = "default_as_true")] + #[serde(default = "default_as_true", skip_serializing_if = "is_true")] pub enabled: bool, pub url: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub api_key: Option, #[serde(default)] pub query_parameter: PanelApiQueryParametersDto, diff --git a/shared/src/model/config/paths.rs b/shared/src/model/config/paths.rs index e699cff33..907a1bb80 100644 --- a/shared/src/model/config/paths.rs +++ b/shared/src/model/config/paths.rs @@ -1,10 +1,13 @@ +use crate::utils::is_blank_optional_string; + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct ConfigPaths { pub config_path: String, pub config_file_path: String, pub sources_file_path: String, + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub mapping_file_path: Option, pub api_proxy_file_path: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub custom_stream_response_path: Option, } diff --git a/shared/src/model/config/proxy.rs b/shared/src/model/config/proxy.rs index d49b91280..abaa40668 100644 --- a/shared/src/model/config/proxy.rs +++ b/shared/src/model/config/proxy.rs @@ -1,26 +1,28 @@ use crate::error::{TuliproxError, TuliproxErrorKind}; -use crate::utils::is_blank_optional_string; +use crate::utils::{is_blank_optional_str, is_blank_optional_string}; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] #[serde(deny_unknown_fields)] pub struct ProxyConfigDto { pub url: String, + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub username: Option, + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub password: Option, } impl ProxyConfigDto { pub fn is_empty(&self) -> bool { - is_blank_optional_string(self.username.as_deref()) - && is_blank_optional_string(self.password.as_deref()) + is_blank_optional_str(self.username.as_deref()) + && is_blank_optional_str(self.password.as_deref()) && self.url.trim().is_empty() } pub fn clean(&mut self) { - if is_blank_optional_string(self.username.as_deref()) { + if is_blank_optional_str(self.username.as_deref()) { self.username = None; } - if is_blank_optional_string(self.password.as_deref()) { + if is_blank_optional_str(self.password.as_deref()) { self.password = None; } } diff --git a/shared/src/model/config/reverse_proxy.rs b/shared/src/model/config/reverse_proxy.rs index e730d1534..71910d7b4 100644 --- a/shared/src/model/config/reverse_proxy.rs +++ b/shared/src/model/config/reverse_proxy.rs @@ -1,17 +1,22 @@ use crate::error::{TuliproxError, TuliproxErrorKind}; use crate::model::{CacheConfigDto, GeoIpConfigDto, RateLimitConfigDto, StreamConfigDto}; -use crate::utils::{default_resource_retry_attempts, default_resource_retry_backoff_ms, - default_resource_retry_backoff_multiplier, hex_to_u8_16}; +use crate::utils::{is_false, default_resource_retry_attempts, + default_resource_retry_backoff_ms, + default_resource_retry_backoff_multiplier, + is_default_resource_retry_attempts, + is_default_resource_retry_backoff_ms, + is_default_resource_retry_backoff_multiplier, + hex_to_u8_16}; use log::warn; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] #[serde(deny_unknown_fields)] pub struct ReverseProxyDisabledHeaderConfigDto { - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub referer_header: bool, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub x_header: bool, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub cloudflare_header: bool, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub custom_header: Vec, @@ -33,20 +38,20 @@ impl ReverseProxyDisabledHeaderConfigDto { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] #[serde(deny_unknown_fields)] pub struct ReverseProxyConfigDto { - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub resource_rewrite_disabled: bool, pub rewrite_secret: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub resource_retry: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub disabled_header: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub stream: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub cache: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub rate_limit: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub geoip: Option, } @@ -119,11 +124,11 @@ impl ReverseProxyConfigDto { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct ResourceRetryConfigDto { - #[serde(default = "default_resource_retry_attempts")] + #[serde(default = "default_resource_retry_attempts", skip_serializing_if = "is_default_resource_retry_attempts")] pub max_attempts: u32, - #[serde(default = "default_resource_retry_backoff_ms")] + #[serde(default = "default_resource_retry_backoff_ms", skip_serializing_if = "is_default_resource_retry_backoff_ms")] pub backoff_millis: u64, - #[serde(default = "default_resource_retry_backoff_multiplier")] + #[serde(default = "default_resource_retry_backoff_multiplier", skip_serializing_if = "is_default_resource_retry_backoff_multiplier")] pub backoff_multiplier: f64, } diff --git a/shared/src/model/config/schedule.rs b/shared/src/model/config/schedule.rs index ec94fb3ec..da3a9ad27 100644 --- a/shared/src/model/config/schedule.rs +++ b/shared/src/model/config/schedule.rs @@ -3,6 +3,6 @@ pub struct ScheduleConfigDto { #[serde(default)] pub schedule: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub targets: Option>, } \ No newline at end of file diff --git a/shared/src/model/config/source.rs b/shared/src/model/config/source.rs index 03f1ab891..5316d5943 100644 --- a/shared/src/model/config/source.rs +++ b/shared/src/model/config/source.rs @@ -1,6 +1,6 @@ use std::collections::HashSet; use crate::create_tuliprox_error_result; -use crate::error::{handle_tuliprox_error_result_list, TuliproxError, TuliproxErrorKind}; +use crate::error::{TuliproxError, TuliproxErrorKind}; use crate::foundation::filter::prepare_templates; use crate::model::{ConfigInputDto, HdHomeRunDeviceOverview, PatternTemplate}; use crate::model::config::target::ConfigTargetDto; @@ -9,27 +9,22 @@ use crate::utils::default_as_default; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct ConfigSourceDto { - pub inputs: Vec, + #[serde(default)] + pub inputs: Vec, pub targets: Vec, } impl ConfigSourceDto { #[allow(clippy::cast_possible_truncation)] - pub fn prepare(&mut self, index: u16, include_computed: bool) -> Result { - let mut current_index = index; + pub fn prepare(&mut self, index: u16, _include_computed: bool) -> Result { + let current_index = index; if self.inputs.is_empty() { return create_tuliprox_error_result!(TuliproxErrorKind::Info, "At least one input should be defined at source: {index}"); } - handle_tuliprox_error_result_list!(TuliproxErrorKind::Info, self.inputs.iter_mut() - .map(|i| - match i.prepare(current_index, include_computed) { - Ok(new_idx) => { - current_index = new_idx; - Ok(()) - }, - Err(err) => Err(err) - } - )); + // Trim all input names + for input in &mut self.inputs { + *input = input.trim().to_owned(); + } Ok(current_index) } } @@ -37,8 +32,9 @@ impl ConfigSourceDto { #[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct SourcesConfigDto { - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub templates: Option>, + pub inputs: Vec, pub sources: Vec, } @@ -55,11 +51,21 @@ impl SourcesConfigDto { let mut source_index: u16 = 0; let mut input_index: u16 = 0; let mut target_index: u16 = 1; + // Prepare global inputs + for input in &mut self.inputs { + input_index = input.prepare(input_index, include_computed)?; + } + for source in &mut self.sources { source_index = source.prepare(source_index, include_computed)?; - for input in &mut source.inputs { - input_index = input.prepare(input_index, include_computed)?; + + // Validate referenced inputs + for name in &source.inputs { + if !self.inputs.iter().any(|i| &i.name == name) { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "Source references unknown input: '{name}'"); + } } + for target in &mut source.targets { // prepare target templates let prepare_result = match &self.templates { @@ -104,4 +110,8 @@ impl SourcesConfigDto { } Ok(()) } + + pub fn get_input(&self, name: &str) -> Option<&ConfigInputDto> { + self.inputs.iter().find(|i| i.name == name) + } } \ No newline at end of file diff --git a/shared/src/model/config/stream.rs b/shared/src/model/config/stream.rs index 764053bff..4266de69c 100644 --- a/shared/src/model/config/stream.rs +++ b/shared/src/model/config/stream.rs @@ -1,10 +1,13 @@ use crate::error::{TuliproxError, TuliproxErrorKind}; use crate::info_err; -use crate::utils::{default_grace_period_millis, default_grace_period_timeout_secs, parse_to_kbps}; +use crate::utils::{default_grace_period_millis, default_grace_period_timeout_secs, + is_default_grace_period_millis, is_default_grace_period_timeout_secs, + default_shared_burst_buffer_mb, is_default_shared_burst_buffer_mb, + is_blank_optional_string, + 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)] @@ -32,17 +35,17 @@ impl StreamBufferConfigDto { pub struct StreamConfigDto { #[serde(default)] pub retry: bool, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub buffer: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub throttle: Option, - #[serde(default = "default_grace_period_millis")] + #[serde(default = "default_grace_period_millis", skip_serializing_if = "is_default_grace_period_millis")] pub grace_period_millis: u64, - #[serde(default = "default_grace_period_timeout_secs")] + #[serde(default = "default_grace_period_timeout_secs", skip_serializing_if = "is_default_grace_period_timeout_secs")] pub grace_period_timeout_secs: u64, #[serde(default, skip)] pub throttle_kbps: u64, - #[serde(default = "default_shared_burst_buffer_mb")] + #[serde(default = "default_shared_burst_buffer_mb", skip_serializing_if = "is_default_shared_burst_buffer_mb")] pub shared_burst_buffer_mb: u64, } diff --git a/shared/src/model/config/target.rs b/shared/src/model/config/target.rs index c7dd4d97f..0a671ac96 100644 --- a/shared/src/model/config/target.rs +++ b/shared/src/model/config/target.rs @@ -1,19 +1,22 @@ +use crate::utils::is_blank_optional_string; use log::warn; use crate::error::{TuliproxError, TuliproxErrorKind}; use crate::{create_tuliprox_error_result, handle_tuliprox_error_result_list, info_err}; use crate::foundation::filter::{get_filter, Filter}; -use crate::model::{ClusterFlags, ConfigFavouritesDto, ConfigRenameDto, ConfigSortDto, HdHomeRunDeviceOverview, PatternTemplate, ProcessingOrder, StrmExportStyle, TargetType, TraktConfigDto}; -use crate::utils::{default_as_true, default_resolve_delay_secs, default_as_default}; +use crate::model::{ClusterFlags, ConfigFavouritesDto, ConfigRenameDto, ConfigSortDto, HdHomeRunDeviceOverview, + PatternTemplate, ProcessingOrder, StrmExportStyle, TargetType, TraktConfigDto}; +use crate::utils::{is_true, is_false, default_as_true, default_resolve_delay_secs, default_as_default, + is_default_resolve_delay_secs, is_zero_u16, is_config_target_options_empty}; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] #[serde(deny_unknown_fields)] pub struct ConfigTargetOptions { - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub ignore_logo: bool, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub share_live_streams: bool, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub remove_duplicates: bool, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub force_redirect: Option, } @@ -30,23 +33,23 @@ impl ConfigTargetOptions { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct XtreamTargetOutputDto { - #[serde(default = "default_as_true")] + #[serde(default = "default_as_true", skip_serializing_if = "is_true")] pub skip_live_direct_source: bool, - #[serde(default = "default_as_true")] + #[serde(default = "default_as_true", skip_serializing_if = "is_true")] pub skip_video_direct_source: bool, - #[serde(default = "default_as_true")] + #[serde(default = "default_as_true", skip_serializing_if = "is_true")] pub skip_series_direct_source: bool, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub resolve_series: bool, - #[serde(default = "default_resolve_delay_secs")] + #[serde(default = "default_resolve_delay_secs", skip_serializing_if = "is_default_resolve_delay_secs")] pub resolve_series_delay: u16, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub resolve_vod: bool, - #[serde(default = "default_resolve_delay_secs")] + #[serde(default = "default_resolve_delay_secs", skip_serializing_if = "is_default_resolve_delay_secs")] pub resolve_vod_delay: u16, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub trakt: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub filter: Option, #[serde(skip)] pub t_filter: Option, @@ -94,13 +97,13 @@ impl XtreamTargetOutputDto { #[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct M3uTargetOutputDto { - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub filename: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub include_type_in_url: bool, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub mask_redirect_url: bool, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub filter: Option, #[serde(skip)] pub t_filter: Option, @@ -127,24 +130,24 @@ impl M3uTargetOutputDto { #[serde(deny_unknown_fields)] pub struct StrmTargetOutputDto { pub directory: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub username: Option, #[serde(default)] pub style: StrmExportStyle, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub flat: bool, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub underscore_whitespace: bool, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub cleanup: bool, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub strm_props: Option>, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub filter: Option, + #[serde(default, skip_serializing_if = "is_false")] + pub add_quality_to_filename: bool, #[serde(skip)] pub t_filter: Option, - #[serde(default)] - pub add_quality_to_filename: bool, } impl StrmTargetOutputDto { @@ -161,7 +164,7 @@ impl StrmTargetOutputDto { pub struct HdHomeRunTargetOutputDto { pub device: String, pub username: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub use_output: Option, } @@ -198,30 +201,30 @@ impl TargetOutputDto { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct ConfigTargetDto { - #[serde(default)] + #[serde(default, skip_serializing_if = "is_zero_u16")] pub id: u16, - #[serde(default = "default_as_true")] + #[serde(default = "default_as_true", skip_serializing_if = "is_true")] pub enabled: bool, #[serde(default = "default_as_default")] pub name: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_config_target_options_empty")] pub options: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub sort: Option, pub filter: String, #[serde(default)] pub output: Vec, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub rename: Option>, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub mapping: Option>, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub favourites: Option>, #[serde(default)] pub processing_order: ProcessingOrder, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub watch: Option>, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub use_memory_cache: bool, #[serde(skip)] pub t_filter: Option, diff --git a/shared/src/model/config/video_download.rs b/shared/src/model/config/video_download.rs index fbdd87fad..0841702f1 100644 --- a/shared/src/model/config/video_download.rs +++ b/shared/src/model/config/video_download.rs @@ -3,20 +3,19 @@ use std::borrow::BorrowMut; use crate::create_tuliprox_error_result; use crate::error::{TuliproxError, TuliproxErrorKind}; use crate::model::DEFAULT_USER_AGENT; -use crate::utils::is_blank_optional_string; - -pub const DEFAULT_VIDEO_EXTENSIONS: [&str; 6] = ["mkv", "avi", "mp4", "mpeg", "divx", "mov"]; +use crate::utils::{is_false, is_blank_optional_string, is_blank_optional_str, default_supported_video_extensions, is_default_supported_video_extensions}; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] #[serde(deny_unknown_fields)] pub struct VideoDownloadConfigDto { - #[serde(default)] + #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub headers: HashMap, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub directory: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_false")] pub organize_into_directories: bool, - #[serde(default)] + // TODO use ptt + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub episode_pattern: Option, } @@ -24,26 +23,25 @@ impl VideoDownloadConfigDto { pub fn is_empty(&self) -> bool { !self.organize_into_directories && self.headers.is_empty() - && is_blank_optional_string(self.directory.as_deref()) - && is_blank_optional_string(self.episode_pattern.as_deref()) + && is_blank_optional_str(self.directory.as_deref()) + && is_blank_optional_str(self.episode_pattern.as_deref()) } } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] #[serde(deny_unknown_fields)] pub struct VideoConfigDto { - #[serde(default)] + #[serde(default = "default_supported_video_extensions", skip_serializing_if = "is_default_supported_video_extensions")] pub extensions: Vec, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub download: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub web_search: Option, } - impl VideoConfigDto { pub fn is_empty(&self) -> bool { - self.extensions.is_empty() && is_blank_optional_string(self.web_search.as_deref()) + self.extensions.is_empty() && is_blank_optional_str(self.web_search.as_deref()) && (self.download.is_none() || self.download.as_ref().is_some_and(|d| d.is_empty())) } @@ -58,10 +56,7 @@ impl VideoConfigDto { /// Will panic if default `RegEx` gets invalid pub fn prepare(&mut self) -> Result<(), TuliproxError> { if self.extensions.is_empty() { - self.extensions = DEFAULT_VIDEO_EXTENSIONS - .iter() - .map(|&arg| arg.to_string()) - .collect(); + self.extensions = default_supported_video_extensions(); } match &mut self.download { None => {} diff --git a/shared/src/model/config/web_auth.rs b/shared/src/model/config/web_auth.rs index 49bdb8188..30f65a780 100644 --- a/shared/src/model/config/web_auth.rs +++ b/shared/src/model/config/web_auth.rs @@ -1,19 +1,15 @@ -use crate::utils::{default_as_true, is_blank_optional_string}; -/// 30 minutes by default; `0` still means “no expiration.” -fn default_token_ttl_mins() -> u32 { - 30 -} +use crate::utils::{is_true, default_as_true, default_token_ttl_mins, is_default_token_ttl_mins, is_blank_optional_string, is_blank_optional_str}; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct WebAuthConfigDto { - #[serde(default = "default_as_true")] + #[serde(default = "default_as_true", skip_serializing_if = "is_true")] pub enabled: bool, pub issuer: String, pub secret: String, - #[serde(default = "default_token_ttl_mins")] + #[serde(default = "default_token_ttl_mins", skip_serializing_if = "is_default_token_ttl_mins")] pub token_ttl_mins: u32, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub userfile: Option, } @@ -36,6 +32,6 @@ impl WebAuthConfigDto { && self.token_ttl_mins == empty.token_ttl_mins && self.issuer.trim().is_empty() && self.secret.trim().is_empty() - && is_blank_optional_string(self.userfile.as_deref()) + && is_blank_optional_str(self.userfile.as_deref()) } } diff --git a/shared/src/model/config/web_ui.rs b/shared/src/model/config/web_ui.rs index 3cd9a8a0a..b5436b31b 100644 --- a/shared/src/model/config/web_ui.rs +++ b/shared/src/model/config/web_ui.rs @@ -1,6 +1,6 @@ use crate::error::{TuliproxError, TuliproxErrorKind}; use crate::model::WebAuthConfigDto; -use crate::utils::{default_as_true, is_blank_optional_string, default_kick_secs}; +use crate::utils::{is_true, default_as_true, is_blank_optional_string, default_kick_secs, is_default_kick_secs, is_blank_optional_str}; const RESERVED_PATHS: &[&str] = &[ "cvs", @@ -27,7 +27,7 @@ const RESERVED_PATHS: &[&str] = &[ pub struct ContentSecurityPolicyConfigDto { #[serde(default)] pub enabled: bool, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub custom_attributes: Option>, } @@ -70,19 +70,19 @@ impl ContentSecurityPolicyConfigDto { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct WebUiConfigDto { - #[serde(default = "default_as_true")] + #[serde(default = "default_as_true", skip_serializing_if = "is_true")] pub enabled: bool, - #[serde(default = "default_as_true")] + #[serde(default = "default_as_true", skip_serializing_if = "is_true")] pub user_ui_enabled: bool, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub content_security_policy: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub path: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub auth: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub player_server: Option, - #[serde(default = "default_kick_secs")] + #[serde(default = "default_kick_secs", skip_serializing_if = "is_default_kick_secs")] pub kick_secs: u64, } @@ -105,8 +105,8 @@ impl WebUiConfigDto { let empty = WebUiConfigDto::default(); self.enabled == empty.enabled && self.user_ui_enabled == empty.user_ui_enabled - && is_blank_optional_string(self.path.as_deref()) - && is_blank_optional_string(self.player_server.as_deref()) + && is_blank_optional_str(self.path.as_deref()) + && is_blank_optional_str(self.player_server.as_deref()) && self.kick_secs == default_kick_secs() && (self.content_security_policy.is_none() || self @@ -128,10 +128,10 @@ impl WebUiConfigDto { self.auth = None; } - if is_blank_optional_string(self.path.as_deref()) { + if is_blank_optional_str(self.path.as_deref()) { self.path = None; } - if is_blank_optional_string(self.player_server.as_deref()) { + if is_blank_optional_str(self.player_server.as_deref()) { self.player_server = None; } self.kick_secs = default_kick_secs(); diff --git a/shared/src/model/ip_check.rs b/shared/src/model/ip_check.rs index c697bed0c..251498361 100644 --- a/shared/src/model/ip_check.rs +++ b/shared/src/model/ip_check.rs @@ -1,8 +1,10 @@ +use crate::utils::is_blank_optional_string; + #[derive(Debug, serde::Serialize, serde::Deserialize, Default)] pub struct IpCheckDto { - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub ipv4: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub ipv6: Option, } \ No newline at end of file diff --git a/shared/src/model/status_check.rs b/shared/src/model/status_check.rs index e45c2c337..8040c7503 100644 --- a/shared/src/model/status_check.rs +++ b/shared/src/model/status_check.rs @@ -1,3 +1,4 @@ +use crate::utils::is_blank_optional_string; use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use crate::model::StreamInfo; @@ -6,10 +7,10 @@ use crate::model::StreamInfo; pub struct StatusCheck { pub status: String, pub version: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub build_time: Option, pub server_time: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub cache: Option, pub active_users: usize, pub active_user_connections: usize, diff --git a/shared/src/model/stream_info.rs b/shared/src/model/stream_info.rs index dbdfff6d1..89a72255a 100644 --- a/shared/src/model/stream_info.rs +++ b/shared/src/model/stream_info.rs @@ -1,3 +1,4 @@ +use crate::utils::is_blank_optional_string; use std::net::SocketAddr; use serde::{Deserialize, Serialize}; use crate::model::{M3uPlaylistItem, PlaylistEntry, PlaylistItemType, XtreamCluster, XtreamPlaylistItem}; @@ -65,9 +66,9 @@ pub struct StreamInfo { pub user_agent: String, #[serde(default)] pub ts: u64, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub country: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub session_token: Option, } diff --git a/shared/src/utils/default_utils.rs b/shared/src/utils/default_utils.rs index ae1d11121..520ccd1e7 100644 --- a/shared/src/utils/default_utils.rs +++ b/shared/src/utils/default_utils.rs @@ -1,26 +1,156 @@ +use crate::model::{ConfigTargetOptions, LibraryMetadataFormat}; + +pub const fn is_zero_u16(v: &u16) -> bool { *v == 0 } +pub const fn is_true(v: &bool) -> bool { *v } +pub const fn is_false(v: &bool) -> bool { !*v } pub const fn default_as_true() -> bool { true } -pub fn default_as_default() -> String { String::from("default") } +pub fn is_blank_optional_string(s: &Option) -> bool { + s.as_ref().is_none_or(|s| s.chars().all(|c| c.is_whitespace())) +} +pub fn default_as_default() -> String { "default".into() } // Default delay values for resolving VOD or Series requests, // used to prevent frequent requests that could trigger a provider ban. pub const fn default_resolve_delay_secs() -> u16 { 2 } - +pub const fn is_default_resolve_delay_secs(v: &u16) -> bool { *v == default_resolve_delay_secs() } // Default grace values to accommodate rapid channel changes and seek requests, // helping avoid triggering hard max_connection enforcement. pub const fn default_grace_period_millis() -> u64 { 2000 } +pub const fn is_default_grace_period_millis(v: &u64) -> bool { *v == default_grace_period_millis() } +pub const fn default_shared_burst_buffer_mb() -> u64 { 12 } +pub const fn is_default_shared_burst_buffer_mb(v: &u64) -> bool { *v == default_shared_burst_buffer_mb() } pub const fn default_grace_period_timeout_secs() -> u64 { 4 } +pub const fn is_default_grace_period_timeout_secs(v: &u64) -> bool { *v == default_grace_period_timeout_secs() } pub const fn default_connect_timeout_secs() -> u32 { 6 } +pub const fn is_default_connect_timeout_secs(v: &u32) -> bool { *v == default_connect_timeout_secs() } pub const fn default_resource_retry_attempts() -> u32 { 3 } +pub const fn is_default_resource_retry_attempts(v: &u32) -> bool { *v == default_resource_retry_attempts() } pub const fn default_resource_retry_backoff_ms() -> u64 { 250 } +pub const fn is_default_resource_retry_backoff_ms(v: &u64) -> bool { *v == default_resource_retry_backoff_ms() } pub const fn default_resource_retry_backoff_multiplier() -> f64 { 1.0 } +pub const F64_DEFAULT_EPSILON: f64 = 1e-9; +pub const fn is_default_resource_retry_backoff_multiplier(v: &f64) -> bool { + (*v - default_resource_retry_backoff_multiplier()).abs() < F64_DEFAULT_EPSILON +} + pub fn default_secret() -> String { let mut out = [0u8; 16]; for x in &mut out { *x = fastrand::u8(..); } - out.iter().map(|b| format!("{:02X}", b)).collect() } -pub const fn default_kick_secs() -> u64 { 90 } \ No newline at end of file +pub const fn default_kick_secs() -> u64 { 90 } +pub const fn is_default_kick_secs(v: &u64) -> bool { *v == default_kick_secs() } +/// 30 minutes by default; `0` still means “no expiration.” +pub const fn default_token_ttl_mins() -> u32 { + 30 +} +pub const fn is_default_token_ttl_mins(v: &u32) -> bool { *v == default_token_ttl_mins() } + +pub const fn default_match_threshold() -> u16 { 80 } +pub const fn is_default_match_threshold(v: &u16) -> bool { *v == default_match_threshold() } +pub const fn default_best_match_threshold() -> u16 { 95 } +pub const fn is_default_best_match_threshold(v: &u16) -> bool { *v == default_best_match_threshold() } + +pub const TMDB_API_KEY: &str = "4219e299c89411838049ab0dab19ebd5"; +pub fn default_tmdb_api_key() -> Option { Some(TMDB_API_KEY.to_string()) } +pub fn is_tmdb_default_api_key(s: &Option) -> bool { + s.as_ref().is_none_or(|s| s == TMDB_API_KEY) +} +pub fn is_default_tmdb_language(v: &String) -> bool { v == DEFAULT_TMDB_LANGUAGE } + +pub fn default_metadata_path() -> String { + "library_metadata".to_string() +} + +pub const DEFAULT_TMDB_RATE_LIMIT_MS: u64 = 250; +pub const DEFAULT_TMDB_CACHE_DURATION_DAYS: u32 = 30; +pub const DEFAULT_TMDB_LANGUAGE: &str = "en-US"; +pub const fn default_tmdb_rate_limit_ms() -> u64 { DEFAULT_TMDB_RATE_LIMIT_MS } +pub const fn default_tmdb_cache_duration_days() -> u32 { DEFAULT_TMDB_CACHE_DURATION_DAYS } +pub fn default_tmdb_language() -> String { DEFAULT_TMDB_LANGUAGE.to_owned() } +pub fn is_default_tmdb_rate_limit_ms(v: &u64) -> bool { *v == DEFAULT_TMDB_RATE_LIMIT_MS } +pub fn is_default_tmdb_cache_duration_days(v: &u32) -> bool { *v == DEFAULT_TMDB_CACHE_DURATION_DAYS } + +pub fn default_storage_formats() -> Vec { + vec![] +} +pub fn default_movie_category() -> String { + "Local Movies".to_string() +} +pub fn default_series_category() -> String { + "Local TV Shows".to_string() +} + +pub const DEFAULT_SUPPORTED_LIBRARY_EXTENSIONS: &[&str] = &[ + "mp4", + "mkv", + "avi", + "mov", + "ts", + "m4v", + "webm", +]; + +pub fn default_supported_library_extensions() -> Vec { + DEFAULT_SUPPORTED_LIBRARY_EXTENSIONS + .iter() + .map(|s| (*s).to_owned()) + .collect() +} + +pub fn is_default_supported_library_extensions(v: &[String]) -> bool { + v.len() == DEFAULT_SUPPORTED_LIBRARY_EXTENSIONS.len() + && v.iter() + .zip(DEFAULT_SUPPORTED_LIBRARY_EXTENSIONS) + .all(|(a, b)| a == b) +} + +pub const DEFAULT_VIDEO_EXTENSIONS: &[&str] = &["mkv", "avi", "mp4", "mpeg", "divx", "mov"]; + +pub fn default_supported_video_extensions() -> Vec { + DEFAULT_VIDEO_EXTENSIONS + .iter() + .map(|s| (*s).to_owned()) + .collect() +} + +pub fn is_default_supported_video_extensions(v: &[String]) -> bool { + v.len() == DEFAULT_VIDEO_EXTENSIONS.len() + && v.iter() + .zip(DEFAULT_VIDEO_EXTENSIONS) + .all(|(a, b)| a == b) +} + +pub fn is_config_target_options_empty(v: &Option) -> bool { + v.as_ref().is_none_or(|c| c.is_empty() ) +} + + +////////////////////////////// +// HDHomerun Device Defaults +////////////////////////////// +const DEFAULT_FRIENDLY_NAME: &str = "TuliproxTV"; +const DEFAULT_MANUFACTURER: &str = "Silicondust"; +const DEFAULT_MODEL_NAME: &str = "HDTC-2US"; +const DEFAULT_FIRMWARE_NAME: &str = "hdhomeruntc_atsc"; +const DEFAULT_FIRMWARE_VERSION: &str = "20170930"; +const DEFAULT_DEVICE_TYPE: &str = "urn:schemas-upnp-org:device:MediaServer:1"; +const DEFAULT_DEVICE_UDN: &str = "uuid:12345678-90ab-cdef-1234-567890abcdef::urn:dial-multicast:com.silicondust.hdhomerun"; +pub fn default_friendly_name() -> String { DEFAULT_FRIENDLY_NAME.into() } +pub fn default_manufacturer() -> String { DEFAULT_MANUFACTURER.into() } +pub fn default_model_name() -> String { DEFAULT_MODEL_NAME.into() } +pub fn default_firmware_name() -> String { DEFAULT_FIRMWARE_NAME.into() } +pub fn default_firmware_version() -> String { DEFAULT_FIRMWARE_VERSION.into() } +pub fn default_device_type() -> String { DEFAULT_DEVICE_TYPE.into() } +pub fn default_device_udn() -> String { DEFAULT_DEVICE_UDN.into() } +pub fn is_default_friendly_name(value: &String) -> bool { value == DEFAULT_FRIENDLY_NAME } +pub fn is_default_manufacturer(value: &String) -> bool { value == DEFAULT_MANUFACTURER } +pub fn is_default_model_name(value: &String) -> bool { value == DEFAULT_MODEL_NAME } +pub fn is_default_firmware_name(value: &String) -> bool { value == DEFAULT_FIRMWARE_NAME } +pub fn is_default_firmware_version(value: &String) -> bool { value == DEFAULT_FIRMWARE_VERSION } +pub fn is_default_device_type(value: &String) -> bool { value == DEFAULT_DEVICE_TYPE } +pub fn is_default_device_udn(value: &String) -> bool { value == DEFAULT_DEVICE_UDN } diff --git a/shared/src/utils/serde_utils.rs b/shared/src/utils/serde_utils.rs index 353c1055e..e33bb7199 100644 --- a/shared/src/utils/serde_utils.rs +++ b/shared/src/utils/serde_utils.rs @@ -3,7 +3,7 @@ use base64::engine::general_purpose; use base64::Engine; use chrono::{NaiveDateTime, ParseError, TimeZone, Utc}; use serde::de::Error; -use serde::{Deserialize, Deserializer, Serializer}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_json::Value; use std::io; @@ -182,30 +182,6 @@ where } } -// -// pub fn string_or_number_u32<'de, D>(deserializer: D) -> Result -// where -// D: serde::Deserializer<'de>, -// { -// let value: Value = serde::Deserialize::deserialize(deserializer)?; -// -// match value { -// Value::Null => Ok(0u32), -// Value::Number(num) => { -// if let Some(v) = num.as_u64() { -// Ok(u32::try_from(v).unwrap_or_default()) -// //.map_err(|_| serde::de::Error::custom("Number out of range for u32")) -// } else { -// Ok(0u32), -// //Err(serde::de::Error::custom("Invalid number")) -// } -// } -// Value::String(s) => Ok(s.parse::().unwrap_or_default()) -// //.map_err(|_| serde::de::Error::custom("Invalid string number")), -// _ => Ok(0u32), //Err(serde::de::Error::custom("Expected number or string")), -// } -// } - #[inline] pub fn bin_serialize(value: &T) -> io::Result> where @@ -364,4 +340,41 @@ where | Some(Value::Bool(_)) => Ok(None), Some(v) => Ok(Some(serde_json::to_string(&v).map_err(D::Error::custom)?)), } -} \ No newline at end of file +} + +/// Serialize an Option> in flow-style YAML (- { key: value, ... }) +pub fn serialize_option_slice_flow( + opt: &Option>, + serializer: S, +) -> Result +where + T: serde::Serialize, + S: serde::Serializer, +{ + match opt { + Some(items) if !items.is_empty() => { + serde_saphyr::FlowSeq(items).serialize(serializer) + } + _ => serializer.serialize_none(), + } +} + +pub fn serialize_number_as_string(value: &N, serializer: S) -> Result +where + N: std::fmt::Display, + S: Serializer, +{ + serializer.serialize_str(&value.to_string()) +} + +// +// pub fn serialize_option_number_as_string(value: &Option, serializer: S,) -> Result +// where +// N: std::fmt::Display, +// S: Serializer, +// { +// match value { +// Some(v) => serializer.serialize_str(&v.to_string()), +// None => serializer.serialize_str(""), +// } +// } \ No newline at end of file diff --git a/shared/src/utils/string_utils.rs b/shared/src/utils/string_utils.rs index 29ce29119..f5d8950be 100644 --- a/shared/src/utils/string_utils.rs +++ b/shared/src/utils/string_utils.rs @@ -58,8 +58,8 @@ pub fn get_non_empty_str<'a>(first: &'a str, second: &'a str, third: &'a str) -> } } -pub fn is_blank_optional_string(s: Option<&str>) -> bool { - s.is_none() || s.is_some_and(|s| s.trim().is_empty()) +pub fn is_blank_optional_str(s: Option<&str>) -> bool { + s.as_ref().is_none_or(|s| s.chars().all(|c| c.is_whitespace())) } pub fn trim_slash(s: &str) -> Cow<'_, str> { diff --git a/shared/src/utils/time_utils.rs b/shared/src/utils/time_utils.rs index 87267eb5a..f4026c139 100644 --- a/shared/src/utils/time_utils.rs +++ b/shared/src/utils/time_utils.rs @@ -12,13 +12,17 @@ pub fn current_time_secs() -> u64 { } pub fn unix_ts_to_str(ts: i64) -> Option { + unix_ts_to_str_with_format(ts, "%d.%m.%Y") +} + +pub fn unix_ts_to_str_with_format(ts: i64, format: &str) -> Option { if ts > 0 { let normalized_ts = if ts > 4102444800 { ts / 1000 } else { ts }; - chrono::DateTime::from_timestamp(normalized_ts, 0).map(|dt| dt.format("%d.%m.%Y").to_string()) + chrono::DateTime::from_timestamp(normalized_ts, 0).map(|dt| dt.format(format).to_string()) } else { None }