mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-19 14:22:19 +02:00
File download and web-ui refactoring
This commit is contained in:
@@ -13,6 +13,8 @@
|
||||
* Added simple stats
|
||||
* Target Output is now a list of multiple output formats, !breaking change!
|
||||
* RegExp captures can now be used in mapper attributes
|
||||
* Added file download to a defined directory in config
|
||||
* Refactored web-ui
|
||||
|
||||
Changes in `config.yml`
|
||||
```yaml
|
||||
|
||||
Generated
+163
-102
@@ -69,7 +69,7 @@ dependencies = [
|
||||
"actix-utils",
|
||||
"ahash",
|
||||
"base64",
|
||||
"bitflags 2.4.0",
|
||||
"bitflags 2.4.1",
|
||||
"brotli",
|
||||
"bytes",
|
||||
"bytestring",
|
||||
@@ -142,7 +142,7 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"mio",
|
||||
"socket2 0.5.4",
|
||||
"socket2 0.5.5",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
@@ -203,7 +203,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"smallvec",
|
||||
"socket2 0.5.4",
|
||||
"socket2 0.5.5",
|
||||
"time",
|
||||
"url",
|
||||
]
|
||||
@@ -237,14 +237,15 @@ checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.3"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c99f64d1e06488f620f932677e24bc6e2897582980441ae90a671415bd7ec2f"
|
||||
checksum = "91429305e9f0a25f6205c5b8e0d2db09e0708a7a6df0f42212bb56c32c8ac97a"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"getrandom",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -374,9 +375,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.4"
|
||||
version = "0.21.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ba43ea6f343b788c8764558649e08df62f86c6ef251fdaeb1ffd010a9ae50a2"
|
||||
checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9"
|
||||
|
||||
[[package]]
|
||||
name = "bincode"
|
||||
@@ -395,9 +396,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.4.0"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635"
|
||||
checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
@@ -488,9 +489,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.4.6"
|
||||
version = "4.4.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d04704f56c2cde07f43e8e2c154b43f216dc5c92fc98ada720177362f953b956"
|
||||
checksum = "ac495e00dcec98c83465d5ad66c5c4fabd652fd6686e7c6269b117e729a6f17b"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
@@ -498,9 +499,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.4.6"
|
||||
version = "4.4.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e231faeaca65ebd1ea3c737966bf858971cd38c3849107aa3ea7de90a804e45"
|
||||
checksum = "c77ed9a32a62e6ca27175d00d29d05ca32e396ea1eb5fb01d8256b669cec7663"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
@@ -510,9 +511,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.4.2"
|
||||
version = "4.4.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0862016ff20d69b84ef8247369fabf5c008a7417002411897d40ee1f4532b873"
|
||||
checksum = "cf9804afaaf59a91e75b022a30fb7229a7901f60c755489cc61c9b423b836442"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
@@ -522,9 +523,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd7cc57abe963c6d3b9d8be5b06ba7c8957a930305ca90304f24ef040aa6f961"
|
||||
checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
@@ -576,9 +577,9 @@ checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.9"
|
||||
version = "0.2.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a17b76ff3a4162b0b27f354a0c87015ddad39d35f9c0c36607a3bdd175dde1f1"
|
||||
checksum = "3fbc60abd742b35f2492f808e1abbb83d45f72db402e14c55057edc9c7b1e9e4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
@@ -633,7 +634,7 @@ dependencies = [
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"socket2 0.4.9",
|
||||
"socket2 0.4.10",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
@@ -655,9 +656,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.3.8"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2696e8a945f658fd14dc3b87242e6b80cd0f36ff04ea560fa39082368847946"
|
||||
checksum = "0f32d04922c60427da6f9fef14d042d9edddef64cb9d4ce0d64d0685fbeb1fd3"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
@@ -769,9 +773,9 @@ checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.0.27"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6c98ee8095e9d1dcbf2fcc6d95acccb90d1c81db1e44725c6a984b1dbdfb010"
|
||||
checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
@@ -965,9 +969,9 @@ checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.1"
|
||||
version = "0.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dfda62a12f55daeae5015f81b0baea145391cb4520f86c248fc615d72640d12"
|
||||
checksum = "f93e7192158dbcda357bdec5fb5788eebf8bbac027f3f33e719d29135ae84156"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
@@ -1044,7 +1048,7 @@ dependencies = [
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"socket2 0.4.9",
|
||||
"socket2 0.4.10",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -1080,16 +1084,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.57"
|
||||
version = "0.1.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2fad5b825842d2b38bd206f3e81d6957625fd7f0a361e345c30e01a0ae2dd613"
|
||||
checksum = "8326b86b6cff230b97d0d312a6c40a60726df3332e721f72a1b035f451663b20"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"windows",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1128,7 +1132,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8adf3ddd720272c6ea8bf59463c04e0f93d0bbf7c5439b691bca2987e0270897"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.14.1",
|
||||
"hashbrown 0.14.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1142,9 +1146,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.8.0"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28b29a3cd74f0f4598934efe3aeba42bae0eb4680554128851ebbecb02af14e6"
|
||||
checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3"
|
||||
|
||||
[[package]]
|
||||
name = "is-terminal"
|
||||
@@ -1275,9 +1279,9 @@ checksum = "e34f76eb3611940e0e7d53a9aaa4e6a3151f69541a282fd0dad5571420c53ff1"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.10"
|
||||
version = "0.4.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1cc9717a20b1bb222f333e6a92fd32f7d8a18ddc5a3191a11af45dcbf4dcd16"
|
||||
checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"scopeguard",
|
||||
@@ -1320,6 +1324,7 @@ dependencies = [
|
||||
"serde_yaml",
|
||||
"unidecode",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1361,9 +1366,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "0.8.8"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2"
|
||||
checksum = "3dce281c5e46beae905d4de1870d8b1509a9142b62eedf18b443b011ca8343d0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
@@ -1439,7 +1444,7 @@ version = "0.10.57"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bac25ee399abb46215765b1cb35bc0212377e58a061560d8b29b024fd0430e7c"
|
||||
dependencies = [
|
||||
"bitflags 2.4.0",
|
||||
"bitflags 2.4.1",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
@@ -1489,9 +1494,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.1.1"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e52c774a4c39359c1d1c52e43f73dd91a75a614652c825408eec30c95a9b2067"
|
||||
checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
@@ -1505,13 +1510,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot_core"
|
||||
version = "0.9.8"
|
||||
version = "0.9.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447"
|
||||
checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"redox_syscall 0.4.1",
|
||||
"smallvec",
|
||||
"windows-targets",
|
||||
]
|
||||
@@ -1548,9 +1553,9 @@ checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94"
|
||||
|
||||
[[package]]
|
||||
name = "pest"
|
||||
version = "2.7.4"
|
||||
version = "2.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c022f1e7b65d6a24c0dbbd5fb344c66881bc01f3e5ae74a1c8100f2f985d98a4"
|
||||
checksum = "ae9cee2a55a544be8b89dc6848072af97a20f2422603c10865be2a42b580fff5"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"thiserror",
|
||||
@@ -1559,9 +1564,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest_derive"
|
||||
version = "2.7.4"
|
||||
version = "2.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35513f630d46400a977c4cb58f78e1bfbe01434316e60c37d27b9ad6139c66d8"
|
||||
checksum = "81d78524685f5ef2a3b3bd1cafbc9fcabb036253d9b1463e726a91cd16e2dfc2"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_generator",
|
||||
@@ -1569,9 +1574,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest_generator"
|
||||
version = "2.7.4"
|
||||
version = "2.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc9fc1b9e7057baba189b5c626e2d6f40681ae5b6eb064dc7c7834101ec8123a"
|
||||
checksum = "68bd1206e71118b5356dae5ddc61c8b11e28b09ef6a31acbd15ea48a28e0c227"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_meta",
|
||||
@@ -1582,9 +1587,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest_meta"
|
||||
version = "2.7.4"
|
||||
version = "2.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1df74e9e7ec4053ceb980e7c0c8bd3594e977fde1af91daba9c928e8e8c6708d"
|
||||
checksum = "7c747191d4ad9e4a4ab9c8798f1e82a39affe7ef9648390b7e5548d18e099de6"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"pest",
|
||||
@@ -1655,6 +1660,12 @@ dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.17"
|
||||
@@ -1719,10 +1730,19 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.10.0"
|
||||
name = "redox_syscall"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d119d7c7ca818f8a53c300863d4f87566aac09943aef5b355bb83969dae75d87"
|
||||
checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
@@ -1732,9 +1752,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.1"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "465c6fc0621e4abc4187a2bda0937bfd4f722c2730b29562e19689ea796c9a4b"
|
||||
checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
@@ -1743,9 +1763,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.1"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56d84fdd47036b038fc80dd333d10b6aab10d5d31f4a366e20014def75328d33"
|
||||
checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
@@ -1794,17 +1814,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.16.20"
|
||||
version = "0.17.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc"
|
||||
checksum = "fb0205304757e5d899b9c2e448b867ffd03ae7f988002e47cd24954391394d0b"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"getrandom",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"spin",
|
||||
"untrusted",
|
||||
"web-sys",
|
||||
"winapi",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1837,11 +1856,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "0.38.18"
|
||||
version = "0.38.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a74ee2d7c2581cd139b42447d7d9389b889bdaad3a73f1ebb16f2a3237bb19c"
|
||||
checksum = "67ce50cb2e16c2903e30d1cbccfd8387a74b9d4c938b6a4c5ec6cc7556f7a8a0"
|
||||
dependencies = [
|
||||
"bitflags 2.4.0",
|
||||
"bitflags 2.4.1",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
@@ -1850,9 +1869,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.21.7"
|
||||
version = "0.21.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd8d6c9f025a446bc4d18ad9632e69aec8f287aa84499ee335599fabd20c3fd8"
|
||||
checksum = "446e14c5cda4f3f30fe71863c34ec70f5ac79d6087097ad0bb433e1be5edf04c"
|
||||
dependencies = [
|
||||
"log",
|
||||
"ring",
|
||||
@@ -1871,9 +1890,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.101.6"
|
||||
version = "0.101.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c7d5dece342910d9ba34d259310cae3e0154b873b35408b787b59bce53d34fe"
|
||||
checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"untrusted",
|
||||
@@ -1902,9 +1921,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sct"
|
||||
version = "0.7.0"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4"
|
||||
checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"untrusted",
|
||||
@@ -1941,18 +1960,18 @@ checksum = "836fa6a3e1e547f9a2c4040802ec865b5d85f4014efe00555d7090a3dcaa1090"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.188"
|
||||
version = "1.0.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf9e0fcba69a370eed61bcf2b728575f726b50b55cba78064753d708ddc7549e"
|
||||
checksum = "8e422a44e74ad4001bdc8eede9a4570ab52f71190e9c076d14369f38b9200537"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.188"
|
||||
version = "1.0.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4eca7ac642d82aa35b60049a6eccb4be6be75e599bd2e9adb5f875a737654af2"
|
||||
checksum = "1e48d1f918009ce3145511378cf68d613e3b3d9137d67272562080d68a2b32d5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -2054,9 +2073,9 @@ checksum = "942b4a808e05215192e39f4ab80813e599068285906cc91aa64f923db842bd5a"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.4.9"
|
||||
version = "0.4.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662"
|
||||
checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
@@ -2064,9 +2083,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.5.4"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4031e820eb552adee9295814c0ced9e5cf38ddf1e8b7d566d6de8e2538ea989e"
|
||||
checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
@@ -2074,9 +2093,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.5.2"
|
||||
version = "0.9.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
|
||||
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
@@ -2135,7 +2154,7 @@ checksum = "cb94d2f3cc536af71caac6b6fcebf65860b347e7ce0cc9ebe8f70d3e521054ef"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"fastrand 2.0.1",
|
||||
"redox_syscall",
|
||||
"redox_syscall 0.3.5",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
]
|
||||
@@ -2151,18 +2170,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.49"
|
||||
version = "1.0.50"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1177e8c6d7ede7afde3585fd2513e611227efd6481bd78d2e82ba1ce16557ed4"
|
||||
checksum = "f9a7210f5c9a7156bb50aa36aed4c95afb51df0df00713949448cf9e97d382d2"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.49"
|
||||
version = "1.0.50"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10712f02019e9288794769fba95cd6847df9874d49d871d062172f9dd41bc4cc"
|
||||
checksum = "266b2e40bc00e5a6c09c3584011e08b06f123c00362c92b975ba9843aaaa14b8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -2171,12 +2190,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.29"
|
||||
version = "0.3.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "426f806f4089c493dcac0d24c29c01e2c38baf8e30f1b716ee37e83d200b18fe"
|
||||
checksum = "c4a34ab300f2dee6e562c10a046fc05e358b29f9bf92277f30c3c8d82275f6f5"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"itoa",
|
||||
"powerfmt",
|
||||
"serde",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
@@ -2226,7 +2246,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2 0.5.4",
|
||||
"socket2 0.5.5",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
@@ -2272,11 +2292,10 @@ checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.37"
|
||||
version = "0.1.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8"
|
||||
checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
@@ -2285,9 +2304,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.26"
|
||||
version = "0.1.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f4f31f56159e98206da9efd823404b79b6ef3143b4a7ab76e67b1751b25a4ab"
|
||||
checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -2296,9 +2315,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.31"
|
||||
version = "0.1.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0955b8137a1df6f1a2e9a37d8a6656291ff0297c1a97c24e0d8425fe2312f79a"
|
||||
checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
@@ -2375,9 +2394,9 @@ checksum = "f28467d3e1d3c6586d8f25fa243f544f5800fec42d97032474e17222c2b75cfa"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.7.1"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
@@ -2396,6 +2415,28 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88ad59a7560b41a70d191093a945f0b87bc1deeda46fb237479708a1d6b6cdfc"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"rand",
|
||||
"uuid-macro-internal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uuid-macro-internal"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d8c6bba9b149ee82950daefc9623b32bb1dacbfb1890e352f6b887bd582adaf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.38",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
@@ -2556,10 +2597,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.48.0"
|
||||
name = "windows-core"
|
||||
version = "0.51.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f"
|
||||
checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
@@ -2640,6 +2681,26 @@ dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.7.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69c48d63854f77746c68a5fbb4aa17f3997ece1cb301689a257af8cb80610d21"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.7.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c258c1040279e4f88763a113de72ce32dde2d50e2a94573f15dd534cea36a16d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.38",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.12.4"
|
||||
|
||||
@@ -39,3 +39,4 @@ log = "0.4"
|
||||
env_logger = "0.10"
|
||||
rustelebot = "0.3"
|
||||
bincode = "1.3"
|
||||
uuid = { version = "1.3.0", features = ["v4", "fast-rng", "macro-diagnostics"] }
|
||||
@@ -31,7 +31,6 @@ m3u-filter is a simple application which can:
|
||||
|
||||
If you have a playlist which contains unwanted entries, you can create filter which include or discard entries
|
||||
based on the header information of the playlist entries, you can rename entries or map entries based on regular expressions.
|
||||
Currently, filter and rename operations support group, name and title fields.
|
||||
|
||||
You can run m3u-filter as command line application to update your playlists (manually or as cron job), or you can
|
||||
run it in server mode and open the web-ui to see the contents of the playlist, filter/search content and save
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
],
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"@typescript-eslint/ban-ts-ignore": "off",
|
||||
"react/display-name": "off",
|
||||
"react/prop-types": "off"
|
||||
},
|
||||
|
||||
+19
-24
@@ -17,43 +17,38 @@
|
||||
"analyze": "source-map-explorer 'build/static/js/*.js'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "11.10.5",
|
||||
"@emotion/styled": "11.10.5",
|
||||
"@mui/icons-material": "5.11.0",
|
||||
"@mui/material": "5.11.6",
|
||||
"@mui/styles": "5.11.2",
|
||||
"cross-env": "7.0.3",
|
||||
"file-saver": "2.0.5",
|
||||
"notistack": "2.0.8",
|
||||
"notistack": "3.0.1",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"rxjs": "7.8.0"
|
||||
"rxjs": "7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/runtime": "7.20.13",
|
||||
"@types/file-saver": "2.0.5",
|
||||
"@types/react": "18.0.27",
|
||||
"@types/react-dom": "18.0.10",
|
||||
"@types/video.js": "7.3.50",
|
||||
"@typescript-eslint/eslint-plugin": "5.49.0",
|
||||
"@typescript-eslint/parser": "5.49.0",
|
||||
"@babel/runtime": "7.23.2",
|
||||
"@types/file-saver": "2.0.6",
|
||||
"@types/react": "18.2.31",
|
||||
"@types/react-dom": "18.2.14",
|
||||
"@types/video.js": "7.3.55",
|
||||
"@typescript-eslint/eslint-plugin": "6.8.0",
|
||||
"@typescript-eslint/parser": "6.8.0",
|
||||
"babel-eslint": "10.1.0",
|
||||
"concurrently": "7.6.0",
|
||||
"eslint": "8.32.0",
|
||||
"eslint-config-prettier": "8.6.0",
|
||||
"concurrently": "8.2.2",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint": "8.52.0",
|
||||
"eslint-config-prettier": "9.0.0",
|
||||
"eslint-config-react-app": "7.0.1",
|
||||
"eslint-plugin-flowtype": "8.0.3",
|
||||
"eslint-plugin-import": "2.27.5",
|
||||
"eslint-plugin-jest": "27.2.1",
|
||||
"eslint-plugin-import": "2.29.0",
|
||||
"eslint-plugin-jest": "27.4.3",
|
||||
"eslint-plugin-jsx-a11y": "6.7.1",
|
||||
"eslint-plugin-node": "11.1.0",
|
||||
"eslint-plugin-react": "7.32.1",
|
||||
"eslint-plugin-react": "7.33.2",
|
||||
"eslint-plugin-react-hooks": "4.6.0",
|
||||
"node-sass": "8.0.0",
|
||||
"prettier": "2.8.3",
|
||||
"node-sass": "9.0.0",
|
||||
"prettier": "3.0.3",
|
||||
"react-scripts": "5.0.1",
|
||||
"source-map-explorer": "2.5.3",
|
||||
"typescript": "4.9.4",
|
||||
"typescript": "5.2.2",
|
||||
"wait-on": "7.0.1"
|
||||
},
|
||||
"browserslist": {
|
||||
|
||||
@@ -8,10 +8,15 @@ const HEADER_ACCEPT = 'Accept';
|
||||
|
||||
export default interface ApiService {
|
||||
get<T>(query: string, url?: string): Observable<T>;
|
||||
|
||||
post<T>(query: string, payload: any, url?: string): Observable<T>;
|
||||
|
||||
put<T>(query: string, payload: any, url?: string): Observable<T>;
|
||||
delete<T>(query: string, url?: string) : Observable<T>;
|
||||
postFile<T>(query: string, fileName: string, file: any, url?: string) : Observable<T>;
|
||||
|
||||
delete<T>(query: string, url?: string): Observable<T>;
|
||||
|
||||
postFile<T>(query: string, fileName: string, file: any, url?: string): Observable<T>;
|
||||
downloadFile<T>(query: string, payload: any, url?: string): Observable<T>;
|
||||
}
|
||||
|
||||
export class DefaultApiService implements ApiService {
|
||||
@@ -114,4 +119,31 @@ export class DefaultApiService implements ApiService {
|
||||
});
|
||||
}
|
||||
|
||||
downloadFile<T>(query: string, payload: any, url?: string): Observable<T> {
|
||||
return this.post(query, payload, url);
|
||||
// return new Observable((observer) => {
|
||||
//
|
||||
// axios.post<T>(this.getUrl(query, url), payload, {responseType: 'blob', headers: this.getHeaders()})
|
||||
// .then((response) => {
|
||||
// const disposition = response.headers['content-disposition'];
|
||||
// let filename = disposition.split(/;(.+)/)[1].split(/=(.+)/)[1];
|
||||
// if (filename.toLowerCase().startsWith("utf-8''"))
|
||||
// filename = decodeURIComponent(filename.replace("utf-8''", ''));
|
||||
// else
|
||||
// filename = filename.replace(/['"]/g, '');
|
||||
// return {filename, data: response.data};
|
||||
// }).then((params: { filename: string, data: any }) => {
|
||||
// const url = window.URL.createObjectURL(params.data);
|
||||
// const a = document.createElement('a');
|
||||
// a.href = url;
|
||||
// a.download = params.filename;
|
||||
// document.body.appendChild(a); // append the element to the dom
|
||||
// a.click();
|
||||
// a.remove(); // afterwards, remove the element
|
||||
// observer.next(true as any);
|
||||
// observer.complete();
|
||||
// }).catch((error) => observer.error(this.prepareError(error)));
|
||||
// });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import ApiService, {DefaultApiService} from "./api-service";
|
||||
import {Observable, throwError, timer} from "rxjs";
|
||||
import {FileDownloadInfo, FileDownloadRequest, FileDownloadResponse} from "../model/file-download";
|
||||
import {first} from "rxjs/operators";
|
||||
|
||||
//const FILE_API_PATH = 'file';
|
||||
const FILE_DOWNLOAD_API_PATH = 'file/download';
|
||||
|
||||
export default interface FileApiService extends ApiService {
|
||||
download(req: FileDownloadRequest): Observable<FileDownloadResponse>;
|
||||
getDownloadInfo(download_id: string): Observable<FileDownloadInfo>;
|
||||
}
|
||||
|
||||
export class DefaultFileApiService extends DefaultApiService implements FileApiService {
|
||||
download(req: FileDownloadRequest): Observable<FileDownloadResponse> {
|
||||
if (req.url) {
|
||||
return this.post<FileDownloadResponse>(FILE_DOWNLOAD_API_PATH, req);
|
||||
}
|
||||
return throwError(() => new Error('Invalid arguments'));
|
||||
}
|
||||
|
||||
getDownloadInfo(download_id: string): Observable<FileDownloadInfo> {
|
||||
if (download_id) {
|
||||
return new Observable((observer) => {
|
||||
const fetch_info = () => {
|
||||
this.get<FileDownloadInfo>(FILE_DOWNLOAD_API_PATH + '/' + download_id).pipe(first()).subscribe({
|
||||
next: (info: FileDownloadInfo) => {
|
||||
if (info.finished) {
|
||||
observer.next(info);
|
||||
observer.complete();
|
||||
} else if (info.filesize != undefined) {
|
||||
observer.next(info);
|
||||
setTimeout(() => fetch_info(), 1000);
|
||||
} else {
|
||||
observer.error("unknown file download state");
|
||||
}
|
||||
},
|
||||
error: (err) => observer.error(err)
|
||||
});
|
||||
}
|
||||
fetch_info();
|
||||
});
|
||||
}
|
||||
return throwError(() => new Error('Invalid arguments'));
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
import ApiService, {DefaultApiService} from "./api-service";
|
||||
import {PlaylistGroup} from "../model/playlist";
|
||||
import {Observable} from "rxjs";
|
||||
import {Observable, throwError} from "rxjs";
|
||||
import {PlaylistRequest} from "../model/playlist-request";
|
||||
|
||||
const PLAYLIST_API_PATH = 'playlist';
|
||||
|
||||
export default interface PlaylistApiService extends ApiService {
|
||||
getPlaylist(url: string): Observable<PlaylistGroup[]>;
|
||||
getPlaylist(req: PlaylistRequest): Observable<PlaylistGroup[]>;
|
||||
}
|
||||
|
||||
export class DefaultPlaylistApiService extends DefaultApiService implements PlaylistApiService {
|
||||
getPlaylist(url: string): Observable<PlaylistGroup[]> {
|
||||
return this.post<PlaylistGroup[]>(PLAYLIST_API_PATH, {url});
|
||||
getPlaylist(req: PlaylistRequest): Observable<PlaylistGroup[]> {
|
||||
if (req.url || req.input_id != undefined) {
|
||||
return this.post<PlaylistGroup[]>(PLAYLIST_API_PATH, req);
|
||||
}
|
||||
return throwError(() => new Error('Invalid arguments'));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
justify-content: center;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
|
||||
@@ -16,7 +15,7 @@
|
||||
|
||||
.app-main {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
flex-flow: column;
|
||||
justify-content: center;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
|
||||
+28
-10
@@ -1,4 +1,4 @@
|
||||
import React, {useRef, useState, useCallback, useMemo} from 'react';
|
||||
import React, {useRef, useState, useCallback, useMemo, useEffect} from 'react';
|
||||
import './app.scss';
|
||||
import SourceSelector from "../component/source-selector/source-selector";
|
||||
import PlaylistViewer, {IPlaylistViewer} from "../component/playlist-viewer/playlist-viewer";
|
||||
@@ -9,10 +9,12 @@ import {useServices} from "../provider/service-provider";
|
||||
import {first} from "rxjs/operators";
|
||||
import Progress from '../component/progress/progress';
|
||||
import PlaylistFilter from "../component/playlist-filter/playlist-filter";
|
||||
import {Subject} from "rxjs";
|
||||
import {noop, Subject} from "rxjs";
|
||||
import PlaylistVideo from "../component/playlist-video/playlist-video";
|
||||
import ClipboardViewer from "../component/clipboard-viewer/clipboard-viewer";
|
||||
import Sidebar from "../component/sidebar/sidebar";
|
||||
import {PlaylistRequest} from "../model/playlist-request";
|
||||
import ServerConfig from "../model/server-config";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-empty-interface */
|
||||
interface AppProps {
|
||||
@@ -23,14 +25,15 @@ export default function App(props: AppProps) {
|
||||
const searchChannel = useMemo<Subject<string>>(() => new Subject<string>(), []);
|
||||
const [progress, setProgress] = useState<boolean>(false);
|
||||
const [playlist, setPlaylist] = useState<PlaylistGroup[]>([]);
|
||||
const clipboardChannelRef = useRef<Subject<string>>(new Subject<string>());
|
||||
const [serverConfig, setServerConfig] = useState<ServerConfig>(undefined);
|
||||
const clipboardChannel = useMemo<Subject<string>>(() => new Subject<string>(), []);
|
||||
const viewerRef = useRef<IPlaylistViewer>();
|
||||
const {enqueueSnackbar/*, closeSnackbar*/} = useSnackbar();
|
||||
const services = useServices();
|
||||
const videoChannel = useMemo(() => new Subject<PlaylistItem>(), []);
|
||||
const handleDownload = useCallback((url: string) => {
|
||||
const handleDownload = useCallback((req: PlaylistRequest) => {
|
||||
setProgress(true);
|
||||
services.playlist().getPlaylist(url).pipe(first()).subscribe({
|
||||
services.playlist().getPlaylist(req).pipe(first()).subscribe({
|
||||
next: (pl: PlaylistGroup[]) => {
|
||||
enqueueSnackbar('Sucessfully downloaded playlist', {variant: 'success'})
|
||||
setPlaylist(pl);
|
||||
@@ -64,21 +67,36 @@ export default function App(props: AppProps) {
|
||||
}, [videoChannel]);
|
||||
|
||||
const handleOnCopy = useCallback((playlistItem: PlaylistItem): void => {
|
||||
clipboardChannelRef.current.next(playlistItem.url);
|
||||
}, []);
|
||||
clipboardChannel.next(playlistItem.header.url);
|
||||
}, [clipboardChannel]);
|
||||
|
||||
useEffect(() => {
|
||||
services.config().getServerConfig().pipe(first()).subscribe({
|
||||
next: (cfg: ServerConfig) => {
|
||||
setServerConfig(cfg);
|
||||
},
|
||||
error: (err) => {
|
||||
enqueueSnackbar('Failed to download server config!', {variant: 'error'});
|
||||
},
|
||||
complete: noop,
|
||||
});
|
||||
return noop
|
||||
}, [enqueueSnackbar, services]);
|
||||
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<div className={'app-header'}>m3u-filter</div>
|
||||
<div className={'app-main'}>
|
||||
<Sidebar>
|
||||
<ClipboardViewer channel={clipboardChannelRef.current}></ClipboardViewer>
|
||||
<ClipboardViewer channel={clipboardChannel}></ClipboardViewer>
|
||||
</Sidebar>
|
||||
<div className={'app-content'}>
|
||||
<SourceSelector onDownload={handleDownload}/>
|
||||
<SourceSelector onDownload={handleDownload} serverConfig={serverConfig}/>
|
||||
<PlaylistFilter onFilter={handleFilter}/>
|
||||
<PlaylistViewer ref={viewerRef} playlist={playlist} searchChannel={searchChannel}
|
||||
onProgress={handleProgress} onCopy={handleOnCopy} onPlay={handleOnPlay}/>
|
||||
onProgress={handleProgress} onCopy={handleOnCopy} onPlay={handleOnPlay}
|
||||
serverConfig={serverConfig}/>
|
||||
<PlaylistVideo channel={videoChannel}/>
|
||||
<Toolbar onDownload={handleSave}/>
|
||||
<Progress visible={progress}/>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
@import '../../scss/theme';
|
||||
|
||||
$viewer-width: 200px;
|
||||
$viewer-height: 200px;
|
||||
|
||||
.clipboard-viewer {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
flex: 1 1 auto;
|
||||
flex-flow: column;
|
||||
|
||||
.clipboard-viewer-toolbar {
|
||||
@@ -30,8 +30,7 @@ $viewer-width: 200px;
|
||||
|
||||
.clipboard-viewer-content {
|
||||
flex-grow: 1;
|
||||
max-width: $viewer-width;
|
||||
min-width: $viewer-width;
|
||||
max-height: $viewer-height;
|
||||
color: var(--text-color);
|
||||
overflow: auto;
|
||||
white-space: nowrap;
|
||||
@@ -42,5 +41,6 @@ $viewer-width: 200px;
|
||||
padding: 0; /* Remove padding */
|
||||
margin: 0; /* Remove margins */
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, {useCallback, useEffect, useState} from 'react';
|
||||
import './clipboard-viewer.scss';
|
||||
import {noop, Observable} from "rxjs";
|
||||
import {ContentCopy, DeleteSweep} from "@mui/icons-material";
|
||||
import copyToClipboard from "../../utils/clipboard";
|
||||
import {first} from "rxjs/operators";
|
||||
import {useSnackbar} from "notistack";
|
||||
import {getIconByName} from "../../icons/icons";
|
||||
|
||||
interface ClipboardViewerProps {
|
||||
channel: Observable<string>;
|
||||
@@ -18,7 +18,7 @@ export default function ClipboardViewer(props: ClipboardViewerProps): JSX.Elemen
|
||||
|
||||
useEffect(() => {
|
||||
const sub = channel.subscribe({
|
||||
next: (value: string) => setData(d => [ ...d, value]),
|
||||
next: (value: string) => setData(d => [...d, value]),
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
}, [channel]);
|
||||
@@ -30,7 +30,8 @@ export default function ClipboardViewer(props: ClipboardViewerProps): JSX.Elemen
|
||||
const handleCopyToClipboard = useCallback(() => {
|
||||
if (data?.length) {
|
||||
copyToClipboard(data.join('\n')).pipe(first()).subscribe({
|
||||
next: value => enqueueSnackbar(value ? "Copied to clipboard" : "Copy to clipboard failed!", {variant: value ? 'success' : 'error'}),
|
||||
next: value => enqueueSnackbar(value ? "Copied to clipboard" : "Copy to clipboard failed!",
|
||||
{variant: value ? 'success' : 'error'}),
|
||||
error: err => enqueueSnackbar("Copy to clipboard failed!", {variant: 'error'}),
|
||||
complete: noop,
|
||||
});
|
||||
@@ -39,12 +40,12 @@ export default function ClipboardViewer(props: ClipboardViewerProps): JSX.Elemen
|
||||
|
||||
return <div className={'clipboard-viewer'}>
|
||||
<div className={'clipboard-viewer-toolbar'}>
|
||||
<button className={'toolbar-btn'} onClick={handleClear}><DeleteSweep/></button>
|
||||
<button className={'toolbar-btn'} onClick={handleCopyToClipboard}><ContentCopy/></button>
|
||||
<button className={'toolbar-btn'} onClick={handleClear}>{getIconByName('DeleteSweep')}</button>
|
||||
<button className={'toolbar-btn'} onClick={handleCopyToClipboard}>{getIconByName('ContentCopy')}</button>
|
||||
</div>
|
||||
<div className={'clipboard-viewer-content'}>
|
||||
<ul>
|
||||
{data.map((t, i) => <li key={'text-'+ i}>{t}</li>)}
|
||||
{data.map((t, i) => <li key={'text-' + i}>{t}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, {useMemo, useCallback, useRef, KeyboardEvent} from "react";
|
||||
import './playlist-filter.scss';
|
||||
import {TextField, InputAdornment, IconButton} from "@mui/material";
|
||||
import {Search} from "@mui/icons-material";
|
||||
import {getIconByName} from "../../icons/icons";
|
||||
|
||||
interface PlaylistFilterProps {
|
||||
onFilter: (filter: string) => void;
|
||||
@@ -22,31 +21,11 @@ export default function PlaylistFilter(props: PlaylistFilterProps) {
|
||||
}
|
||||
}, [handleSearch]);
|
||||
|
||||
const inputProps = useMemo(() => (
|
||||
{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
className={"icon-button"}
|
||||
aria-label="select"
|
||||
onClick={handleSearch}
|
||||
edge="end">
|
||||
<Search />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}
|
||||
), [handleSearch]);
|
||||
|
||||
return <div className={'playlist-filter'}>
|
||||
<TextField
|
||||
className={'text-input'}
|
||||
inputRef={textField} label="Search" variant="outlined"
|
||||
InputProps={inputProps}
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
}}
|
||||
onKeyUp={handleKeyPress}
|
||||
/>
|
||||
<div className={'input-field'} onKeyUp={handleKeyPress}>
|
||||
<label>Search</label>
|
||||
<input ref={textField}/>
|
||||
<button onClick={handleSearch}>{getIconByName('Search')}</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -51,6 +51,7 @@
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
|
||||
.tool-button {
|
||||
display: flex;
|
||||
@@ -108,3 +109,30 @@
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.download-info {
|
||||
position: fixed;
|
||||
left: 12px;
|
||||
top: 12px;
|
||||
background-color: var(--popup-background-color);
|
||||
color: var(--popup-color);
|
||||
filter: drop-shadow(2px 2px 3px var(--popup-shadow-color));
|
||||
border: 1px solid var(--popup-border-color);
|
||||
z-index: 100;
|
||||
|
||||
ul {
|
||||
list-style-type: none; /* Remove bullets */
|
||||
padding: 0; /* Remove padding */
|
||||
margin: 0; /* Remove margins */
|
||||
li {
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
color: var(--popup-hover-color);
|
||||
background-color: var(--popup-hover-background-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,23 @@
|
||||
import React, {useCallback, useState, useRef} from 'react';
|
||||
import React, {useCallback, useState, useRef, useEffect} from 'react';
|
||||
import './playlist-tree.scss';
|
||||
import {PlaylistGroup, PlaylistItem} from "../../model/playlist";
|
||||
import {ExpandMore, ChevronRight, LinkRounded, PlayArrow} from "@mui/icons-material";
|
||||
import copyToClipboard from "../../utils/clipboard";
|
||||
import {first} from "rxjs/operators";
|
||||
import {noop} from "rxjs";
|
||||
import {noop, Subscription} from "rxjs";
|
||||
import {useSnackbar} from "notistack";
|
||||
import {getIconByName} from "../../icons/icons";
|
||||
import {useServices} from "../../provider/service-provider";
|
||||
import ServerConfig from "../../model/server-config";
|
||||
import {FileDownloadInfo, FileDownloadResponse} from "../../model/file-download";
|
||||
|
||||
const VALID_VIDEO_FILES = ['mkv', 'mp4', 'avi'];
|
||||
|
||||
type DownloadInfo = {filename: string, filesize: number};
|
||||
|
||||
export type PlaylistTreeState = { [key: number]: boolean };
|
||||
|
||||
interface PlaylistTreeProps {
|
||||
serverConfig: ServerConfig;
|
||||
data: PlaylistGroup[];
|
||||
state: PlaylistTreeState;
|
||||
onCopy: (playlistItem: PlaylistItem) => void;
|
||||
@@ -17,18 +25,50 @@ interface PlaylistTreeProps {
|
||||
}
|
||||
|
||||
export default function PlaylistTree(props: PlaylistTreeProps) {
|
||||
const {state, data, onCopy, onPlay} = props;
|
||||
const {serverConfig, state, data, onCopy, onPlay} = props;
|
||||
|
||||
const [, setForceUpdate] = useState(null);
|
||||
const expanded = useRef<PlaylistTreeState>({});
|
||||
const {enqueueSnackbar/*, closeSnackbar*/} = useSnackbar();
|
||||
const services = useServices();
|
||||
const [videoExtensions, setVideoExtensions] = useState<string[]>([]);
|
||||
const [downloads, setDownloads] = useState<Record<string, DownloadInfo>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (serverConfig) {
|
||||
setVideoExtensions(serverConfig.video.extensions);
|
||||
}
|
||||
return noop;
|
||||
}, [serverConfig]);
|
||||
|
||||
|
||||
const setDownloadsInfo = useCallback((info: FileDownloadInfo) => {
|
||||
if (info.finished == undefined && info.filesize == undefined) {
|
||||
setDownloads((downloads) => {
|
||||
downloads[info.download_id] = {filename: info.filename, filesize: 0};
|
||||
return {...downloads};
|
||||
});
|
||||
} else {
|
||||
if (info.filesize != undefined) {
|
||||
setDownloads((downloads) => {
|
||||
downloads[info.download_id].filesize = info.filesize;
|
||||
return {...downloads};
|
||||
});
|
||||
} else {
|
||||
setDownloads((downloads) => {
|
||||
delete downloads[info.download_id];
|
||||
return {...downloads};
|
||||
});
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getPlaylistItemById = useCallback((itemId: string): PlaylistItem => {
|
||||
const id = parseInt(itemId);
|
||||
if (data && !isNaN(id)) {
|
||||
for (let i=0, len = data.length; i < len; i++) {
|
||||
for (let i = 0, len = data.length; i < len; i++) {
|
||||
const group = data[i];
|
||||
for (let j=0, clen = group.channels.length; j < clen; j++) {
|
||||
for (let j = 0, clen = group.channels.length; j < clen; j++) {
|
||||
const plitem = group.channels[j];
|
||||
if (plitem.id == id) {
|
||||
return plitem;
|
||||
@@ -55,7 +95,7 @@ export default function PlaylistTree(props: PlaylistTreeProps) {
|
||||
const item = getPlaylistItemById(e.target.dataset.item);
|
||||
if (item) {
|
||||
onCopy(item);
|
||||
copyToClipboard(item.url).pipe(first()).subscribe({
|
||||
copyToClipboard(item.header.url).pipe(first()).subscribe({
|
||||
next: value => enqueueSnackbar(value ? "URL copied to clipboard" : "Copy to clipboard failed!", {variant: value ? 'success' : 'error'}),
|
||||
error: err => enqueueSnackbar("Copy to clipboard failed!", {variant: 'error'}),
|
||||
complete: noop,
|
||||
@@ -63,36 +103,91 @@ export default function PlaylistTree(props: PlaylistTreeProps) {
|
||||
}
|
||||
}, [enqueueSnackbar, getPlaylistItemById, onCopy]);
|
||||
|
||||
const startPollingDownload = useCallback((downloadId: string) => {
|
||||
let subs: Subscription = services.file().getDownloadInfo(downloadId).subscribe({
|
||||
next: (info: FileDownloadInfo) => setDownloadsInfo(info),
|
||||
error: (err) => enqueueSnackbar("Download file failed!", {variant: 'error'}),
|
||||
complete: () => subs.unsubscribe()
|
||||
});
|
||||
}, [setDownloadsInfo, enqueueSnackbar, services]);
|
||||
|
||||
const handleDownloadUrl = useCallback((e: any) => {
|
||||
if (! serverConfig.video.download?.directory) {
|
||||
enqueueSnackbar("Please updated the server configuration and add video.download directory and headers!", {variant: 'error'})
|
||||
} else {
|
||||
const item = getPlaylistItemById(e.target.dataset.item);
|
||||
if (item) {
|
||||
let filename = item.header.title;
|
||||
const parts = item.header.url.split('.');
|
||||
let ext = undefined;
|
||||
if (parts.length > 1) {
|
||||
ext = parts[parts.length - 1];
|
||||
}
|
||||
|
||||
if (VALID_VIDEO_FILES.includes(ext)) {
|
||||
filename = filename + '.' + ext;
|
||||
services.file().download({url: item.header.url, filename}).pipe(first()).subscribe({
|
||||
next: (download: FileDownloadResponse) => {
|
||||
setDownloadsInfo({download_id: download.download_id, filename: filename});
|
||||
startPollingDownload(download.download_id)
|
||||
},
|
||||
error: err => enqueueSnackbar("Download failed!", {variant: 'error'}),
|
||||
complete: noop,
|
||||
});
|
||||
} else {
|
||||
enqueueSnackbar("Invalid filetype!", {variant: 'error'})
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [serverConfig, enqueueSnackbar, getPlaylistItemById, services, startPollingDownload, setDownloadsInfo]);
|
||||
|
||||
const handlePlayUrl = useCallback((e: any) => {
|
||||
if (onPlay) {
|
||||
const item = getPlaylistItemById(e.target.dataset.item);
|
||||
if (item) {
|
||||
onPlay(item);
|
||||
onPlay(item);
|
||||
}
|
||||
}
|
||||
}, [onPlay, getPlaylistItemById]);
|
||||
|
||||
const isVideoFile = useCallback((entry: PlaylistItem): boolean => {
|
||||
if (videoExtensions && entry.header.url) {
|
||||
for (const ext of videoExtensions) {
|
||||
if (entry.header.url.endsWith(ext)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [videoExtensions]);
|
||||
|
||||
const renderEntry = useCallback((entry: PlaylistItem, index: number): React.ReactNode => {
|
||||
return <div key={entry.id} className={'tree-channel'}>
|
||||
<div className={'tree-channel-tools'}>
|
||||
<div className={'tool-button'} data-item={entry.id} onClick={handleClipboardUrl}>
|
||||
<LinkRounded/>
|
||||
{getIconByName('LinkRounded')}
|
||||
</div>
|
||||
<div style={{display: 'none'}} className={'tool-button'} data-item={entry.id} onClick={handlePlayUrl}>
|
||||
<PlayArrow/>
|
||||
{getIconByName('PlayArrow')}
|
||||
</div>
|
||||
{isVideoFile(entry) &&
|
||||
<div className={'tool-button'} data-item={entry.id} onClick={handleDownloadUrl}>
|
||||
{getIconByName('Download')}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div className={'tree-channel-content'}>
|
||||
<div className={'tree-channel-nr'}>{index + 1}</div>
|
||||
{entry.header.name}</div>
|
||||
</div>
|
||||
}, [handleClipboardUrl, handlePlayUrl]);
|
||||
}, [handleClipboardUrl, handlePlayUrl, handleDownloadUrl, isVideoFile]);
|
||||
|
||||
const renderGroup = useCallback((group: PlaylistGroup): React.ReactNode => {
|
||||
return <div className={'tree-group'} key={group.id}>
|
||||
<div className={'tree-group-header'}>
|
||||
<div className={'tree-expander'} data-group={group.id}
|
||||
onClick={handleExpand}>{expanded.current[group.id] ? <ExpandMore/> : <ChevronRight/>}</div>
|
||||
onClick={handleExpand}>{getIconByName(expanded.current[group.id] ?
|
||||
'ExpandMore' : 'ChevronRight')}</div>
|
||||
<div className={'tree-group-header-content'}>
|
||||
<input type={"checkbox"} onChange={handleChange} data-group={group.id}/>
|
||||
{group.title}
|
||||
@@ -115,5 +210,17 @@ export default function PlaylistTree(props: PlaylistTreeProps) {
|
||||
</React.Fragment>;
|
||||
}, [data, renderGroup]);
|
||||
|
||||
return <div className={'playlist-tree'}>{renderPlaylist()}</div>;
|
||||
const renderDownloads = useCallback((): React.ReactNode => {
|
||||
const keys = Object.keys(downloads)
|
||||
if (keys.length) {
|
||||
let elements = keys.map(key => {
|
||||
const info: DownloadInfo = downloads[key];
|
||||
return <li key={key}>{info.filename}: {info.filesize ? (info.filesize / 1_048_576).toFixed(2) : 0} MB</li>;
|
||||
})
|
||||
return <div className={'download-info'}><ul>{elements}</ul></div>;
|
||||
}
|
||||
return <></>;
|
||||
}, [downloads]);
|
||||
|
||||
return <div className={'playlist-tree'}>{renderPlaylist()}{renderDownloads()}</div>;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
.playlist-viewer {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
|
||||
@@ -5,6 +5,7 @@ import PlaylistTree, {PlaylistTreeState} from "../playlist-tree/playlist-tree";
|
||||
import {Observable, noop, tap, finalize} from "rxjs";
|
||||
import {useSnackbar} from "notistack";
|
||||
import {first} from "rxjs/operators";
|
||||
import ServerConfig from "../../model/server-config";
|
||||
|
||||
function filterPlaylist(playlist: PlaylistGroup[], filter: { [key: string]: boolean }): PlaylistGroup[] {
|
||||
if (playlist) {
|
||||
@@ -58,6 +59,7 @@ export interface IPlaylistViewer {
|
||||
}
|
||||
|
||||
interface PlaylistViewerProps {
|
||||
serverConfig: ServerConfig;
|
||||
playlist: PlaylistGroup[];
|
||||
searchChannel: Observable<string>;
|
||||
onProgress: (value: boolean) => void;
|
||||
@@ -66,7 +68,7 @@ interface PlaylistViewerProps {
|
||||
}
|
||||
|
||||
const PlaylistViewer = forwardRef<IPlaylistViewer, PlaylistViewerProps>((props: PlaylistViewerProps, ref: any) => {
|
||||
const {playlist, searchChannel, onProgress, onCopy, onPlay} = props;
|
||||
const {serverConfig, playlist, searchChannel, onProgress, onCopy, onPlay} = props;
|
||||
const {enqueueSnackbar/*, closeSnackbar*/} = useSnackbar();
|
||||
const [data, setData] = useState<PlaylistGroup[]>([]);
|
||||
const checked = useMemo((): PlaylistTreeState => ({}), []);
|
||||
@@ -109,7 +111,7 @@ const PlaylistViewer = forwardRef<IPlaylistViewer, PlaylistViewerProps>((props:
|
||||
}, [searchChannel, playlist, enqueueSnackbar, onProgress]);
|
||||
|
||||
return <div className={'playlist-viewer'}>
|
||||
<PlaylistTree data={data} state={checked} onCopy={onCopy} onPlay={onPlay}/>
|
||||
<PlaylistTree data={data} state={checked} onCopy={onCopy} onPlay={onPlay} serverConfig={serverConfig}/>
|
||||
</div>
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
.popup-menu {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
color: var(--popup-color);
|
||||
background-color: var(--popup-background-color);
|
||||
border: 1px solid var(--popup-border-color);
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
filter: drop-shadow(8px 9px 7px var(--popup-shadow-color));
|
||||
|
||||
ul {
|
||||
list-style-type: none; /* Remove bullets */
|
||||
padding: 0; /* Remove padding */
|
||||
margin: 0; /* Remove margins */
|
||||
li {
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
color: var(--popup-hover-color);
|
||||
background-color: var(--popup-hover-background-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import React, {ReactChildren, ReactNode, useCallback, useEffect, useRef, useState} from "react";
|
||||
|
||||
import './popup-menu.scss';
|
||||
import ClickAwayListener from "../../utils/click-away-listener";
|
||||
|
||||
interface PopupMenuProps {
|
||||
position: {x: number, y: number},
|
||||
children: ReactNode,
|
||||
onHide: () => void;
|
||||
}
|
||||
|
||||
export default function PopupMenu(props: PopupMenuProps) {
|
||||
|
||||
const popupRef = useRef();
|
||||
const [position, setPosition] = useState<any>({top:0, left: -2000})
|
||||
|
||||
const handleClickAway = useCallback(() => {
|
||||
props.onHide?.();
|
||||
}, [props.onHide]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.position && popupRef.current) {
|
||||
const ww = window.innerWidth;
|
||||
const {offsetWidth} = popupRef.current;
|
||||
const style: any =
|
||||
(props.position.x + offsetWidth > ww)
|
||||
? {right:12, top: props.position.y}
|
||||
: {left:props.position.x, top: props.position.y};
|
||||
setPosition(style);
|
||||
}
|
||||
}, [props.position])
|
||||
|
||||
return <>{props.position && <ClickAwayListener onClickAway={handleClickAway}>
|
||||
<div className="popup-menu" ref={popupRef} style={position}>
|
||||
{props.children}
|
||||
</div>
|
||||
</ClickAwayListener>}</>
|
||||
}
|
||||
@@ -3,32 +3,28 @@ $toggle-size: 24px;
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
flex-flow: column;
|
||||
justify-content: center;
|
||||
flex-shrink: 1;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
|
||||
.sidebar-content {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
justify-content: center;
|
||||
flex-grow: 1;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
.sidebar-collapsed {
|
||||
max-width: 0;
|
||||
width: 0;
|
||||
max-height: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: $toggle-size;
|
||||
min-width: $toggle-size;
|
||||
width: $toggle-size;
|
||||
justify-content: center;
|
||||
color: var(--sidebar-toggle-color);
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, {useCallback, useState} from "react";
|
||||
import './sidebar.scss';
|
||||
import {ArrowLeft, ArrowRight} from "@mui/icons-material";
|
||||
import {getIconByName} from "../../icons/icons";
|
||||
/* eslint-disable @typescript-eslint/no-empty-interface */
|
||||
interface SidebarProps {
|
||||
children?: any;
|
||||
@@ -20,7 +20,7 @@ export default function Sidebar(props: SidebarProps): JSX.Element {
|
||||
{children}
|
||||
</div>
|
||||
<div className={'sidebar-toggle'} onClick={handleToggle}>
|
||||
{collapsed ? <ArrowRight/> : <ArrowLeft/>}
|
||||
{getIconByName(collapsed ? 'ArrowDown' : 'ArrowUp')}
|
||||
</div>
|
||||
</div>;
|
||||
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,123 +1,78 @@
|
||||
import React, {useRef, useState, useEffect, useCallback, useMemo, KeyboardEvent} from "react";
|
||||
import React, {KeyboardEvent, useCallback, useEffect, useRef, useState} from "react";
|
||||
|
||||
import './source-selector.scss';
|
||||
import {IconButton, InputAdornment, Menu, MenuItem, TextField} from "@mui/material";
|
||||
import {CloudDownload, ArrowDropDown} from "@mui/icons-material";
|
||||
import Source from "../../model/source";
|
||||
import {useServices} from "../../provider/service-provider";
|
||||
import {first} from "rxjs/operators";
|
||||
import {useSnackbar} from "notistack";
|
||||
import {noop} from "rxjs";
|
||||
import {getIconByName} from "../../icons/icons";
|
||||
import ServerConfig, {InputConfig} from "../../model/server-config";
|
||||
import PopupMenu from "../popup-menu/popup-menu";
|
||||
import {PlaylistRequest} from "../../model/playlist-request";
|
||||
|
||||
interface SourceSelectorProps {
|
||||
onDownload: (url: string) => void;
|
||||
serverConfig: ServerConfig;
|
||||
onDownload: (req: PlaylistRequest) => void;
|
||||
}
|
||||
|
||||
export default function SourceSelector(props: SourceSelectorProps) {
|
||||
const textField = useRef<HTMLInputElement>();
|
||||
const services = useServices();
|
||||
const {enqueueSnackbar/*, closeSnackbar*/} = useSnackbar();
|
||||
const [openSourceDropDown, setOpenSourceDropDown] = useState(false);
|
||||
const [sources, setSources] = useState<Source[]>([]);
|
||||
const [popupVisible, setPopupVisible] = useState<{ x: number, y: number }>(undefined);
|
||||
const [sources, setSources] = useState<InputConfig[]>([]);
|
||||
const [selected, setSelected] = useState<InputConfig>(undefined);
|
||||
|
||||
const {onDownload} = props;
|
||||
|
||||
const addNewSource = useCallback((url: string) => {
|
||||
if (sources.filter(s => s.url === url).length === 0) {
|
||||
const src: Source = {
|
||||
url,
|
||||
ts: Date.now()
|
||||
};
|
||||
setSources((s) => [].concat(s, [src]))
|
||||
}
|
||||
}, [sources]);
|
||||
const {serverConfig, onDownload} = props;
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
const value = textField.current.value;
|
||||
if (value && value.trim().length > 0) {
|
||||
addNewSource(value);
|
||||
onDownload(value);
|
||||
if (value.trim() == selected?.name) {
|
||||
onDownload({input_id: selected.id});
|
||||
} else {
|
||||
onDownload({url: value.trim()});
|
||||
}
|
||||
}
|
||||
}, [addNewSource, onDownload]);
|
||||
}, [onDownload, selected]);
|
||||
|
||||
const handleKeyPress = useCallback((event: KeyboardEvent<any>) => {
|
||||
const handleKeyPress = useCallback((event: KeyboardEvent<any>) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleDownload();
|
||||
}
|
||||
}, [handleDownload]);
|
||||
|
||||
const selectSource = useCallback((url: string) => {
|
||||
textField.current.value = url;
|
||||
const closePopup = useCallback(() => {
|
||||
setPopupVisible(undefined);
|
||||
}, []);
|
||||
|
||||
const closeSourceDropDown = useCallback(() => {
|
||||
setOpenSourceDropDown(false);
|
||||
const openPopup = useCallback((evt: any) => {
|
||||
const rect = evt.target.getBoundingClientRect();
|
||||
const top = rect.y + rect.height;
|
||||
setPopupVisible({x: evt.clientX, y: top});
|
||||
}, []);
|
||||
|
||||
const openSourcesDropDown = useCallback(() => {
|
||||
setOpenSourceDropDown(true);
|
||||
}, []);
|
||||
|
||||
const selectSourceDropDown = useCallback((e: any) => {
|
||||
setOpenSourceDropDown(false);
|
||||
const url = e.target.dataset.url;
|
||||
selectSource(url);
|
||||
}, [selectSource]);
|
||||
|
||||
useEffect(() => {
|
||||
services.config().getServerConfig().pipe(first()).subscribe({
|
||||
next: (cfg) => {
|
||||
setSources(cfg.sources?.map(s => ({url: s, ts: Date.now()} as Source)))
|
||||
},
|
||||
error: (err) => {
|
||||
enqueueSnackbar('Failed to download server config!', {variant: 'error'});
|
||||
},
|
||||
complete: noop,
|
||||
});
|
||||
return noop
|
||||
}, [enqueueSnackbar, services]);
|
||||
|
||||
const inputLabelProps = useMemo(() => ({
|
||||
shrink: true,
|
||||
}), []);
|
||||
|
||||
const inputProps = useMemo(() => (
|
||||
{
|
||||
endAdornment: <InputAdornment position="end">
|
||||
<IconButton
|
||||
className={"icon-button"}
|
||||
aria-label="download"
|
||||
onClick={handleDownload}
|
||||
edge="end">
|
||||
<CloudDownload/>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
className={"icon-button"}
|
||||
aria-label="select"
|
||||
onClick={openSourcesDropDown}
|
||||
edge="end">
|
||||
<ArrowDropDown/>
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
const handleMenuClick = useCallback((evt: any) => {
|
||||
const idx = evt.target.dataset.idx;
|
||||
if (idx != null) {
|
||||
setPopupVisible(undefined);
|
||||
setSelected(sources[idx]);
|
||||
textField.current.value = sources[idx].name || sources[idx].url;
|
||||
}
|
||||
), [handleDownload, openSourcesDropDown]);
|
||||
}, [sources]);
|
||||
|
||||
useEffect(()=> {
|
||||
if (serverConfig) {
|
||||
setSources(serverConfig.sources?.flatMap(source => source.inputs))
|
||||
}
|
||||
}, [serverConfig]);
|
||||
|
||||
return <div className={'source-selector'}>
|
||||
<TextField className={'text-input'} inputRef={textField} label="Url" variant="outlined"
|
||||
InputLabelProps={inputLabelProps}
|
||||
InputProps={inputProps}
|
||||
onKeyUp={handleKeyPress}
|
||||
/>
|
||||
<Menu
|
||||
id="source-menu"
|
||||
anchorEl={textField.current}
|
||||
keepMounted
|
||||
open={openSourceDropDown}
|
||||
onClose={closeSourceDropDown}
|
||||
>
|
||||
{sources?.map(s =>
|
||||
<MenuItem key={s.ts} data-url={s.url} onClick={selectSourceDropDown}>{s.url}</MenuItem>)
|
||||
}
|
||||
</Menu>
|
||||
<div className={'input-field'}>
|
||||
<label>Source</label>
|
||||
<input onKeyUp={handleKeyPress} ref={textField}/>
|
||||
<button onClick={handleDownload}>{getIconByName('CloudDownload')}</button>
|
||||
<button onClick={openPopup}>{getIconByName('ArrowDown')}</button>
|
||||
</div>
|
||||
<PopupMenu position={popupVisible} onHide={closePopup}>
|
||||
<ul>
|
||||
{sources.map((s, idx) =>
|
||||
<li key={s.id + '_' + idx} data-idx={idx} onClick={handleMenuClick}>{s.name || s.url}</li>)}
|
||||
</ul>
|
||||
</PopupMenu>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
.svg-icon {
|
||||
fill: currentColor;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
display: inline-block;
|
||||
font-size: 1.5rem;
|
||||
transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import './svg-icon.scss';
|
||||
import * as React from "react";
|
||||
|
||||
interface SvgIconProps {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
function SvgIcon(props: SvgIconProps): React.JSX.Element {
|
||||
return <svg className="svg-icon" focusable="false" aria-hidden="true" data-testid={props.name} height="100" width="100" viewBox='0 0 24 24'>
|
||||
<path d={props.path}/>
|
||||
</svg>;
|
||||
}
|
||||
|
||||
SvgIcon.displayName = 'SvgIcon';
|
||||
export default SvgIcon;
|
||||
@@ -3,4 +3,13 @@
|
||||
flex-grow: 0;
|
||||
justify-content: flex-end;
|
||||
padding: 6px;
|
||||
|
||||
button {
|
||||
background-color: #0081fa;
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 1.2rem;
|
||||
border-radius: 4px;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from "react";
|
||||
import './toolbar.scss';
|
||||
import {Button} from "@mui/material";
|
||||
|
||||
interface ToolbarProps {
|
||||
onDownload: () => void;
|
||||
@@ -8,8 +7,8 @@ interface ToolbarProps {
|
||||
|
||||
export default function Toolbar(props: ToolbarProps) {
|
||||
return <div className={'toolbar'}>
|
||||
<Button variant="contained" color="primary" onClick={props.onDownload}>
|
||||
<button onClick={props.onDownload}>
|
||||
Save
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import createIcon from "../utils/icon-utils";
|
||||
import * as React from "react";
|
||||
|
||||
const ICON: Record<string, React.JSX.Element> = {};
|
||||
|
||||
export const getIconByName = (name: string): any => {
|
||||
if (name) {
|
||||
// @ts-ignore
|
||||
return ICON[name];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const DEFAULT_ICONS = {
|
||||
ContentCopy: 'M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z',
|
||||
DeleteSweep: 'M15 16h4v2h-4zm0-8h7v2h-7zm0 4h6v2h-6zM3 18c0 1.1.9 2 2 2h6c1.1 0 2-.9 2-2V8H3v10zM14 5h-3l-1-1H6L5 5H2v2h12z',
|
||||
Search: 'M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z',
|
||||
ExpandMore: 'M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z',
|
||||
ExpandLess: 'm12 8-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z',
|
||||
ChevronRight: 'M10 6 8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z',
|
||||
LinkRounded: 'M17 7h-3c-.55 0-1 .45-1 1s.45 1 1 1h3c1.65 0 3 1.35 3 3s-1.35 3-3 3h-3c-.55 0-1 .45-1 1s.45 1 1 1h3c2.76 0 5-2.24 5-5s-2.24-5-5-5zm-9 5c0 .55.45 1 1 1h6c.55 0 1-.45 1-1s-.45-1-1-1H9c-.55 0-1 .45-1 1zm2 3H7c-1.65 0-3-1.35-3-3s1.35-3 3-3h3c.55 0 1-.45 1-1s-.45-1-1-1H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h3c.55 0 1-.45 1-1s-.45-1-1-1z',
|
||||
PlayArrow: 'M8 5v14l11-7z',
|
||||
ArrowLeft: 'm14 7-5 5 5 5V7z',
|
||||
ArrowRight: 'm10 17 5-5-5-5v10z',
|
||||
ArrowDown: 'm7 10 5 5 5-5z',
|
||||
ArrowUp: 'm7 14 5-5 5 5z',
|
||||
CloudDownload: 'M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM17 13l-5 5-5-5h3V9h4v4h3z',
|
||||
'Download': 'M5 20h14v-2H5v2zM19 9h-4V3H9v6H5l7 7 7-7z'
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
Object.entries(DEFAULT_ICONS).forEach(icon_def => {
|
||||
ICON[icon_def[0]] = createIcon(icon_def[0], icon_def[1]);
|
||||
});
|
||||
|
||||
|
||||
export default ICON;
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface FileDownloadRequest {
|
||||
url: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export interface FileDownloadResponse {
|
||||
download_id: string;
|
||||
}
|
||||
|
||||
export interface FileDownloadInfo {
|
||||
download_id: string;
|
||||
filename?: string;
|
||||
finished?: boolean;
|
||||
filesize?: number;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export interface PlaylistRequest {
|
||||
url: string;
|
||||
url?: string;
|
||||
input_id?: number;
|
||||
}
|
||||
@@ -10,12 +10,12 @@ export interface PlaylistItemHeader {
|
||||
time_shift: string;
|
||||
rec: string;
|
||||
source: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PlaylistItem {
|
||||
id: number;
|
||||
header: PlaylistItemHeader;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PlaylistGroup {
|
||||
|
||||
@@ -1,3 +1,93 @@
|
||||
export enum InputType {
|
||||
m3u = "m3u",
|
||||
xtream = "xtream"
|
||||
}
|
||||
|
||||
export enum SortOrder {
|
||||
asc = "asc",
|
||||
desc = "desc"
|
||||
}
|
||||
|
||||
export enum TargetType {
|
||||
m3u = "m3u",
|
||||
xtream = "xtream",
|
||||
strm = "strm"
|
||||
}
|
||||
|
||||
export enum ProcessingOrder {
|
||||
frm = "frm",
|
||||
fmr = "fmr",
|
||||
rfm = "rfm",
|
||||
rmf = "rmf",
|
||||
mfr = "mfr",
|
||||
mrf = "mrf",
|
||||
}
|
||||
|
||||
export interface InputConfig {
|
||||
id: number,
|
||||
input_type: InputType,
|
||||
url: string,
|
||||
username: string,
|
||||
password: string,
|
||||
persist: string,
|
||||
name: string,
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface TargetConfig {
|
||||
enabled: boolean,
|
||||
name: string,
|
||||
options: {
|
||||
ignore_logo: boolean,
|
||||
underscore_whitespace: boolean,
|
||||
cleanup: boolean,
|
||||
kodi_style: boolean
|
||||
},
|
||||
sort: {
|
||||
match_as_ascii: boolean,
|
||||
groups: {
|
||||
order: SortOrder
|
||||
},
|
||||
channels:
|
||||
{
|
||||
field: string,
|
||||
group_pattern: string,
|
||||
order: SortOrder
|
||||
}[]
|
||||
},
|
||||
filter: string,
|
||||
output: [
|
||||
{
|
||||
target: TargetType,
|
||||
filename: string
|
||||
}
|
||||
],
|
||||
rename: [
|
||||
{
|
||||
field: string,
|
||||
pattern: string,
|
||||
new_name: string
|
||||
}
|
||||
],
|
||||
mapping: string[],
|
||||
processing_order: ProcessingOrder,
|
||||
watch: string[]
|
||||
}
|
||||
|
||||
export interface SourceConfig {
|
||||
inputs: InputConfig[];
|
||||
targets: TargetConfig[];
|
||||
}
|
||||
|
||||
export interface VideoConfig {
|
||||
extensions: string[];
|
||||
download: {
|
||||
headers: Record<string, string>,
|
||||
directory: string;
|
||||
}
|
||||
}
|
||||
|
||||
export default interface ServerConfig {
|
||||
sources: string[];
|
||||
sources: SourceConfig[];
|
||||
video: VideoConfig;
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export default interface Source {
|
||||
url: string;
|
||||
ts: number;
|
||||
}
|
||||
@@ -1,26 +1,79 @@
|
||||
.text-input {
|
||||
flex-grow: 1;
|
||||
.input-field {
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-flow: row nowrap;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
flex: 1 1 auto;
|
||||
padding: 2px;
|
||||
|
||||
.MuiFormLabel-root {
|
||||
color: var(--playlist-color);
|
||||
font-size: 1.2em;
|
||||
&:focus-within {
|
||||
border: 1px solid var(--hover-border-color);
|
||||
}
|
||||
|
||||
.MuiOutlinedInput-notchedOutline {
|
||||
border-color: var(--border-color);
|
||||
label {
|
||||
position: absolute;
|
||||
color: var(--label-color);
|
||||
transform: translate(6px, -0.8rem);
|
||||
background-color: var(--background-color);
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.MuiInputBase-input {
|
||||
input {
|
||||
color: var(--text-color);
|
||||
background-color: transparent;
|
||||
font-size: 1.2rem;
|
||||
padding: 0.5rem;
|
||||
border: none;
|
||||
flex: 1 1 auto;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
color: var(--playlist-color);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
aspect-ratio: 1 / 1;
|
||||
|
||||
svg {
|
||||
color: var(--playlist-color);
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
background-color: var(--tree-hover-background-color);
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
color: var(--playlist-color);
|
||||
svg {
|
||||
color: var(--playlist-color);
|
||||
.app {
|
||||
/* ===== Scrollbar CSS ===== */
|
||||
/* Firefox */
|
||||
* {
|
||||
scrollbar-width: auto;
|
||||
scrollbar-color: #0081fa #ffffff;
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--tree-hover-background-color) !important;
|
||||
|
||||
/* Chrome, Edge, and Safari */
|
||||
*::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: var(--background-color);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: #0081fa;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
:root {
|
||||
--text-color: #ffffff;
|
||||
--label-color: #aaaaaa;
|
||||
--background-color: #2b2b2b;
|
||||
--app-header-color: #6495b1;
|
||||
--sidebar-toggle-color: #ffffff;
|
||||
--sidebar-toggle-hover-color: #004db4;
|
||||
--playlist-color: #aeb0b2;
|
||||
--border-color: #aeb0b2;
|
||||
--hover-border-color: #ffffff;
|
||||
--spinner-color: #ffffff66;
|
||||
--spinner-highlight-color: #5599f0;
|
||||
--tree-count-color: #7b7b7e;
|
||||
@@ -20,4 +22,12 @@
|
||||
--toolbar-background-color: #434343;
|
||||
--toolbar-button-hover-color: #ffffff;
|
||||
--toolbar-button-hover-background-color: #004db4;
|
||||
|
||||
--popup-background-color: #272727;
|
||||
--popup-shadow-color: #646464;
|
||||
--popup-border-color: #646464;
|
||||
--popup-color: #fff;
|
||||
--popup-hover-background-color: #5599f0;
|
||||
--popup-hover-color: #fff;
|
||||
|
||||
}
|
||||
@@ -1,16 +1,31 @@
|
||||
import {PlaylistItem, PlaylistGroup} from "../model/playlist";
|
||||
import FileSaver from "file-saver";
|
||||
|
||||
import {Observable} from "rxjs";
|
||||
import FileApiService, {DefaultFileApiService} from "../api/file-api-service";
|
||||
import {FileDownloadInfo, FileDownloadRequest, FileDownloadResponse} from "../model/file-download";
|
||||
export default class FileService {
|
||||
|
||||
constructor(private fileApiService: FileApiService = new DefaultFileApiService()) {
|
||||
}
|
||||
|
||||
save(playlist: PlaylistGroup[]) {
|
||||
const lines = ['#EXTM3U'];
|
||||
playlist.forEach(group => {
|
||||
group.channels.forEach((entry: PlaylistItem) => {
|
||||
lines.push(entry.header.source);
|
||||
lines.push(entry.url);
|
||||
})
|
||||
group.channels.forEach((entry: PlaylistItem) => {
|
||||
lines.push(entry.header.source);
|
||||
lines.push(entry.header.url);
|
||||
})
|
||||
});
|
||||
const blob = new Blob([lines.join('\n')], { type: "text/plain;charset=utf-8" });
|
||||
FileSaver.saveAs(blob, "playlist.m3u");
|
||||
}
|
||||
}
|
||||
|
||||
download(req: FileDownloadRequest): Observable<FileDownloadResponse> {
|
||||
return this.fileApiService.download(req);
|
||||
}
|
||||
|
||||
getDownloadInfo(download_id: string): Observable<FileDownloadInfo> {
|
||||
return this.fileApiService.getDownloadInfo(download_id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,15 +2,17 @@ import {Observable} from "rxjs";
|
||||
import PlaylistApiService, {DefaultPlaylistApiService} from "../api/playlist-api-service";
|
||||
import {PlaylistGroup} from "../model/playlist";
|
||||
import {first} from "rxjs/operators";
|
||||
import {InputType} from "../model/server-config";
|
||||
import {PlaylistRequest} from "../model/playlist-request";
|
||||
|
||||
export default class PlaylistService {
|
||||
|
||||
constructor(private playlistApiService: PlaylistApiService = new DefaultPlaylistApiService()) {
|
||||
}
|
||||
|
||||
getPlaylist(url: string): Observable<PlaylistGroup[]> {
|
||||
getPlaylist(req: PlaylistRequest): Observable<PlaylistGroup[]> {
|
||||
return new Observable((obs) =>
|
||||
this.playlistApiService.getPlaylist(url).pipe(first()).subscribe({
|
||||
this.playlistApiService.getPlaylist(req).pipe(first()).subscribe({
|
||||
next: (pl) => {
|
||||
if (pl) {
|
||||
let cnt = 0;
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
useRef,
|
||||
useEffect,
|
||||
RefCallback,
|
||||
cloneElement,
|
||||
ReactElement,
|
||||
HTMLAttributes,
|
||||
MutableRefObject,
|
||||
FunctionComponent
|
||||
} from 'react';
|
||||
|
||||
type MouseEvents = 'click' | 'mousedown' | 'mouseup';
|
||||
type TouchEvents = 'touchstart' | 'touchend';
|
||||
type Events = MouseEvent | TouchEvent | KeyboardEvent;
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLElement> {
|
||||
onClickAway: (event: Events) => void;
|
||||
mouseEvent?: MouseEvents;
|
||||
touchEvent?: TouchEvents;
|
||||
children: ReactElement<any>;
|
||||
}
|
||||
|
||||
const eventTypeMapping = {
|
||||
click: 'onClick',
|
||||
mousedown: 'onMouseDown',
|
||||
mouseup: 'onMouseUp',
|
||||
touchstart: 'onTouchStart',
|
||||
touchend: 'onTouchEnd'
|
||||
};
|
||||
|
||||
const ClickAwayListener: FunctionComponent<Props> = ({
|
||||
children,
|
||||
onClickAway,
|
||||
mouseEvent = 'click',
|
||||
touchEvent = 'touchend'
|
||||
}) => {
|
||||
const node = useRef<HTMLElement | null>(null);
|
||||
const bubbledEventTarget = useRef<EventTarget | null>(null);
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
/**
|
||||
* Prevents the bubbled event from getting triggered immediately
|
||||
* https://github.com/facebook/react/issues/20074
|
||||
*/
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
mountedRef.current = true;
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleBubbledEvents =
|
||||
(type: string) =>
|
||||
(event: Events): void => {
|
||||
bubbledEventTarget.current = event.target;
|
||||
|
||||
const handler = children?.props[type];
|
||||
|
||||
if (handler) {
|
||||
handler(event);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChildRef = (childRef: HTMLElement) => {
|
||||
node.current = childRef;
|
||||
|
||||
let {ref} = children as typeof children & {
|
||||
ref: RefCallback<HTMLElement> | MutableRefObject<HTMLElement>;
|
||||
};
|
||||
|
||||
if (typeof ref === 'function') {
|
||||
ref(childRef);
|
||||
} else if (ref) {
|
||||
ref.current = childRef;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleEvents = (event: Events): void => {
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
if (
|
||||
(node.current && node.current.contains(event.target as Node)) ||
|
||||
bubbledEventTarget.current === event.target ||
|
||||
!document.contains(event.target as Node)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClickAway(event);
|
||||
};
|
||||
|
||||
const handleKeayboardEvent = (evt: KeyboardEvent) => {
|
||||
if (!mountedRef.current) return;
|
||||
if (evt.key === 'Escape') {
|
||||
onClickAway(evt);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener(mouseEvent, handleEvents);
|
||||
document.addEventListener(touchEvent, handleEvents);
|
||||
document.addEventListener('keydown', handleKeayboardEvent, {passive: true});
|
||||
|
||||
return () => {
|
||||
document.removeEventListener(mouseEvent, handleEvents);
|
||||
document.removeEventListener(touchEvent, handleEvents);
|
||||
document.removeEventListener('keydown', handleKeayboardEvent);
|
||||
};
|
||||
}, [mouseEvent, onClickAway, touchEvent]);
|
||||
|
||||
const mappedMouseEvent = eventTypeMapping[mouseEvent];
|
||||
const mappedTouchEvent = eventTypeMapping[touchEvent];
|
||||
|
||||
return React.Children.only(
|
||||
// @ts-ignore
|
||||
cloneElement(children as ReactElement<any>, {
|
||||
ref: handleChildRef,
|
||||
[mappedMouseEvent]: handleBubbledEvents(mappedMouseEvent),
|
||||
[mappedTouchEvent]: handleBubbledEvents(mappedTouchEvent)
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
ClickAwayListener.displayName = 'ClickAwayListener';
|
||||
|
||||
export default ClickAwayListener;
|
||||
@@ -0,0 +1,6 @@
|
||||
import * as React from "react";
|
||||
import SvgIcon from "../component/svg-icon/svg-icon";
|
||||
|
||||
export default function createIcon(name: string, path: string): React.JSX.Element {
|
||||
return <SvgIcon name={name} path={path}/>;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use actix_web::web;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::model::config::{Config, ConfigOptions, ConfigRename, ConfigSort, InputType, ProcessTargets, TargetOutput, VideoConfig};
|
||||
@@ -7,6 +8,7 @@ use crate::model::model_config::{default_as_empty_str, ProcessingOrder};
|
||||
pub(crate) struct AppState {
|
||||
pub config: Arc<Config>,
|
||||
pub targets: Arc<ProcessTargets>,
|
||||
pub downloads: Arc<Mutex<HashMap<String, u64>>>
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
||||
+3
-1
@@ -1,7 +1,8 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use actix_cors::Cors;
|
||||
@@ -44,6 +45,7 @@ pub(crate) async fn start_server(cfg: Arc<Config>, targets: Arc<ProcessTargets>)
|
||||
let shared_data = web::Data::new(AppState {
|
||||
config: cfg,
|
||||
targets,
|
||||
downloads: Arc::new(Mutex::new(HashMap::new()))
|
||||
});
|
||||
|
||||
// Scheduler
|
||||
|
||||
+76
-33
@@ -1,16 +1,21 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
use std::{fs, io};
|
||||
use std::io::{ErrorKind, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use actix_web::{HttpResponse, Scope, web};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
use crate::api::api_model::{AppState, FileDownloadRequest, PlaylistRequest, ServerConfig, ServerInputConfig, ServerSourceConfig, ServerTargetConfig};
|
||||
use crate::download::{get_m3u_playlist, get_xtream_playlist};
|
||||
use crate::model::config::{ConfigInput, InputType};
|
||||
use crate::utils::{bytes_to_megabytes};
|
||||
use futures::stream::TryStreamExt;
|
||||
use log::error;
|
||||
use log::{error, info};
|
||||
use reqwest::{header, Response};
|
||||
use reqwest::header::{HeaderName, HeaderValue};
|
||||
use unidecode::unidecode;
|
||||
|
||||
const DOWNLOAD_HEADERS: &[(&str, &str)] = &[
|
||||
("Accept", "video/*"),
|
||||
@@ -97,12 +102,13 @@ pub(crate) async fn config(
|
||||
HttpResponse::Ok().json(result)
|
||||
}
|
||||
|
||||
async fn async_download_file(filename: &str, response: Response) -> Result<(), String> {
|
||||
let path = PathBuf::from(filename);
|
||||
async fn async_download_file(download_id: &String, path: &PathBuf, response: Response, downloads: Arc<Mutex<HashMap<String, u64>>>) -> Result<u64, String> {
|
||||
match File::create(path) {
|
||||
Ok(mut file) => {
|
||||
info!("Downloading {}", path.to_str().unwrap_or("?"));
|
||||
let mut stream = response.bytes_stream().map_err(|err| io::Error::new(ErrorKind::Other, err));
|
||||
let mut downloaded: u64 = 0;
|
||||
downloads.lock().unwrap().insert(download_id.to_owned(), downloaded);
|
||||
loop {
|
||||
match stream.try_next().await {
|
||||
Ok(item) => {
|
||||
@@ -112,11 +118,16 @@ async fn async_download_file(filename: &str, response: Response) -> Result<(), S
|
||||
Ok(_) => {
|
||||
let new = downloaded + (chunk.len() as u64);
|
||||
downloaded = new;
|
||||
downloads.lock().unwrap().insert(download_id.to_owned(), downloaded);
|
||||
}
|
||||
Err(err) => return Err(format!("Error while writing to file: {}", err))
|
||||
}
|
||||
}
|
||||
None => return Ok(()),
|
||||
None => {
|
||||
let megabytes = bytes_to_megabytes(downloaded);
|
||||
info!("Downloaded {}, filesize: {}MB", path.to_str().unwrap_or("?"), megabytes);
|
||||
return Ok(downloaded)
|
||||
},
|
||||
}
|
||||
}
|
||||
Err(err) => return Err(format!("Error while writing to file: {}", err)),
|
||||
@@ -127,39 +138,70 @@ async fn async_download_file(filename: &str, response: Response) -> Result<(), S
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn download_file_info(
|
||||
info: web::Path<String>,
|
||||
_app_state: web::Data<AppState>,
|
||||
) -> HttpResponse {
|
||||
let did: String = info.into_inner();
|
||||
match _app_state.downloads.lock().unwrap().get(&did) {
|
||||
// @TODO it is only a success when the file remains.
|
||||
None => HttpResponse::Ok().json(json!({"download_id": &did, "finished": true})),
|
||||
Some(downloaded) => HttpResponse::Ok().json(json!({"download_id": &did, "filesize": downloaded}))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn download_file(
|
||||
req: web::Json<FileDownloadRequest>,
|
||||
_app_state: web::Data<AppState>,
|
||||
) -> HttpResponse {
|
||||
match reqwest::Url::parse(&req.url) {
|
||||
Ok(_url) => {
|
||||
let client = reqwest::Client::new();
|
||||
let mut headers = header::HeaderMap::new();
|
||||
for (key, value) in DOWNLOAD_HEADERS {
|
||||
headers.insert(
|
||||
HeaderName::from_bytes(key.as_bytes()).unwrap(),
|
||||
HeaderValue::from_bytes(value.as_bytes()).unwrap(),
|
||||
);
|
||||
}
|
||||
match client.get(_url).headers(headers).send().await {
|
||||
Ok(response) => {
|
||||
actix_rt::spawn(async move {
|
||||
match async_download_file(&req.filename, response).await {
|
||||
Ok(_) => {}
|
||||
Err(err) => error!("{}", err)
|
||||
}
|
||||
});
|
||||
HttpResponse::Ok().json(json!({"success": true}))
|
||||
}
|
||||
Err(err) => HttpResponse::InternalServerError().json(json!({"error": format!("{}", err)})),
|
||||
}
|
||||
|
||||
//
|
||||
// use rocket::futures::TryStreamExt; // for map_err() call below:
|
||||
// let reader = StreamReader::new(response.bytes_stream().map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)));
|
||||
// rocket::response::Stream::chunked(reader, 4096)
|
||||
if let Some(download) = &_app_state.config.video.as_ref().unwrap().download {
|
||||
if download.directory.is_none() {
|
||||
return HttpResponse::BadRequest().json(json!({"error": "Server config missing video.download.directory configuration"}));
|
||||
}
|
||||
Err(_) => HttpResponse::BadRequest().json(json!({"error": "Invalid Arguments"})),
|
||||
|
||||
match reqwest::Url::parse(&req.url) {
|
||||
Ok(_url) => {
|
||||
let client = reqwest::Client::new();
|
||||
let mut headers = header::HeaderMap::new();
|
||||
for (key, value) in DOWNLOAD_HEADERS {
|
||||
headers.insert(
|
||||
HeaderName::from_bytes(key.as_bytes()).unwrap(),
|
||||
HeaderValue::from_bytes(value.as_bytes()).unwrap(),
|
||||
);
|
||||
}
|
||||
match client.get(_url).headers(headers).send().await {
|
||||
Ok(response) => {
|
||||
let filename = unidecode(&req.filename).replace(' ', "_");
|
||||
let path: PathBuf = [(download.directory.clone().unwrap().as_str()), filename.as_str()].iter().collect();
|
||||
let download_id = Uuid::new_v4().to_string();
|
||||
let response_download_id = download_id.clone();
|
||||
actix_rt::spawn(async move {
|
||||
let downloads = _app_state.downloads.clone();
|
||||
match async_download_file(&download_id, &path, response, downloads.clone()).await {
|
||||
Ok(_) => {
|
||||
downloads.lock().unwrap().remove(&download_id);
|
||||
}
|
||||
Err(err) => {
|
||||
downloads.lock().unwrap().remove(&download_id);
|
||||
let _ = fs::remove_file(&path);
|
||||
error!("{}", err);
|
||||
}
|
||||
}
|
||||
});
|
||||
HttpResponse::Ok().json(json!({"download_id": response_download_id}))
|
||||
}
|
||||
Err(err) => HttpResponse::InternalServerError().json(json!({"error": format!("{}", err)})),
|
||||
}
|
||||
|
||||
//
|
||||
// use rocket::futures::TryStreamExt; // for map_err() call below:
|
||||
// let reader = StreamReader::new(response.bytes_stream().map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)));
|
||||
// rocket::response::Stream::chunked(reader, 4096)
|
||||
}
|
||||
Err(_) => HttpResponse::BadRequest().json(json!({"error": "Invalid Arguments"})),
|
||||
}
|
||||
} else {
|
||||
HttpResponse::BadRequest().json(json!({"error": "Server config missing video.download configuration"}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,4 +210,5 @@ pub(crate) fn v1_api_register() -> Scope {
|
||||
.route("/config", web::get().to(config))
|
||||
.route("/playlist", web::post().to(playlist))
|
||||
.route("/file/download", web::post().to(download_file))
|
||||
.route("/file/download/{did}", web::get().to(download_file_info))
|
||||
}
|
||||
|
||||
+5
-1
@@ -251,4 +251,8 @@ pub(crate) fn get_input_json_content(input: &ConfigInput, url_str: &String, pers
|
||||
// pub(crate) fn dedup<T: Eq + Hash + Copy>(v: &mut Vec<T>) {
|
||||
// let mut uniques = HashSet::new();
|
||||
// v.retain(|e| uniques.insert(*e));
|
||||
// }
|
||||
// }
|
||||
|
||||
pub(crate) fn bytes_to_megabytes(bytes: u64) -> u64 {
|
||||
bytes / 1_048_576
|
||||
}
|
||||
Reference in New Issue
Block a user