diff --git a/CHANGELOG.md b/CHANGELOG.md index 797be1560..1694ef8bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index dca6557f3..622ee0aff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 8b47224ec..882d561af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } \ No newline at end of file diff --git a/README.md b/README.md index 21a2119aa..70991dfee 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/frontend/eslintrc.json b/frontend/eslintrc.json index 5bfb4eb7c..cdc243a37 100644 --- a/frontend/eslintrc.json +++ b/frontend/eslintrc.json @@ -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" }, diff --git a/frontend/package.json b/frontend/package.json index 64c8fea1b..d595b1199 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -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": { diff --git a/frontend/src/api/api-service.ts b/frontend/src/api/api-service.ts index dbeaf6f67..aa78c5e65 100644 --- a/frontend/src/api/api-service.ts +++ b/frontend/src/api/api-service.ts @@ -8,10 +8,15 @@ const HEADER_ACCEPT = 'Accept'; export default interface ApiService { get(query: string, url?: string): Observable; + post(query: string, payload: any, url?: string): Observable; + put(query: string, payload: any, url?: string): Observable; - delete(query: string, url?: string) : Observable; - postFile(query: string, fileName: string, file: any, url?: string) : Observable; + + delete(query: string, url?: string): Observable; + + postFile(query: string, fileName: string, file: any, url?: string): Observable; + downloadFile(query: string, payload: any, url?: string): Observable; } export class DefaultApiService implements ApiService { @@ -114,4 +119,31 @@ export class DefaultApiService implements ApiService { }); } + downloadFile(query: string, payload: any, url?: string): Observable { + return this.post(query, payload, url); + // return new Observable((observer) => { + // + // axios.post(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))); + // }); + } + } diff --git a/frontend/src/api/file-api-service.ts b/frontend/src/api/file-api-service.ts new file mode 100644 index 000000000..c4227a57b --- /dev/null +++ b/frontend/src/api/file-api-service.ts @@ -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; + getDownloadInfo(download_id: string): Observable; +} + +export class DefaultFileApiService extends DefaultApiService implements FileApiService { + download(req: FileDownloadRequest): Observable { + if (req.url) { + return this.post(FILE_DOWNLOAD_API_PATH, req); + } + return throwError(() => new Error('Invalid arguments')); + } + + getDownloadInfo(download_id: string): Observable { + if (download_id) { + return new Observable((observer) => { + const fetch_info = () => { + this.get(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')); + } +} diff --git a/frontend/src/api/playlist-api-service.ts b/frontend/src/api/playlist-api-service.ts index ebc157ddb..4ee49daeb 100644 --- a/frontend/src/api/playlist-api-service.ts +++ b/frontend/src/api/playlist-api-service.ts @@ -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; + getPlaylist(req: PlaylistRequest): Observable; } export class DefaultPlaylistApiService extends DefaultApiService implements PlaylistApiService { - getPlaylist(url: string): Observable { - return this.post(PLAYLIST_API_PATH, {url}); + getPlaylist(req: PlaylistRequest): Observable { + if (req.url || req.input_id != undefined) { + return this.post(PLAYLIST_API_PATH, req); + } + return throwError(() => new Error('Invalid arguments')); } - } diff --git a/frontend/src/app/app.scss b/frontend/src/app/app.scss index 02b6c8318..861caa836 100755 --- a/frontend/src/app/app.scss +++ b/frontend/src/app/app.scss @@ -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; diff --git a/frontend/src/app/app.tsx b/frontend/src/app/app.tsx index 92c19fe8c..31f056a72 100755 --- a/frontend/src/app/app.tsx +++ b/frontend/src/app/app.tsx @@ -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>(() => new Subject(), []); const [progress, setProgress] = useState(false); const [playlist, setPlaylist] = useState([]); - const clipboardChannelRef = useRef>(new Subject()); + const [serverConfig, setServerConfig] = useState(undefined); + const clipboardChannel = useMemo>(() => new Subject(), []); const viewerRef = useRef(); const {enqueueSnackbar/*, closeSnackbar*/} = useSnackbar(); const services = useServices(); const videoChannel = useMemo(() => new Subject(), []); - 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 (
m3u-filter
- +
- + + onProgress={handleProgress} onCopy={handleOnCopy} onPlay={handleOnPlay} + serverConfig={serverConfig}/> diff --git a/frontend/src/component/clipboard-viewer/clipboard-viewer.scss b/frontend/src/component/clipboard-viewer/clipboard-viewer.scss index 1bd0b9de1..e1c13f310 100644 --- a/frontend/src/component/clipboard-viewer/clipboard-viewer.scss +++ b/frontend/src/component/clipboard-viewer/clipboard-viewer.scss @@ -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 */ } + } } \ No newline at end of file diff --git a/frontend/src/component/clipboard-viewer/clipboard-viewer.tsx b/frontend/src/component/clipboard-viewer/clipboard-viewer.tsx index 7e05ef955..eceaaf167 100644 --- a/frontend/src/component/clipboard-viewer/clipboard-viewer.tsx +++ b/frontend/src/component/clipboard-viewer/clipboard-viewer.tsx @@ -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; @@ -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
- - + +
    - {data.map((t, i) =>
  • {t}
  • )} + {data.map((t, i) =>
  • {t}
  • )}
