Add web UI, AGPL3, notifications, continue watching, bulk watched, storage stats, scheduler
License: - GPL3 → AGPL3 New features: - Web interface (--web [port]): axum/tokio server with SSE progress, library browser, download trigger, watched toggle; embedded single-page HTML/JS UI - Desktop notifications via notify-rust when downloads complete or fail - Continue Watching sidebar entry showing videos with saved resume positions - Bulk select mode: select multiple videos, mark all watched/unwatched at once - Storage stats: channel disk usage shown in sidebar next to video count - Scheduled channel checks: configurable interval (hours), auto re-downloads all tracked channels using channel_url from info.json; enabled in Settings Theme fixes: - Scene Queen: remove override_text_color so active button text uses fg_stroke (dark navy on neon green) instead of near-white — fixes unreadable contrast - Trans: override_text_color → hot pink #cc0066 for pink-tinted text throughout Settings additions: - Auto-check channels toggle + interval_hours DragValue - Web UI port setting Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
5b3b8fc901
commit
80cce00b99
8 changed files with 1526 additions and 211 deletions
604
Cargo.lock
generated
604
Cargo.lock
generated
|
|
@ -439,6 +439,73 @@ version = "1.5.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.7.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum-core",
|
||||
"axum-macros",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-macros"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.6.0"
|
||||
|
|
@ -493,6 +560,15 @@ dependencies = [
|
|||
"objc2 0.5.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
|
||||
dependencies = [
|
||||
"objc2 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blocking"
|
||||
version = "1.6.2"
|
||||
|
|
@ -867,6 +943,26 @@ version = "1.2.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
||||
|
||||
[[package]]
|
||||
name = "dbus"
|
||||
version = "0.9.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libdbus-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
|
|
@ -1424,7 +1520,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
||||
dependencies = [
|
||||
"rustix 1.1.4",
|
||||
"windows-link",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1836,6 +1932,86 @@ version = "0.2.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body-util"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.2.0"
|
||||
|
|
@ -2012,7 +2188,7 @@ dependencies = [
|
|||
"simd_cesu8",
|
||||
"thiserror 2.0.18",
|
||||
"walkdir",
|
||||
"windows-link",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2142,6 +2318,15 @@ version = "0.2.186"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libdbus-sys"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
|
||||
dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.7.4"
|
||||
|
|
@ -2159,7 +2344,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-link",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2243,6 +2428,18 @@ version = "0.4.29"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "mac-notification-sys"
|
||||
version = "0.6.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"objc2 0.6.4",
|
||||
"objc2-foundation 0.3.2",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "malloc_buf"
|
||||
version = "0.0.6"
|
||||
|
|
@ -2252,6 +2449,12 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
|
|
@ -2291,6 +2494,12 @@ dependencies = [
|
|||
"paste",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
|
|
@ -2301,6 +2510,17 @@ dependencies = [
|
|||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
|
|
@ -2409,6 +2629,25 @@ version = "0.2.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||
|
||||
[[package]]
|
||||
name = "notify-rust"
|
||||
version = "4.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50ff2e74231b72c832d82982193b417f230945be6bdb5575b251d941d31adb00"
|
||||
dependencies = [
|
||||
"dbus",
|
||||
"futures-lite",
|
||||
"log",
|
||||
"mac-notification-sys",
|
||||
"tauri-winrt-notification",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
|
|
@ -2481,7 +2720,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"libc",
|
||||
"objc2 0.5.2",
|
||||
"objc2-core-data",
|
||||
|
|
@ -2510,7 +2749,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-core-location",
|
||||
"objc2-foundation 0.2.2",
|
||||
|
|
@ -2522,7 +2761,7 @@ version = "0.2.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
|
@ -2534,7 +2773,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
|
@ -2569,7 +2808,7 @@ version = "0.2.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
"objc2-metal",
|
||||
|
|
@ -2581,7 +2820,7 @@ version = "0.2.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-contacts",
|
||||
"objc2-foundation 0.2.2",
|
||||
|
|
@ -2600,7 +2839,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"dispatch",
|
||||
"libc",
|
||||
"objc2 0.5.2",
|
||||
|
|
@ -2613,6 +2852,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"block2 0.6.2",
|
||||
"libc",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
|
@ -2634,7 +2875,7 @@ version = "0.2.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit 0.2.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
|
|
@ -2647,7 +2888,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
|
@ -2659,7 +2900,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
"objc2-metal",
|
||||
|
|
@ -2682,7 +2923,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-cloud-kit",
|
||||
"objc2-core-data",
|
||||
|
|
@ -2702,7 +2943,7 @@ version = "0.2.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
|
@ -2714,7 +2955,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-core-location",
|
||||
"objc2-foundation 0.2.2",
|
||||
|
|
@ -2806,7 +3047,7 @@ dependencies = [
|
|||
"libc",
|
||||
"redox_syscall 0.5.18",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2919,6 +3160,12 @@ dependencies = [
|
|||
"zerovec",
|
||||
]
|
||||
|
||||
[[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.21"
|
||||
|
|
@ -3034,6 +3281,15 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.37.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.39.4"
|
||||
|
|
@ -3211,6 +3467,12 @@ version = "1.0.22"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
|
|
@ -3294,6 +3556,17 @@ dependencies = [
|
|||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.20"
|
||||
|
|
@ -3314,6 +3587,18 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
|
|
@ -3456,6 +3741,16 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spirv"
|
||||
version = "0.3.0+sdk-1.3.268.0"
|
||||
|
|
@ -3505,6 +3800,12 @@ dependencies = [
|
|||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.13.2"
|
||||
|
|
@ -3535,6 +3836,18 @@ version = "0.12.16"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
||||
|
||||
[[package]]
|
||||
name = "tauri-winrt-notification"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
|
||||
dependencies = [
|
||||
"quick-xml 0.37.5",
|
||||
"thiserror 2.0.18",
|
||||
"windows 0.61.3",
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
|
|
@ -3597,6 +3910,25 @@ dependencies = [
|
|||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.11.4"
|
||||
|
|
@ -3632,6 +3964,59 @@ dependencies = [
|
|||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.52.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.2"
|
||||
|
|
@ -3707,6 +4092,50 @@ dependencies = [
|
|||
"winnow 1.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bytes",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"pin-project-lite",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-layer"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
|
||||
|
||||
[[package]]
|
||||
name = "tower-service"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
|
|
@ -4302,6 +4731,28 @@ dependencies = [
|
|||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.61.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-core 0.61.2",
|
||||
"windows-future",
|
||||
"windows-link 0.1.3",
|
||||
"windows-numerics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-collections"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
|
||||
dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.52.0"
|
||||
|
|
@ -4317,13 +4768,37 @@ version = "0.58.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
"windows-implement 0.58.0",
|
||||
"windows-interface 0.58.0",
|
||||
"windows-result 0.2.0",
|
||||
"windows-strings 0.1.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
||||
dependencies = [
|
||||
"windows-implement 0.60.2",
|
||||
"windows-interface 0.59.3",
|
||||
"windows-link 0.1.3",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-future"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
|
||||
dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
"windows-link 0.1.3",
|
||||
"windows-threading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.58.0"
|
||||
|
|
@ -4335,6 +4810,17 @@ dependencies = [
|
|||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.58.0"
|
||||
|
|
@ -4346,12 +4832,39 @@ dependencies = [
|
|||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-numerics"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
|
||||
dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.2.0"
|
||||
|
|
@ -4361,16 +4874,34 @@ dependencies = [
|
|||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
|
||||
dependencies = [
|
||||
"windows-result",
|
||||
"windows-result 0.2.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
|
|
@ -4404,7 +4935,7 @@ version = "0.61.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4429,7 +4960,7 @@ version = "0.53.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-link 0.2.1",
|
||||
"windows_aarch64_gnullvm 0.53.1",
|
||||
"windows_aarch64_msvc 0.53.1",
|
||||
"windows_i686_gnu 0.53.1",
|
||||
|
|
@ -4440,6 +4971,24 @@ dependencies = [
|
|||
"windows_x86_64_msvc 0.53.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-version"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
|
|
@ -4546,7 +5095,7 @@ dependencies = [
|
|||
"android-activity",
|
||||
"atomic-waker",
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"bytemuck",
|
||||
"calloop 0.13.0",
|
||||
"cfg_aliases 0.2.1",
|
||||
|
|
@ -4816,12 +5365,17 @@ dependencies = [
|
|||
name = "yt-offline"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"eframe",
|
||||
"image",
|
||||
"notify-rust",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"toml",
|
||||
"tower-http",
|
||||
"tray-icon",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ serde = { version = "1.0", features = ["derive"] }
|
|||
serde_json = "1.0"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
tray-icon = "0.13"
|
||||
notify-rust = { version = "4", default-features = false, features = ["d"] }
|
||||
axum = { version = "0.7", features = ["macros"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
tower-http = { version = "0.5", features = ["cors"] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = 2
|
||||
|
|
|
|||
141
LICENSE
141
LICENSE
|
|
@ -1,5 +1,5 @@
|
|||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
|
|
@ -7,17 +7,15 @@
|
|||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
|
|
@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
|
|||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
|
@ -72,7 +60,7 @@ modification follow.
|
|||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
|
@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
|
|||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
|
|
@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
|
|||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
|
|
|||
427
src/app.rs
427
src/app.rs
|
|
@ -2,6 +2,7 @@ use std::collections::{HashMap, HashSet};
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::time::Instant;
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
|
|
@ -30,6 +31,14 @@ enum SortMode {
|
|||
SizeDesc,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum SidebarView {
|
||||
All,
|
||||
Channel(usize),
|
||||
Playlist(usize, usize),
|
||||
ContinueWatching,
|
||||
}
|
||||
|
||||
struct Card {
|
||||
channel_name: String,
|
||||
title: String,
|
||||
|
|
@ -48,8 +57,7 @@ pub struct App {
|
|||
config_path: PathBuf,
|
||||
channels_root: PathBuf,
|
||||
library: Vec<library::Channel>,
|
||||
selected_channel: Option<usize>,
|
||||
selected_playlist: Option<(usize, usize)>,
|
||||
sidebar_view: SidebarView,
|
||||
selected_video: Option<String>,
|
||||
search: String,
|
||||
downloader: Downloader,
|
||||
|
|
@ -66,9 +74,14 @@ pub struct App {
|
|||
sort_mode: SortMode,
|
||||
watched: HashSet<String>,
|
||||
resume_positions: HashMap<String, f64>,
|
||||
prev_any_running: bool,
|
||||
prev_job_states: HashMap<usize, JobState>,
|
||||
currently_playing: Option<String>,
|
||||
mpv_rx: Option<Receiver<(String, f64)>>,
|
||||
// Bulk selection
|
||||
bulk_mode: bool,
|
||||
bulk_selected: HashSet<String>,
|
||||
// Scheduler
|
||||
last_scheduled_check: Option<Instant>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
|
|
@ -109,8 +122,7 @@ impl App {
|
|||
config_path,
|
||||
channels_root: channels_root.clone(),
|
||||
library,
|
||||
selected_channel: None,
|
||||
selected_playlist: None,
|
||||
sidebar_view: SidebarView::All,
|
||||
selected_video: None,
|
||||
search: String::new(),
|
||||
downloader: Downloader::new(channels_root, browser),
|
||||
|
|
@ -127,16 +139,18 @@ impl App {
|
|||
sort_mode: SortMode::Title,
|
||||
watched,
|
||||
resume_positions,
|
||||
prev_any_running: false,
|
||||
prev_job_states: HashMap::new(),
|
||||
currently_playing: None,
|
||||
mpv_rx: None,
|
||||
bulk_mode: false,
|
||||
bulk_selected: HashSet::new(),
|
||||
last_scheduled_check: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rescan(&mut self) {
|
||||
self.library = library::scan_channels(&self.channels_root);
|
||||
self.selected_channel = None;
|
||||
self.selected_playlist = None;
|
||||
self.sidebar_view = SidebarView::All;
|
||||
self.selected_video = None;
|
||||
self.desc_cache.clear();
|
||||
self.textures.clear();
|
||||
|
|
@ -150,51 +164,68 @@ impl App {
|
|||
fn cards(&self) -> Vec<Card> {
|
||||
let query = self.search.trim().to_lowercase();
|
||||
|
||||
let channel_indices: Vec<usize> = if let Some(ci) = self.selected_channel {
|
||||
vec![ci]
|
||||
} else {
|
||||
(0..self.library.len()).collect()
|
||||
let mut cards = Vec::new();
|
||||
|
||||
let add_video = |cards: &mut Vec<Card>, ch_name: &str, v: &library::Video| {
|
||||
if !query.is_empty()
|
||||
&& !v.title.to_lowercase().contains(&query)
|
||||
&& !v.id.to_lowercase().contains(&query)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let resume_pos = self.resume_positions.get(&v.id).copied();
|
||||
cards.push(Card {
|
||||
channel_name: ch_name.to_string(),
|
||||
title: v.title.clone(),
|
||||
id: v.id.clone(),
|
||||
video_path: v.video_path.clone(),
|
||||
thumb_path: v.thumb_path.clone(),
|
||||
has_live_chat: v.has_live_chat,
|
||||
duration_secs: v.duration_secs,
|
||||
file_size: v.file_size,
|
||||
watched: self.watched.contains(&v.id),
|
||||
resume_pos,
|
||||
});
|
||||
};
|
||||
|
||||
let mut cards = Vec::new();
|
||||
for ci in channel_indices {
|
||||
let Some(channel) = self.library.get(ci) else { continue };
|
||||
|
||||
let playlist_filter = match self.selected_playlist {
|
||||
Some((pci, pi)) if pci == ci => Some(pi),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let videos_iter: Box<dyn Iterator<Item = &library::Video>> = if let Some(pi) = playlist_filter {
|
||||
let Some(playlist) = channel.playlists.get(pi) else { continue };
|
||||
Box::new(playlist.videos.iter())
|
||||
} else {
|
||||
Box::new(
|
||||
channel.videos.iter()
|
||||
.chain(channel.playlists.iter().flat_map(|p| p.videos.iter()))
|
||||
)
|
||||
};
|
||||
|
||||
for v in videos_iter {
|
||||
if !query.is_empty()
|
||||
&& !v.title.to_lowercase().contains(&query)
|
||||
&& !v.id.to_lowercase().contains(&query)
|
||||
{
|
||||
continue;
|
||||
match &self.sidebar_view {
|
||||
SidebarView::ContinueWatching => {
|
||||
for ch in &self.library {
|
||||
for v in ch.videos.iter().chain(ch.playlists.iter().flat_map(|p| p.videos.iter())) {
|
||||
if self.resume_positions.contains_key(&v.id) {
|
||||
add_video(&mut cards, &ch.name, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
let resume_pos = self.resume_positions.get(&v.id).copied();
|
||||
cards.push(Card {
|
||||
channel_name: channel.name.clone(),
|
||||
title: v.title.clone(),
|
||||
id: v.id.clone(),
|
||||
video_path: v.video_path.clone(),
|
||||
thumb_path: v.thumb_path.clone(),
|
||||
has_live_chat: v.has_live_chat,
|
||||
duration_secs: v.duration_secs,
|
||||
file_size: v.file_size,
|
||||
watched: self.watched.contains(&v.id),
|
||||
resume_pos,
|
||||
cards.sort_by(|a, b| {
|
||||
b.resume_pos.unwrap_or(0.0)
|
||||
.partial_cmp(&a.resume_pos.unwrap_or(0.0))
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
return cards;
|
||||
}
|
||||
SidebarView::All => {
|
||||
for ch in &self.library {
|
||||
for v in ch.videos.iter().chain(ch.playlists.iter().flat_map(|p| p.videos.iter())) {
|
||||
add_video(&mut cards, &ch.name, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
SidebarView::Channel(ci) => {
|
||||
if let Some(ch) = self.library.get(*ci) {
|
||||
for v in ch.videos.iter().chain(ch.playlists.iter().flat_map(|p| p.videos.iter())) {
|
||||
add_video(&mut cards, &ch.name, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
SidebarView::Playlist(ci, pi) => {
|
||||
if let Some(ch) = self.library.get(*ci) {
|
||||
if let Some(pl) = ch.playlists.get(*pi) {
|
||||
for v in &pl.videos {
|
||||
add_video(&mut cards, &ch.name, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -334,6 +365,76 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
fn bulk_mark_watched(&mut self, watched: bool) {
|
||||
let ids: Vec<String> = self.bulk_selected.iter().cloned().collect();
|
||||
for id in &ids {
|
||||
if let Ok(()) = self.db.set_watched(id, watched) {
|
||||
if watched {
|
||||
self.watched.insert(id.clone());
|
||||
} else {
|
||||
self.watched.remove(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.bulk_selected.clear();
|
||||
self.status = format!(
|
||||
"{} {} as {}watched",
|
||||
ids.len(),
|
||||
if ids.len() == 1 { "video" } else { "videos" },
|
||||
if watched { "" } else { "un" }
|
||||
);
|
||||
}
|
||||
|
||||
fn run_scheduled_check(&mut self) {
|
||||
let mut count = 0;
|
||||
let urls: Vec<String> = self.library.iter()
|
||||
.filter_map(|ch| ch.meta.as_ref()?.channel_url.clone())
|
||||
.collect();
|
||||
for url in urls {
|
||||
let kind = detect_url_kind(&url);
|
||||
self.downloader.start(url, &kind);
|
||||
count += 1;
|
||||
}
|
||||
self.status = format!("Scheduled check: started {} channel downloads", count);
|
||||
}
|
||||
|
||||
fn check_notifications(&mut self) {
|
||||
let jobs = &self.downloader.jobs;
|
||||
let mut finished: Vec<(String, bool)> = Vec::new();
|
||||
|
||||
for (i, job) in jobs.iter().enumerate() {
|
||||
let prev = self.prev_job_states.get(&i).copied();
|
||||
if prev == Some(JobState::Running) && job.state != JobState::Running {
|
||||
finished.push((job.label.clone(), job.state == JobState::Done));
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild snapshot
|
||||
self.prev_job_states = jobs.iter().enumerate()
|
||||
.map(|(i, j)| (i, j.state))
|
||||
.collect();
|
||||
|
||||
for (label, ok) in finished {
|
||||
let summary = if ok {
|
||||
format!("Download complete: {label}")
|
||||
} else {
|
||||
format!("Download failed: {label}")
|
||||
};
|
||||
let _ = notify_rust::Notification::new()
|
||||
.summary("yt-offline")
|
||||
.body(&summary)
|
||||
.timeout(notify_rust::Timeout::Milliseconds(4000))
|
||||
.show();
|
||||
}
|
||||
}
|
||||
|
||||
fn channel_total_size(ch: &library::Channel) -> u64 {
|
||||
ch.videos.iter()
|
||||
.chain(ch.playlists.iter().flat_map(|p| p.videos.iter()))
|
||||
.filter_map(|v| v.file_size)
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn top_bar(&mut self, ctx: &egui::Context) {
|
||||
egui::TopBottomPanel::top("top_bar").show(ctx, |ui| {
|
||||
ui.add_space(2.0);
|
||||
|
|
@ -388,45 +489,76 @@ impl App {
|
|||
ui.separator();
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
let total: usize = self.library.iter().map(|c| c.total_videos()).sum();
|
||||
let resume_count = self.resume_positions.len();
|
||||
|
||||
if ui
|
||||
.selectable_label(
|
||||
self.selected_channel.is_none(),
|
||||
self.sidebar_view == SidebarView::All,
|
||||
format!("⊞ All ({total})"),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
self.selected_channel = None;
|
||||
self.selected_playlist = None;
|
||||
self.sidebar_view = SidebarView::All;
|
||||
self.selected_video = None;
|
||||
}
|
||||
ui.separator();
|
||||
for i in 0..self.library.len() {
|
||||
let is_selected = self.selected_channel == Some(i);
|
||||
let (name, total, has_playlists) = {
|
||||
let ch = &self.library[i];
|
||||
(ch.name.clone(), ch.total_videos(), !ch.playlists.is_empty())
|
||||
};
|
||||
let label = format!("{} ({})", name, total);
|
||||
|
||||
if resume_count > 0 {
|
||||
if ui
|
||||
.selectable_label(is_selected && self.selected_playlist.is_none(), label)
|
||||
.selectable_label(
|
||||
self.sidebar_view == SidebarView::ContinueWatching,
|
||||
format!("▶ Continue Watching ({resume_count})"),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
self.sidebar_view = SidebarView::ContinueWatching;
|
||||
self.selected_video = None;
|
||||
}
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
for i in 0..self.library.len() {
|
||||
let (name, total, has_playlists, size_bytes) = {
|
||||
let ch = &self.library[i];
|
||||
(
|
||||
ch.name.clone(),
|
||||
ch.total_videos(),
|
||||
!ch.playlists.is_empty(),
|
||||
Self::channel_total_size(ch),
|
||||
)
|
||||
};
|
||||
|
||||
let is_ch_selected = matches!(self.sidebar_view, SidebarView::Channel(ci) if ci == i)
|
||||
|| matches!(self.sidebar_view, SidebarView::Playlist(ci, _) if ci == i);
|
||||
|
||||
let size_str = if size_bytes > 0 {
|
||||
format!(" · {}", format_size(size_bytes))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let label = format!("{} ({}{})", name, total, size_str);
|
||||
|
||||
let ch_selected_no_pl = matches!(self.sidebar_view, SidebarView::Channel(ci) if ci == i);
|
||||
if ui
|
||||
.selectable_label(ch_selected_no_pl, label)
|
||||
.on_hover_text(self.library[i].path.display().to_string())
|
||||
.clicked()
|
||||
{
|
||||
self.selected_channel = Some(i);
|
||||
self.selected_playlist = None;
|
||||
self.sidebar_view = SidebarView::Channel(i);
|
||||
self.selected_video = None;
|
||||
}
|
||||
if is_selected && has_playlists {
|
||||
|
||||
if is_ch_selected && has_playlists {
|
||||
let playlist_count = self.library[i].playlists.len();
|
||||
for pi in 0..playlist_count {
|
||||
let (pl_name, pl_len) = {
|
||||
let pl = &self.library[i].playlists[pi];
|
||||
(pl.name.clone(), pl.videos.len())
|
||||
};
|
||||
let is_pl = self.selected_playlist == Some((i, pi));
|
||||
let is_pl = matches!(self.sidebar_view, SidebarView::Playlist(ci, pli) if ci == i && pli == pi);
|
||||
let pl_label = format!(" └ {} ({})", pl_name, pl_len);
|
||||
if ui.selectable_label(is_pl, pl_label).clicked() {
|
||||
self.selected_playlist = Some((i, pi));
|
||||
self.sidebar_view = SidebarView::Playlist(i, pi);
|
||||
self.selected_video = None;
|
||||
}
|
||||
}
|
||||
|
|
@ -486,6 +618,7 @@ impl App {
|
|||
&& ui.button("Clear finished").clicked()
|
||||
{
|
||||
self.downloader.jobs.retain(|j| j.state == JobState::Running);
|
||||
self.prev_job_states.clear();
|
||||
}
|
||||
});
|
||||
if self.downloader.jobs.is_empty() {
|
||||
|
|
@ -537,7 +670,7 @@ impl App {
|
|||
.open(&mut open)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.default_width(460.0)
|
||||
.default_width(480.0)
|
||||
.show(ctx, |ui| {
|
||||
egui::Grid::new("settings_grid")
|
||||
.num_columns(2)
|
||||
|
|
@ -603,6 +736,25 @@ impl App {
|
|||
}
|
||||
});
|
||||
ui.end_row();
|
||||
|
||||
ui.label("Auto-check channels:");
|
||||
ui.checkbox(&mut self.config.scheduler.enabled, "enabled");
|
||||
ui.end_row();
|
||||
|
||||
ui.label("Check interval (hours):");
|
||||
ui.add(
|
||||
egui::DragValue::new(&mut self.config.scheduler.interval_hours)
|
||||
.range(1..=168)
|
||||
.suffix("h"),
|
||||
);
|
||||
ui.end_row();
|
||||
|
||||
ui.label("Web UI port:");
|
||||
ui.add(
|
||||
egui::DragValue::new(&mut self.config.web.port)
|
||||
.range(1024..=65535),
|
||||
);
|
||||
ui.end_row();
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
|
@ -729,10 +881,10 @@ impl App {
|
|||
|
||||
fn video_list(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) {
|
||||
let cards = self.cards();
|
||||
let show_channel = self.selected_channel.is_none();
|
||||
let show_channel = !matches!(self.sidebar_view, SidebarView::Channel(_) | SidebarView::Playlist(_, _));
|
||||
|
||||
// Channel metadata banner
|
||||
if let Some(ci) = self.selected_channel {
|
||||
if let SidebarView::Channel(ci) = self.sidebar_view {
|
||||
if let Some(ch) = self.library.get(ci) {
|
||||
if let Some(meta) = &ch.meta {
|
||||
ui.group(|ui| {
|
||||
|
|
@ -755,14 +907,44 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
// Sort controls
|
||||
// Bulk mode toolbar
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(format!("{} videos", cards.len()));
|
||||
let label_text = if self.bulk_mode {
|
||||
format!("{} videos", cards.len())
|
||||
} else {
|
||||
format!("{} videos", cards.len())
|
||||
};
|
||||
ui.label(label_text);
|
||||
|
||||
if !self.search.trim().is_empty() {
|
||||
ui.label(
|
||||
egui::RichText::new(format!("(filtered by \"{}\")", self.search.trim())).weak(),
|
||||
);
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
if ui.selectable_label(self.bulk_mode, "☑ Select").clicked() {
|
||||
self.bulk_mode = !self.bulk_mode;
|
||||
if !self.bulk_mode {
|
||||
self.bulk_selected.clear();
|
||||
}
|
||||
}
|
||||
|
||||
if self.bulk_mode && !self.bulk_selected.is_empty() {
|
||||
ui.separator();
|
||||
let n = self.bulk_selected.len();
|
||||
ui.label(format!("{n} selected"));
|
||||
if ui.button("✓ Mark watched").clicked() {
|
||||
self.bulk_mark_watched(true);
|
||||
self.bulk_mode = false;
|
||||
}
|
||||
if ui.button("○ Mark unwatched").clicked() {
|
||||
self.bulk_mark_watched(false);
|
||||
self.bulk_mode = false;
|
||||
}
|
||||
}
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.selectable_value(&mut self.sort_mode, SortMode::SizeDesc, "Size ↓");
|
||||
ui.selectable_value(&mut self.sort_mode, SortMode::SizeAsc, "Size ↑");
|
||||
|
|
@ -779,13 +961,15 @@ impl App {
|
|||
ui.add_space(20.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(egui::RichText::new("Nothing here.").weak());
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"Drop a yt-dlp download into channels/<name>/, or use the Downloads panel.",
|
||||
)
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
if !matches!(self.sidebar_view, SidebarView::ContinueWatching) {
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"Drop a yt-dlp download into channels/<name>/, or use the Downloads panel.",
|
||||
)
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -799,6 +983,7 @@ impl App {
|
|||
for card in &cards {
|
||||
let selected = self.selected_video.as_deref() == Some(card.id.as_str());
|
||||
let is_playing = self.currently_playing.as_deref() == Some(card.id.as_str());
|
||||
let bulk_checked = self.bulk_selected.contains(&card.id);
|
||||
let mut clicked_card = false;
|
||||
let mut play_card = false;
|
||||
let mut toggle_watched_card = false;
|
||||
|
|
@ -838,7 +1023,13 @@ impl App {
|
|||
egui::Stroke::new(2.0, egui::Color32::from_rgb(110, 200, 110)),
|
||||
);
|
||||
}
|
||||
// Watched overlay
|
||||
if bulk_checked {
|
||||
ui.painter().rect_stroke(
|
||||
rect,
|
||||
4.0,
|
||||
egui::Stroke::new(3.0, egui::Color32::from_rgb(180, 130, 240)),
|
||||
);
|
||||
}
|
||||
if card.watched {
|
||||
ui.painter().rect_filled(
|
||||
egui::Rect::from_min_size(
|
||||
|
|
@ -866,7 +1057,7 @@ impl App {
|
|||
|
||||
ui.vertical(|ui| {
|
||||
let title_color = if card.watched {
|
||||
egui::Color32::from_gray(140)
|
||||
ui.visuals().weak_text_color()
|
||||
} else {
|
||||
ui.visuals().text_color()
|
||||
};
|
||||
|
|
@ -907,26 +1098,42 @@ impl App {
|
|||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if card.video_path.is_some() && ui.small_button("▶ Play").clicked() {
|
||||
play_card = true;
|
||||
}
|
||||
if let Some(pos) = card.resume_pos {
|
||||
if pos > 5.0 && ui.small_button(format!("⏩ {}", format_duration(pos))).clicked() {
|
||||
if self.bulk_mode {
|
||||
let chk_label = if bulk_checked { "☑" } else { "☐" };
|
||||
if ui.small_button(chk_label).clicked() {
|
||||
clicked_card = true; // handled below
|
||||
}
|
||||
} else {
|
||||
if card.video_path.is_some() && ui.small_button("▶ Play").clicked() {
|
||||
play_card = true;
|
||||
}
|
||||
}
|
||||
if ui.small_button("Details").clicked() {
|
||||
clicked_card = true;
|
||||
}
|
||||
let w_label = if card.watched { "✓" } else { "○" };
|
||||
if ui.small_button(w_label).on_hover_text("Toggle watched").clicked() {
|
||||
toggle_watched_card = true;
|
||||
if let Some(pos) = card.resume_pos {
|
||||
if pos > 5.0 && ui.small_button(format!("⏩ {}", format_duration(pos))).clicked() {
|
||||
play_card = true;
|
||||
}
|
||||
}
|
||||
if ui.small_button("Details").clicked() {
|
||||
clicked_card = true;
|
||||
}
|
||||
let w_label = if card.watched { "✓" } else { "○" };
|
||||
if ui.small_button(w_label).on_hover_text("Toggle watched").clicked() {
|
||||
toggle_watched_card = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if play_card {
|
||||
if self.bulk_mode {
|
||||
if clicked_card {
|
||||
let id = card.id.clone();
|
||||
if self.bulk_selected.contains(&id) {
|
||||
self.bulk_selected.remove(&id);
|
||||
} else {
|
||||
self.bulk_selected.insert(id);
|
||||
}
|
||||
}
|
||||
} else if play_card {
|
||||
if let Some(p) = card.video_path.clone() {
|
||||
let id = card.id.clone();
|
||||
self.play_with_tracking(&p, id);
|
||||
|
|
@ -949,18 +1156,33 @@ impl App {
|
|||
impl eframe::App for App {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.downloader.poll();
|
||||
self.check_notifications();
|
||||
|
||||
let any_running = self.downloader.any_running();
|
||||
if self.prev_any_running && !any_running && !self.downloader.jobs.is_empty() {
|
||||
// Auto-rescan when all downloads finish
|
||||
let was_running = self.prev_job_states.values().any(|&s| s == JobState::Running);
|
||||
if was_running && !any_running {
|
||||
self.rescan();
|
||||
}
|
||||
self.prev_any_running = any_running;
|
||||
|
||||
if any_running {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(250));
|
||||
}
|
||||
|
||||
// Poll mpv position updates from background tracker thread
|
||||
// Scheduled channel checks
|
||||
if self.config.scheduler.enabled && !any_running {
|
||||
let interval = std::time::Duration::from_secs(
|
||||
self.config.scheduler.interval_hours as u64 * 3600,
|
||||
);
|
||||
let due = self.last_scheduled_check
|
||||
.map_or(true, |t| t.elapsed() >= interval);
|
||||
if due {
|
||||
self.last_scheduled_check = Some(Instant::now());
|
||||
self.run_scheduled_check();
|
||||
}
|
||||
}
|
||||
|
||||
// Poll mpv position updates
|
||||
if let Some(rx) = &self.mpv_rx {
|
||||
while let Ok((video_id, pos)) = rx.try_recv() {
|
||||
let _ = self.db.set_position(&video_id, pos);
|
||||
|
|
@ -989,7 +1211,6 @@ fn spawn_mpv_tracker(sock_path: String, video_id: String, tx: std::sync::mpsc::S
|
|||
use std::os::unix::net::UnixStream;
|
||||
use std::time::Duration;
|
||||
|
||||
// Wait for mpv to create the socket (up to 10 seconds)
|
||||
let mut stream = None;
|
||||
for _ in 0..20 {
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
|
|
@ -1002,7 +1223,6 @@ fn spawn_mpv_tracker(sock_path: String, video_id: String, tx: std::sync::mpsc::S
|
|||
Some(s) => s,
|
||||
None => return,
|
||||
};
|
||||
|
||||
stream.set_read_timeout(Some(Duration::from_secs(3))).ok();
|
||||
|
||||
let mut buf = [0u8; 4096];
|
||||
|
|
@ -1029,10 +1249,7 @@ fn spawn_mpv_tracker(sock_path: String, video_id: String, tx: std::sync::mpsc::S
|
|||
}
|
||||
Err(e)
|
||||
if e.kind() == std::io::ErrorKind::WouldBlock
|
||||
|| e.kind() == std::io::ErrorKind::TimedOut =>
|
||||
{
|
||||
// no response yet, mpv may be paused — keep polling
|
||||
}
|
||||
|| e.kind() == std::io::ErrorKind::TimedOut => {}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
|
@ -1058,11 +1275,7 @@ fn format_duration(secs: f64) -> String {
|
|||
let h = secs / 3600;
|
||||
let m = (secs % 3600) / 60;
|
||||
let s = secs % 60;
|
||||
if h > 0 {
|
||||
format!("{h}:{m:02}:{s:02}")
|
||||
} else {
|
||||
format!("{m}:{s:02}")
|
||||
}
|
||||
if h > 0 { format!("{h}:{m:02}:{s:02}") } else { format!("{m}:{s:02}") }
|
||||
}
|
||||
|
||||
fn format_size(bytes: u64) -> String {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ pub struct Config {
|
|||
pub player: PlayerSection,
|
||||
#[serde(default)]
|
||||
pub ui: UiSection,
|
||||
#[serde(default)]
|
||||
pub scheduler: SchedulerSection,
|
||||
#[serde(default)]
|
||||
pub web: WebSection,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
|
|
@ -41,9 +45,37 @@ impl Default for UiSection {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct SchedulerSection {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_interval_hours")]
|
||||
pub interval_hours: u32,
|
||||
}
|
||||
|
||||
impl Default for SchedulerSection {
|
||||
fn default() -> Self {
|
||||
Self { enabled: false, interval_hours: default_interval_hours() }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct WebSection {
|
||||
#[serde(default = "default_web_port")]
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl Default for WebSection {
|
||||
fn default() -> Self {
|
||||
Self { port: default_web_port() }
|
||||
}
|
||||
}
|
||||
|
||||
fn default_player() -> String { "mpv".to_string() }
|
||||
fn default_browser() -> String { "firefox".to_string() }
|
||||
fn default_theme() -> String { "dark".to_string() }
|
||||
fn default_interval_hours() -> u32 { 24 }
|
||||
fn default_web_port() -> u16 { 8080 }
|
||||
|
||||
impl Config {
|
||||
pub fn load(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
|
|
@ -62,6 +94,8 @@ impl Config {
|
|||
backup: BackupSection { directory: dir },
|
||||
player: PlayerSection::default(),
|
||||
ui: UiSection::default(),
|
||||
scheduler: SchedulerSection::default(),
|
||||
web: WebSection::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
19
src/main.rs
19
src/main.rs
|
|
@ -7,8 +7,27 @@ mod downloader;
|
|||
mod library;
|
||||
mod theme;
|
||||
mod tray;
|
||||
mod web;
|
||||
|
||||
fn main() -> eframe::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
// --web [port] → run the web interface instead of the GUI
|
||||
if let Some(pos) = args.iter().position(|a| a == "--web") {
|
||||
let mut cfg = {
|
||||
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
||||
config::Config::load(&cwd.join("config.toml"))
|
||||
.unwrap_or_else(|_| config::Config::default_with_dir(cwd.join("channels")))
|
||||
};
|
||||
// Override port if provided after --web
|
||||
if let Some(port_str) = args.get(pos + 1) {
|
||||
if let Ok(port) = port_str.parse::<u16>() {
|
||||
cfg.web.port = port;
|
||||
}
|
||||
}
|
||||
web::run(cfg);
|
||||
}
|
||||
|
||||
let native_options = eframe::NativeOptions {
|
||||
viewport: eframe::egui::ViewportBuilder::default()
|
||||
.with_inner_size([1280.0, 820.0])
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ fn trans() -> egui::Visuals {
|
|||
v.selection.bg_fill = hex(0x55cdfc);
|
||||
v.selection.stroke = Stroke::new(1.0, hex(0x2288cc));
|
||||
v.hyperlink_color = hex(0x0055aa);
|
||||
v.override_text_color = Some(hex(0x222222));
|
||||
v.override_text_color = Some(hex(0xcc0066)); // hot pink text throughout
|
||||
v.widgets.noninteractive.bg_fill = hex(0xfce8f2);
|
||||
v.widgets.noninteractive.weak_bg_fill = hex(0xfdf4f8);
|
||||
v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0x444444));
|
||||
|
|
@ -161,7 +161,7 @@ fn emo_scene_queen() -> egui::Visuals {
|
|||
v.selection.bg_fill = hex(0x39ff14);
|
||||
v.selection.stroke = Stroke::new(1.0, hex(0x66ff44));
|
||||
v.hyperlink_color = hex(0xff00ff);
|
||||
v.override_text_color = Some(hex(0xd0d0ff));
|
||||
v.override_text_color = None; // let widget fg_stroke handle per-state text color
|
||||
v.widgets.noninteractive.bg_fill = hex(0x111128);
|
||||
v.widgets.noninteractive.weak_bg_fill = hex(0x0d0d20);
|
||||
v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xc8c8ff));
|
||||
|
|
|
|||
503
src/web.rs
Normal file
503
src/web.rs
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
//! Web interface — run with `--web [PORT]` instead of the GUI.
|
||||
//!
|
||||
//! Serves a browser-based UI on http://localhost:PORT that mirrors the
|
||||
//! desktop app's core features: browse library, start downloads, track
|
||||
//! progress, toggle watched.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{header, StatusCode},
|
||||
response::{IntoResponse, Sse},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::broadcast;
|
||||
use tokio_stream::wrappers::BroadcastStream;
|
||||
use tokio_stream::StreamExt as _;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::database::Database;
|
||||
use crate::downloader::{detect_url_kind, Downloader, JobState};
|
||||
use crate::library;
|
||||
|
||||
// ── Shared state ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct JobSnapshot {
|
||||
pub label: String,
|
||||
pub url: String,
|
||||
pub state: &'static str,
|
||||
pub progress: f32,
|
||||
pub last_line: String,
|
||||
}
|
||||
|
||||
pub struct WebState {
|
||||
pub library: Mutex<Vec<library::Channel>>,
|
||||
pub downloader: Mutex<Downloader>,
|
||||
pub watched: Mutex<HashSet<String>>,
|
||||
pub db_path: PathBuf,
|
||||
pub channels_root: PathBuf,
|
||||
pub browser: String,
|
||||
/// Broadcast channel for SSE progress events
|
||||
pub progress_tx: broadcast::Sender<String>,
|
||||
}
|
||||
|
||||
impl WebState {
|
||||
pub fn job_snapshots(dl: &Downloader) -> Vec<JobSnapshot> {
|
||||
dl.jobs
|
||||
.iter()
|
||||
.map(|j| JobSnapshot {
|
||||
label: j.label.clone(),
|
||||
url: j.url.clone(),
|
||||
state: match j.state {
|
||||
JobState::Running => "running",
|
||||
JobState::Done => "done",
|
||||
JobState::Failed => "failed",
|
||||
},
|
||||
progress: j.progress,
|
||||
last_line: j.log.last().cloned().unwrap_or_default(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
// ── API types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LibraryResponse {
|
||||
channels: Vec<ChannelInfo>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ChannelInfo {
|
||||
name: String,
|
||||
total_videos: usize,
|
||||
size_bytes: u64,
|
||||
subscriber_count: Option<u64>,
|
||||
uploader: Option<String>,
|
||||
channel_url: Option<String>,
|
||||
playlists: Vec<PlaylistInfo>,
|
||||
videos: Vec<VideoInfo>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct PlaylistInfo {
|
||||
name: String,
|
||||
videos: Vec<VideoInfo>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct VideoInfo {
|
||||
id: String,
|
||||
title: String,
|
||||
duration_secs: Option<f64>,
|
||||
file_size: Option<u64>,
|
||||
has_video: bool,
|
||||
has_live_chat: bool,
|
||||
watched: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct StartDownloadRequest {
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ProgressResponse {
|
||||
jobs: Vec<JobSnapshot>,
|
||||
}
|
||||
|
||||
// ── Route handlers ────────────────────────────────────────────────────────────
|
||||
|
||||
async fn get_index() -> impl IntoResponse {
|
||||
(
|
||||
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
|
||||
HTML_UI,
|
||||
)
|
||||
}
|
||||
|
||||
async fn get_library(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
||||
let lib = state.library.lock().unwrap();
|
||||
let watched = state.watched.lock().unwrap();
|
||||
|
||||
let channels = lib
|
||||
.iter()
|
||||
.map(|ch| {
|
||||
let size_bytes: u64 = ch
|
||||
.videos
|
||||
.iter()
|
||||
.chain(ch.playlists.iter().flat_map(|p| p.videos.iter()))
|
||||
.filter_map(|v| v.file_size)
|
||||
.sum();
|
||||
|
||||
let to_info = |v: &library::Video| VideoInfo {
|
||||
id: v.id.clone(),
|
||||
title: v.title.clone(),
|
||||
duration_secs: v.duration_secs,
|
||||
file_size: v.file_size,
|
||||
has_video: v.video_path.is_some(),
|
||||
has_live_chat: v.has_live_chat,
|
||||
watched: watched.contains(&v.id),
|
||||
};
|
||||
|
||||
ChannelInfo {
|
||||
name: ch.name.clone(),
|
||||
total_videos: ch.total_videos(),
|
||||
size_bytes,
|
||||
subscriber_count: ch.meta.as_ref().and_then(|m| m.subscriber_count),
|
||||
uploader: ch.meta.as_ref().and_then(|m| m.uploader.clone()),
|
||||
channel_url: ch.meta.as_ref().and_then(|m| m.channel_url.clone()),
|
||||
playlists: ch
|
||||
.playlists
|
||||
.iter()
|
||||
.map(|p| PlaylistInfo {
|
||||
name: p.name.clone(),
|
||||
videos: p.videos.iter().map(to_info).collect(),
|
||||
})
|
||||
.collect(),
|
||||
videos: ch.videos.iter().map(to_info).collect(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Json(LibraryResponse { channels })
|
||||
}
|
||||
|
||||
async fn post_download(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(body): Json<StartDownloadRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let url = body.url.trim().to_string();
|
||||
if url.is_empty() {
|
||||
return (StatusCode::BAD_REQUEST, "empty URL").into_response();
|
||||
}
|
||||
let kind = detect_url_kind(&url);
|
||||
{
|
||||
let mut dl = state.downloader.lock().unwrap();
|
||||
dl.start(url, &kind);
|
||||
}
|
||||
(StatusCode::ACCEPTED, "ok").into_response()
|
||||
}
|
||||
|
||||
async fn get_progress(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
||||
let dl = state.downloader.lock().unwrap();
|
||||
Json(ProgressResponse { jobs: WebState::job_snapshots(&dl) })
|
||||
}
|
||||
|
||||
async fn post_watched(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Path(video_id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let db = match Database::open(&state.db_path) {
|
||||
Ok(d) => d,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
};
|
||||
let mut watched = state.watched.lock().unwrap();
|
||||
let now_watched = !watched.contains(&video_id);
|
||||
if let Err(e) = db.set_watched(&video_id, now_watched) {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
|
||||
}
|
||||
if now_watched {
|
||||
watched.insert(video_id);
|
||||
} else {
|
||||
watched.remove(&video_id);
|
||||
}
|
||||
(StatusCode::OK, if now_watched { "watched" } else { "unwatched" }).into_response()
|
||||
}
|
||||
|
||||
async fn get_events(State(state): State<Arc<WebState>>) -> Sse<impl tokio_stream::Stream<Item = Result<axum::response::sse::Event, std::convert::Infallible>>> {
|
||||
let rx = state.progress_tx.subscribe();
|
||||
let stream = BroadcastStream::new(rx).filter_map(|msg| {
|
||||
msg.ok().map(|data| {
|
||||
Ok(axum::response::sse::Event::default().data(data))
|
||||
})
|
||||
});
|
||||
Sse::new(stream).keep_alive(axum::response::sse::KeepAlive::default())
|
||||
}
|
||||
|
||||
async fn post_rescan(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
||||
let root = state.channels_root.clone();
|
||||
let new_lib = library::scan_channels(&root);
|
||||
*state.library.lock().unwrap() = new_lib;
|
||||
(StatusCode::OK, "rescanned")
|
||||
}
|
||||
|
||||
// ── Server entry point ────────────────────────────────────────────────────────
|
||||
|
||||
pub fn run(config: Config) -> ! {
|
||||
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
|
||||
rt.block_on(async move {
|
||||
serve(config).await;
|
||||
});
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn serve(config: Config) {
|
||||
let channels_root = config.backup.directory.clone();
|
||||
let db_path = channels_root.join("yt-offline.db");
|
||||
let library = library::scan_channels(&channels_root);
|
||||
|
||||
let db = Database::open(&db_path).expect("web: open db");
|
||||
let watched = db.get_watched().unwrap_or_default();
|
||||
|
||||
let browser = config.player.browser.clone();
|
||||
let downloader = Downloader::new(channels_root.clone(), browser.clone());
|
||||
|
||||
let (progress_tx, _) = broadcast::channel::<String>(64);
|
||||
|
||||
let state = Arc::new(WebState {
|
||||
library: Mutex::new(library),
|
||||
downloader: Mutex::new(downloader),
|
||||
watched: Mutex::new(watched),
|
||||
db_path,
|
||||
channels_root,
|
||||
browser,
|
||||
progress_tx: progress_tx.clone(),
|
||||
});
|
||||
|
||||
// Background task: poll downloader and broadcast SSE events
|
||||
let poll_state = Arc::clone(&state);
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
{
|
||||
let mut dl = poll_state.downloader.lock().unwrap();
|
||||
dl.poll();
|
||||
let snap = WebState::job_snapshots(&dl);
|
||||
drop(dl);
|
||||
if let Ok(json) = serde_json::to_string(&snap) {
|
||||
let _ = poll_state.progress_tx.send(json);
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(get_index))
|
||||
.route("/api/library", get(get_library))
|
||||
.route("/api/download", post(post_download))
|
||||
.route("/api/progress", get(get_progress))
|
||||
.route("/api/events", get(get_events))
|
||||
.route("/api/watched/:id", post(post_watched))
|
||||
.route("/api/rescan", post(post_rescan))
|
||||
.with_state(state);
|
||||
|
||||
let port = config.web.port;
|
||||
let addr = format!("0.0.0.0:{port}");
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await.expect("bind");
|
||||
println!("yt-offline web UI: http://localhost:{port}");
|
||||
axum::serve(listener, app).await.expect("serve");
|
||||
}
|
||||
|
||||
// ── Embedded HTML/JS UI ───────────────────────────────────────────────────────
|
||||
|
||||
const HTML_UI: &str = r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>yt-offline</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #1a1a2e; --panel: #16213e; --card: #0f3460;
|
||||
--accent: #e94560; --text: #eee; --muted: #aaa; --border: #334;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: var(--bg); color: var(--text); font: 14px/1.5 system-ui, sans-serif; display: flex; flex-direction: column; height: 100vh; }
|
||||
header { background: var(--panel); padding: 10px 16px; display: flex; gap: 12px; align-items: center; border-bottom: 1px solid var(--border); }
|
||||
header h1 { font-size: 1.1em; }
|
||||
header input { flex: 1; background: var(--bg); border: 1px solid var(--border); color: var(--text); padding: 6px 10px; border-radius: 4px; }
|
||||
button { background: var(--accent); color: #fff; border: none; padding: 6px 14px; border-radius: 4px; cursor: pointer; font-size: 13px; }
|
||||
button:hover { opacity: 0.85; }
|
||||
button.muted { background: var(--card); }
|
||||
main { display: flex; flex: 1; overflow: hidden; }
|
||||
aside { width: 220px; background: var(--panel); border-right: 1px solid var(--border); overflow-y: auto; padding: 8px 0; flex-shrink: 0; }
|
||||
aside h3 { padding: 6px 12px; font-size: 0.75em; text-transform: uppercase; color: var(--muted); letter-spacing: 0.08em; }
|
||||
.ch-item { padding: 6px 12px; cursor: pointer; font-size: 13px; border-left: 3px solid transparent; }
|
||||
.ch-item:hover { background: var(--card); }
|
||||
.ch-item.active { border-left-color: var(--accent); background: var(--card); }
|
||||
.ch-sub { padding: 4px 12px 4px 24px; font-size: 12px; color: var(--muted); cursor: pointer; }
|
||||
.ch-sub:hover { color: var(--text); }
|
||||
section#content { flex: 1; overflow-y: auto; padding: 12px; }
|
||||
.video-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
|
||||
.video-card { background: var(--card); border-radius: 6px; overflow: hidden; cursor: pointer; border: 2px solid transparent; transition: border-color 0.15s; }
|
||||
.video-card:hover { border-color: var(--accent); }
|
||||
.video-card.watched { opacity: 0.55; }
|
||||
.thumb { width: 100%; aspect-ratio: 16/9; background: #222; display: flex; align-items: center; justify-content: center; font-size: 2em; color: #555; }
|
||||
.card-body { padding: 8px; }
|
||||
.card-title { font-size: 13px; font-weight: 600; margin-bottom: 4px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.card-meta { font-size: 11px; color: var(--muted); display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.card-actions { display: flex; gap: 6px; margin-top: 6px; }
|
||||
.card-actions button { font-size: 11px; padding: 3px 8px; }
|
||||
#download-bar { background: var(--panel); border-top: 1px solid var(--border); padding: 10px 16px; display: flex; gap: 8px; align-items: center; }
|
||||
#download-bar input { flex: 1; background: var(--bg); border: 1px solid var(--border); color: var(--text); padding: 6px 10px; border-radius: 4px; }
|
||||
#jobs { background: var(--panel); border-top: 1px solid var(--border); }
|
||||
.job { padding: 6px 16px; display: flex; align-items: center; gap: 10px; font-size: 12px; border-bottom: 1px solid var(--border); }
|
||||
.job-state { font-weight: 700; min-width: 50px; }
|
||||
.job-state.running { color: #facc15; }
|
||||
.job-state.done { color: #4ade80; }
|
||||
.job-state.failed { color: #f87171; }
|
||||
progress { flex: 1; height: 6px; accent-color: var(--accent); }
|
||||
#status { font-size: 12px; color: var(--muted); padding: 0 16px; }
|
||||
.badge { background: var(--accent); color: #fff; border-radius: 10px; padding: 1px 7px; font-size: 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>yt-offline</h1>
|
||||
<input type="search" id="search" placeholder="Filter by title or ID…" oninput="filterCards()">
|
||||
<button class="muted" onclick="rescan()">⟳ Rescan</button>
|
||||
<span id="status"></span>
|
||||
</header>
|
||||
<main>
|
||||
<aside id="sidebar"></aside>
|
||||
<section id="content"><div class="video-grid" id="grid"></div></section>
|
||||
</main>
|
||||
<div id="jobs"></div>
|
||||
<div id="download-bar">
|
||||
<input type="url" id="dl-url" placeholder="YouTube URL to download…" onkeydown="if(event.key==='Enter')startDownload()">
|
||||
<button onclick="startDownload()">⬇ Download</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let library = [];
|
||||
let activeChannel = null;
|
||||
let activePlaylist = null;
|
||||
|
||||
async function loadLibrary() {
|
||||
const r = await fetch('/api/library');
|
||||
library = (await r.json()).channels;
|
||||
renderSidebar();
|
||||
renderGrid();
|
||||
}
|
||||
|
||||
function renderSidebar() {
|
||||
const el = document.getElementById('sidebar');
|
||||
let total = library.reduce((s, c) => s + c.total_videos, 0);
|
||||
let html = `<h3>Channels</h3>
|
||||
<div class="ch-item ${!activeChannel ? 'active' : ''}" onclick="selectChannel(null)">⊞ All (${total})</div>`;
|
||||
for (const ch of library) {
|
||||
const active = activeChannel === ch.name && !activePlaylist;
|
||||
html += `<div class="ch-item ${active ? 'active' : ''}" onclick="selectChannel('${esc(ch.name)}')">
|
||||
${esc(ch.name)} <span style="color:var(--muted);font-size:11px">(${ch.total_videos})</span>
|
||||
</div>`;
|
||||
if (activeChannel === ch.name && ch.playlists.length) {
|
||||
for (const pl of ch.playlists) {
|
||||
const ap = activePlaylist === pl.name;
|
||||
html += `<div class="ch-sub ${ap ? 'active' : ''}" onclick="selectPlaylist('${esc(ch.name)}','${esc(pl.name)}')">└ ${esc(pl.name)} (${pl.videos.length})</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
function selectChannel(name) {
|
||||
activeChannel = name; activePlaylist = null;
|
||||
renderSidebar(); renderGrid();
|
||||
}
|
||||
function selectPlaylist(ch, pl) {
|
||||
activeChannel = ch; activePlaylist = pl;
|
||||
renderSidebar(); renderGrid();
|
||||
}
|
||||
|
||||
function currentVideos() {
|
||||
const q = document.getElementById('search').value.toLowerCase();
|
||||
let videos = [];
|
||||
for (const ch of library) {
|
||||
if (activeChannel && ch.name !== activeChannel) continue;
|
||||
const pool = activePlaylist
|
||||
? (ch.playlists.find(p => p.name === activePlaylist)?.videos || [])
|
||||
: [...ch.videos, ...ch.playlists.flatMap(p => p.videos)];
|
||||
for (const v of pool) {
|
||||
if (!q || v.title.toLowerCase().includes(q) || v.id.includes(q))
|
||||
videos.push({...v, channel: ch.name});
|
||||
}
|
||||
}
|
||||
return videos;
|
||||
}
|
||||
|
||||
function filterCards() { renderGrid(); }
|
||||
|
||||
function renderGrid() {
|
||||
const videos = currentVideos();
|
||||
document.getElementById('status').textContent = `${videos.length} videos`;
|
||||
const grid = document.getElementById('grid');
|
||||
grid.innerHTML = videos.map(v => `
|
||||
<div class="video-card ${v.watched ? 'watched' : ''}" id="card-${v.id}">
|
||||
<div class="thumb">▶</div>
|
||||
<div class="card-body">
|
||||
<div class="card-title">${esc(v.title)}</div>
|
||||
<div class="card-meta">
|
||||
<span>${esc(v.channel)}</span>
|
||||
${v.duration_secs ? `<span>${fmtDur(v.duration_secs)}</span>` : ''}
|
||||
${v.file_size ? `<span>${fmtSize(v.file_size)}</span>` : ''}
|
||||
${v.has_live_chat ? '<span>💬</span>' : ''}
|
||||
${!v.has_video ? '<span style="color:#f87171">no file</span>' : ''}
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button onclick="toggleWatched('${v.id}')">${v.watched ? '✓ Watched' : '○ Watch'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
async function toggleWatched(id) {
|
||||
await fetch(`/api/watched/${id}`, {method:'POST'});
|
||||
await loadLibrary();
|
||||
}
|
||||
|
||||
async function startDownload() {
|
||||
const url = document.getElementById('dl-url').value.trim();
|
||||
if (!url) return;
|
||||
await fetch('/api/download', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({url})});
|
||||
document.getElementById('dl-url').value = '';
|
||||
}
|
||||
|
||||
async function rescan() {
|
||||
await fetch('/api/rescan', {method:'POST'});
|
||||
await loadLibrary();
|
||||
}
|
||||
|
||||
// SSE progress
|
||||
const es = new EventSource('/api/events');
|
||||
es.onmessage = e => {
|
||||
try {
|
||||
const jobs = JSON.parse(e.data);
|
||||
renderJobs(jobs);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
function renderJobs(jobs) {
|
||||
if (!jobs.length) { document.getElementById('jobs').innerHTML = ''; return; }
|
||||
document.getElementById('jobs').innerHTML = jobs.map(j => `
|
||||
<div class="job">
|
||||
<span class="job-state ${j.state}">${j.state}</span>
|
||||
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(j.label)} — ${esc(j.url)}</span>
|
||||
${j.state==='running' ? `<progress value="${j.progress}" max="1"></progress>` : ''}
|
||||
</div>`).join('');
|
||||
// Rescan library when all done
|
||||
if (jobs.length && jobs.every(j => j.state !== 'running')) loadLibrary();
|
||||
}
|
||||
|
||||
function fmtDur(s) {
|
||||
s = Math.floor(s);
|
||||
const h = Math.floor(s/3600), m = Math.floor((s%3600)/60), sec = s%60;
|
||||
return h ? `${h}:${p(m)}:${p(sec)}` : `${m}:${p(sec)}`;
|
||||
}
|
||||
function fmtSize(b) {
|
||||
if (b>=1073741824) return (b/1073741824).toFixed(1)+' GB';
|
||||
if (b>=1048576) return Math.round(b/1048576)+' MB';
|
||||
return Math.round(b/1024)+' KB';
|
||||
}
|
||||
function p(n) { return String(n).padStart(2,'0'); }
|
||||
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
loadLibrary();
|
||||
</script>
|
||||
</body>
|
||||
</html>"#;
|
||||
Loading…
Add table
Add a link
Reference in a new issue