File download and web-ui refactoring

This commit is contained in:
euzu
2023-10-25 13:27:15 +02:00
parent 224c214050
commit 8ff03eb103
44 changed files with 1107 additions and 375 deletions
+2
View File
@@ -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
View File
@@ -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"
+1
View File
@@ -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"] }
-1
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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": {
+34 -2
View File
@@ -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)));
// });
}
}
+46
View File
@@ -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'));
}
}
+8 -5
View File
@@ -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'));
}
}
+1 -2
View File
@@ -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
View File
@@ -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>}</>
}
+6 -10
View File
@@ -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;
+2 -2
View File
@@ -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;
}
}
+2 -3
View File
@@ -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>
}
+39
View File
@@ -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;
+15
View File
@@ -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;
}
+2 -1
View File
@@ -1,3 +1,4 @@
export interface PlaylistRequest {
url: string;
url?: string;
input_id?: number;
}
+1 -1
View File
@@ -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 {
+91 -1
View File
@@ -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;
}
-4
View File
@@ -1,4 +0,0 @@
export default interface Source {
url: string;
ts: number;
}
+67 -14
View File
@@ -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;
}
}
+10
View File
@@ -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;
}
+21 -6
View File
@@ -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);
}
}
+4 -2
View File
@@ -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;
+130
View File
@@ -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;
+6
View File
@@ -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}/>;
}
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}