; diff --git a/frontend/src/component/playlist-filter/playlist-filter.tsx b/frontend/src/component/playlist-filter/playlist-filter.tsx index 849d2ad10..8595ed93f 100755 --- a/frontend/src/component/playlist-filter/playlist-filter.tsx +++ b/frontend/src/component/playlist-filter/playlist-filter.tsx @@ -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: ( - - - - - - ) - } - ), [handleSearch]); - return
- +
+ + + +
} \ No newline at end of file diff --git a/frontend/src/component/playlist-tree/playlist-tree.scss b/frontend/src/component/playlist-tree/playlist-tree.scss index 8228d729c..0708aca07 100644 --- a/frontend/src/component/playlist-tree/playlist-tree.scss +++ b/frontend/src/component/playlist-tree/playlist-tree.scss @@ -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); + } + } + } +} \ No newline at end of file diff --git a/frontend/src/component/playlist-tree/playlist-tree.tsx b/frontend/src/component/playlist-tree/playlist-tree.tsx index e8c334be2..8df2342ee 100644 --- a/frontend/src/component/playlist-tree/playlist-tree.tsx +++ b/frontend/src/component/playlist-tree/playlist-tree.tsx @@ -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({}); const {enqueueSnackbar/*, closeSnackbar*/} = useSnackbar(); + const services = useServices(); + const [videoExtensions, setVideoExtensions] = useState([]); + const [downloads, setDownloads] = useState>({}) + + 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
- + {getIconByName('LinkRounded')}
- + {getIconByName('PlayArrow')}
+ {isVideoFile(entry) && +
+ {getIconByName('Download')} +
+ }
{index + 1}
{entry.header.name}
- }, [handleClipboardUrl, handlePlayUrl]); + }, [handleClipboardUrl, handlePlayUrl, handleDownloadUrl, isVideoFile]); const renderGroup = useCallback((group: PlaylistGroup): React.ReactNode => { return
{expanded.current[group.id] ? : }
+ onClick={handleExpand}>{getIconByName(expanded.current[group.id] ? + 'ExpandMore' : 'ChevronRight')}
{group.title} @@ -115,5 +210,17 @@ export default function PlaylistTree(props: PlaylistTreeProps) { ; }, [data, renderGroup]); - return
{renderPlaylist()}
; + const renderDownloads = useCallback((): React.ReactNode => { + const keys = Object.keys(downloads) + if (keys.length) { + let elements = keys.map(key => { + const info: DownloadInfo = downloads[key]; + return
  • {info.filename}: {info.filesize ? (info.filesize / 1_048_576).toFixed(2) : 0} MB
  • ; + }) + return
      {elements}
    ; + } + return <>; + }, [downloads]); + + return
    {renderPlaylist()}{renderDownloads()}
    ; } \ No newline at end of file diff --git a/frontend/src/component/playlist-viewer/playlist-viewer.scss b/frontend/src/component/playlist-viewer/playlist-viewer.scss index 3f08c647d..deb4c6256 100755 --- a/frontend/src/component/playlist-viewer/playlist-viewer.scss +++ b/frontend/src/component/playlist-viewer/playlist-viewer.scss @@ -1,6 +1,5 @@ .playlist-viewer { - flex-grow: 1; - flex-shrink: 1; + flex: 1 1 auto; overflow: hidden; overflow-y: auto; padding: 10px; diff --git a/frontend/src/component/playlist-viewer/playlist-viewer.tsx b/frontend/src/component/playlist-viewer/playlist-viewer.tsx index 213b209dc..0242c0315 100755 --- a/frontend/src/component/playlist-viewer/playlist-viewer.tsx +++ b/frontend/src/component/playlist-viewer/playlist-viewer.tsx @@ -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; onProgress: (value: boolean) => void; @@ -66,7 +68,7 @@ interface PlaylistViewerProps { } const PlaylistViewer = forwardRef((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([]); const checked = useMemo((): PlaylistTreeState => ({}), []); @@ -109,7 +111,7 @@ const PlaylistViewer = forwardRef((props: }, [searchChannel, playlist, enqueueSnackbar, onProgress]); return
    - +
    }); diff --git a/frontend/src/component/popup-menu/popup-menu.scss b/frontend/src/component/popup-menu/popup-menu.scss new file mode 100644 index 000000000..a243fc4c3 --- /dev/null +++ b/frontend/src/component/popup-menu/popup-menu.scss @@ -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); + } + } + } +} \ No newline at end of file diff --git a/frontend/src/component/popup-menu/popup-menu.tsx b/frontend/src/component/popup-menu/popup-menu.tsx new file mode 100644 index 000000000..97b94db97 --- /dev/null +++ b/frontend/src/component/popup-menu/popup-menu.tsx @@ -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({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 && +
    + {props.children} +
    +
    } +} \ No newline at end of file diff --git a/frontend/src/component/sidebar/sidebar.scss b/frontend/src/component/sidebar/sidebar.scss index 6de372397..1e5a58122 100644 --- a/frontend/src/component/sidebar/sidebar.scss +++ b/frontend/src/component/sidebar/sidebar.scss @@ -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; diff --git a/frontend/src/component/sidebar/sidebar.tsx b/frontend/src/component/sidebar/sidebar.tsx index 4e285ef31..61eb9561a 100644 --- a/frontend/src/component/sidebar/sidebar.tsx +++ b/frontend/src/component/sidebar/sidebar.tsx @@ -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}
    - {collapsed ? : } + {getIconByName(collapsed ? 'ArrowDown' : 'ArrowUp')}
    ; diff --git a/frontend/src/component/source-selector/source-selector.scss b/frontend/src/component/source-selector/source-selector.scss index 3f3184567..a3b40aaf8 100755 --- a/frontend/src/component/source-selector/source-selector.scss +++ b/frontend/src/component/source-selector/source-selector.scss @@ -2,4 +2,5 @@ display: flex; flex-flow: row nowrap; padding: 10px; -} \ No newline at end of file +} + diff --git a/frontend/src/component/source-selector/source-selector.tsx b/frontend/src/component/source-selector/source-selector.tsx index 460b4f91e..491a28c9a 100755 --- a/frontend/src/component/source-selector/source-selector.tsx +++ b/frontend/src/component/source-selector/source-selector.tsx @@ -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(); - const services = useServices(); - const {enqueueSnackbar/*, closeSnackbar*/} = useSnackbar(); - const [openSourceDropDown, setOpenSourceDropDown] = useState(false); - const [sources, setSources] = useState([]); + const [popupVisible, setPopupVisible] = useState<{ x: number, y: number }>(undefined); + const [sources, setSources] = useState([]); + const [selected, setSelected] = useState(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) => { + const handleKeyPress = useCallback((event: KeyboardEvent) => { 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: - - - - - - - + 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
    - - - {sources?.map(s => - {s.url}) - } - +
    + + + + +
    + +
      + {sources.map((s, idx) => +
    • {s.name || s.url}
    • )} +
    +
    } diff --git a/frontend/src/component/svg-icon/svg-icon.scss b/frontend/src/component/svg-icon/svg-icon.scss new file mode 100644 index 000000000..157f0da0c --- /dev/null +++ b/frontend/src/component/svg-icon/svg-icon.scss @@ -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; +} diff --git a/frontend/src/component/svg-icon/svg-icon.tsx b/frontend/src/component/svg-icon/svg-icon.tsx new file mode 100644 index 000000000..458ae0fb0 --- /dev/null +++ b/frontend/src/component/svg-icon/svg-icon.tsx @@ -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 ; +} + +SvgIcon.displayName = 'SvgIcon'; +export default SvgIcon; diff --git a/frontend/src/component/toolbar/toolbar.scss b/frontend/src/component/toolbar/toolbar.scss index f04c5d027..6fdd691fd 100755 --- a/frontend/src/component/toolbar/toolbar.scss +++ b/frontend/src/component/toolbar/toolbar.scss @@ -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; + } } \ No newline at end of file diff --git a/frontend/src/component/toolbar/toolbar.tsx b/frontend/src/component/toolbar/toolbar.tsx index e80df3b14..a9990aae9 100755 --- a/frontend/src/component/toolbar/toolbar.tsx +++ b/frontend/src/component/toolbar/toolbar.tsx @@ -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
    - +
    } \ No newline at end of file diff --git a/frontend/src/icons/icons.tsx b/frontend/src/icons/icons.tsx new file mode 100644 index 000000000..da394d4b6 --- /dev/null +++ b/frontend/src/icons/icons.tsx @@ -0,0 +1,39 @@ +import createIcon from "../utils/icon-utils"; +import * as React from "react"; + +const ICON: Record = {}; + +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; diff --git a/frontend/src/model/file-download.ts b/frontend/src/model/file-download.ts new file mode 100644 index 000000000..9fa50f82a --- /dev/null +++ b/frontend/src/model/file-download.ts @@ -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; +} \ No newline at end of file diff --git a/frontend/src/model/playlist-request.ts b/frontend/src/model/playlist-request.ts index c75c12b1c..315e934c7 100644 --- a/frontend/src/model/playlist-request.ts +++ b/frontend/src/model/playlist-request.ts @@ -1,3 +1,4 @@ export interface PlaylistRequest { - url: string; + url?: string; + input_id?: number; } \ No newline at end of file diff --git a/frontend/src/model/playlist.ts b/frontend/src/model/playlist.ts index 51b8f2540..3e8ae2583 100755 --- a/frontend/src/model/playlist.ts +++ b/frontend/src/model/playlist.ts @@ -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 { diff --git a/frontend/src/model/server-config.ts b/frontend/src/model/server-config.ts index 2f80563c5..49a28b7c2 100644 --- a/frontend/src/model/server-config.ts +++ b/frontend/src/model/server-config.ts @@ -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, + directory: string; + } +} + export default interface ServerConfig { - sources: string[]; + sources: SourceConfig[]; + video: VideoConfig; } \ No newline at end of file diff --git a/frontend/src/model/source.ts b/frontend/src/model/source.ts deleted file mode 100644 index 278e8b35f..000000000 --- a/frontend/src/model/source.ts +++ /dev/null @@ -1,4 +0,0 @@ -export default interface Source { - url: string; - ts: number; -} \ No newline at end of file diff --git a/frontend/src/scss/_default.scss b/frontend/src/scss/_default.scss index c9a3f74cf..c01550134 100644 --- a/frontend/src/scss/_default.scss +++ b/frontend/src/scss/_default.scss @@ -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; } } \ No newline at end of file diff --git a/frontend/src/scss/_theme.scss b/frontend/src/scss/_theme.scss index 20d0dc6fc..e2cefc576 100644 --- a/frontend/src/scss/_theme.scss +++ b/frontend/src/scss/_theme.scss @@ -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; + } \ No newline at end of file diff --git a/frontend/src/service/file-service.ts b/frontend/src/service/file-service.ts index 2ced7e6ec..bee31fcc0 100755 --- a/frontend/src/service/file-service.ts +++ b/frontend/src/service/file-service.ts @@ -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"); } -} \ No newline at end of file + + download(req: FileDownloadRequest): Observable { + return this.fileApiService.download(req); + } + + getDownloadInfo(download_id: string): Observable { + return this.fileApiService.getDownloadInfo(download_id); + } +} + diff --git a/frontend/src/service/playlist-service.ts b/frontend/src/service/playlist-service.ts index bdb2852ad..a8d84483f 100644 --- a/frontend/src/service/playlist-service.ts +++ b/frontend/src/service/playlist-service.ts @@ -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 { + getPlaylist(req: PlaylistRequest): Observable { 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; diff --git a/frontend/src/utils/click-away-listener.tsx b/frontend/src/utils/click-away-listener.tsx new file mode 100644 index 000000000..e8f1f539c --- /dev/null +++ b/frontend/src/utils/click-away-listener.tsx @@ -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 { + onClickAway: (event: Events) => void; + mouseEvent?: MouseEvents; + touchEvent?: TouchEvents; + children: ReactElement; +} + +const eventTypeMapping = { + click: 'onClick', + mousedown: 'onMouseDown', + mouseup: 'onMouseUp', + touchstart: 'onTouchStart', + touchend: 'onTouchEnd' +}; + +const ClickAwayListener: FunctionComponent = ({ + children, + onClickAway, + mouseEvent = 'click', + touchEvent = 'touchend' + }) => { + const node = useRef(null); + const bubbledEventTarget = useRef(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 | MutableRefObject; + }; + + 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, { + ref: handleChildRef, + [mappedMouseEvent]: handleBubbledEvents(mappedMouseEvent), + [mappedTouchEvent]: handleBubbledEvents(mappedTouchEvent) + }) + ); +}; + +ClickAwayListener.displayName = 'ClickAwayListener'; + +export default ClickAwayListener; diff --git a/frontend/src/utils/icon-utils.tsx b/frontend/src/utils/icon-utils.tsx new file mode 100644 index 000000000..7446eb461 --- /dev/null +++ b/frontend/src/utils/icon-utils.tsx @@ -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 ; +} diff --git a/src/api/api_model.rs b/src/api/api_model.rs index 46d399a23..bae4a1291 100644 --- a/src/api/api_model.rs +++ b/src/api/api_model.rs @@ -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, pub targets: Arc, + pub downloads: Arc>> } #[derive(Serialize)] diff --git a/src/api/main_api.rs b/src/api/main_api.rs index aa5faff52..9f29daf41 100644 --- a/src/api/main_api.rs +++ b/src/api/main_api.rs @@ -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, targets: Arc) let shared_data = web::Data::new(AppState { config: cfg, targets, + downloads: Arc::new(Mutex::new(HashMap::new())) }); // Scheduler diff --git a/src/api/v1_api.rs b/src/api/v1_api.rs index 73283d76d..bca1dbf74 100644 --- a/src/api/v1_api.rs +++ b/src/api/v1_api.rs @@ -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>>) -> Result { 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, + _app_state: web::Data, +) -> 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, _app_state: web::Data, ) -> 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)) } diff --git a/src/utils.rs b/src/utils.rs index 12f3d449c..8fe5acc8a 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -251,4 +251,8 @@ pub(crate) fn get_input_json_content(input: &ConfigInput, url_str: &String, pers // pub(crate) fn dedup(v: &mut Vec) { // let mut uniques = HashSet::new(); // v.retain(|e| uniques.insert(*e)); -// } \ No newline at end of file +// } + +pub(crate) fn bytes_to_megabytes(bytes: u64) -> u64 { + bytes / 1_048_576 +} \ No newline at end of file