diff --git a/Cargo.lock b/Cargo.lock index 9de43d9..9e9e9af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -105,18 +105,45 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.19.1" @@ -250,13 +277,22 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crossterm" version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags", + "bitflags 2.10.0", "crossterm_winapi", "mio", "parking_lot", @@ -275,6 +311,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "darling" version = "0.23.0" @@ -309,6 +355,16 @@ dependencies = [ "syn", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dirs" version = "5.0.1" @@ -401,15 +457,17 @@ version = "0.1.1" dependencies = [ "anyhow", "async-trait", + "base64 0.22.1", "chrono", "clap", "crossterm", "dirs", "feed-rs", "futures", + "oauth2", "open", "ratatui", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "tempfile", @@ -550,6 +608,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -557,8 +625,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -573,6 +643,25 @@ dependencies = [ "wasip2", ] +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.13" @@ -584,7 +673,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.4.0", "indexmap", "slab", "tokio", @@ -615,6 +704,17 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.0" @@ -625,6 +725,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -632,7 +743,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -643,8 +754,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -654,6 +765,36 @@ 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 = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.8.1" @@ -664,9 +805,9 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", - "http", - "http-body", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", "httparse", "itoa", "pin-project-lite", @@ -676,19 +817,33 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + [[package]] name = "hyper-rustls" version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http", - "hyper", + "http 1.4.0", + "hyper 1.8.1", "hyper-util", - "rustls", + "rustls 0.23.36", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", ] @@ -700,7 +855,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper", + "hyper 1.8.1", "hyper-util", "native-tls", "tokio", @@ -714,20 +869,20 @@ version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", "futures-util", - "http", - "http-body", - "hyper", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.8.1", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", - "system-configuration", + "socket2 0.6.1", + "system-configuration 0.6.1", "tokio", "tower-service", "tracing", @@ -976,7 +1131,7 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags", + "bitflags 2.10.0", "libc", ] @@ -1081,6 +1236,26 @@ dependencies = [ "autocfg", ] +[[package]] +name = "oauth2" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f" +dependencies = [ + "base64 0.13.1", + "chrono", + "getrandom 0.2.17", + "http 0.2.12", + "rand", + "reqwest 0.11.27", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror", + "url", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1110,7 +1285,7 @@ version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags", + "bitflags 2.10.0", "cfg-if", "foreign-types", "libc", @@ -1222,6 +1397,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.105" @@ -1256,13 +1440,43 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "ratatui" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags", + "bitflags 2.10.0", "cassowary", "compact_str", "crossterm", @@ -1283,7 +1497,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.10.0", ] [[package]] @@ -1326,22 +1540,63 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-rustls 0.24.2", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls 0.21.12", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration 0.5.1", + "tokio", + "tokio-rustls 0.24.1", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", + "winreg", +] + [[package]] name = "reqwest" version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", - "h2", - "http", - "http-body", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.8.1", + "hyper-rustls 0.27.7", "hyper-tls", "hyper-util", "js-sys", @@ -1354,7 +1609,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tokio-native-tls", "tower", @@ -1386,7 +1641,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -1399,13 +1654,25 @@ version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys 0.11.0", "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + [[package]] name = "rustls" version = "0.23.36" @@ -1414,11 +1681,20 @@ checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ "once_cell", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.9", "subtle", "zeroize", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -1428,6 +1704,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.103.9" @@ -1466,13 +1752,23 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "security-framework" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", + "bitflags 2.10.0", "core-foundation", "core-foundation-sys", "libc", @@ -1532,6 +1828,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_spanned" version = "0.6.9" @@ -1553,6 +1860,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1614,6 +1932,16 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.1" @@ -1681,6 +2009,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -1701,15 +2035,36 @@ dependencies = [ "syn", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys 0.5.0", +] + [[package]] name = "system-configuration" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags", + "bitflags 2.10.0", "core-foundation", - "system-configuration-sys", + "system-configuration-sys 0.6.0", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", ] [[package]] @@ -1788,7 +2143,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.1", "tokio-macros", "windows-sys 0.61.2", ] @@ -1814,13 +2169,23 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.36", "tokio", ] @@ -1887,7 +2252,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower-layer", "tower-service", @@ -1899,11 +2264,11 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.10.0", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "iri-string", "pin-project-lite", "tower", @@ -1948,6 +2313,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -2043,6 +2414,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "want" version = "0.3.1" @@ -2136,6 +2513,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + [[package]] name = "winapi" version = "0.3.9" @@ -2468,6 +2851,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -2503,6 +2896,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7456cf00f0685ad319c5b1693f291a650eaf345e941d082fc4e03df8a03996ac" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1328722bbf2115db7e19d69ebcc15e795719e2d66b60827c6a69a117365e37a0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.6" diff --git a/Cargo.toml b/Cargo.toml index 0961d08..bd23f2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,8 @@ futures = "0.3" urlencoding = "2" open = "5" textwrap = "0.16" +oauth2 = "4" +base64 = "0.22" [dev-dependencies] tempfile = "3" diff --git a/src/app.rs b/src/app.rs index 6558248..fdccbd0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -18,9 +18,8 @@ use crossterm::{ use ratatui::{ backend::CrosstermBackend, layout::{Constraint, Direction, Layout}, - Frame, - Terminal, - prelude::Rect + prelude::Rect, + Frame, Terminal, }; use std::io::{self, Stdout}; use std::path::PathBuf; @@ -409,8 +408,8 @@ impl App { fn render_status_message(&self, frame: &mut Frame, area: Rect) { if let Some((message, _)) = &self.status_message { - use ratatui::widgets::{Block, Borders, Clear, Paragraph}; use ratatui::style::{Color, Style}; + use ratatui::widgets::{Block, Borders, Clear, Paragraph}; let width = (message.len() + 4).min(area.width as usize) as u16; let x = area.width.saturating_sub(width).saturating_sub(2); diff --git a/src/config.rs b/src/config.rs index d59781b..1998512 100644 --- a/src/config.rs +++ b/src/config.rs @@ -180,11 +180,33 @@ fn default_max_commits() -> usize { 10 } +/// YouTube feed types for personalized content +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum YoutubeFeedType { + /// Public search/channel videos (uses API key) + Public, + /// User's subscriptions feed (requires OAuth) + Subscriptions, + /// User's liked videos playlist (requires OAuth) + LikedVideos, + /// User's Watch Later playlist (requires OAuth) + WatchLater, +} + +impl Default for YoutubeFeedType { + fn default() -> Self { + YoutubeFeedType::Public + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct YoutubeConfig { #[serde(default = "default_youtube_title")] pub title: String, - pub api_key: String, + /// API key for public API access (required for public feed type) + #[serde(default)] + pub api_key: Option, #[serde(default)] pub channels: Vec, #[serde(default)] @@ -192,6 +214,15 @@ pub struct YoutubeConfig { #[serde(default = "default_max_videos")] pub max_videos: usize, pub position: Position, + /// OAuth client ID for personalized feeds + #[serde(default)] + pub client_id: Option, + /// OAuth client secret for personalized feeds + #[serde(default)] + pub client_secret: Option, + /// Type of feed to display + #[serde(default)] + pub feed_type: YoutubeFeedType, } fn default_youtube_title() -> String { diff --git a/src/feeds/mod.rs b/src/feeds/mod.rs index 4a505ab..050b0f4 100644 --- a/src/feeds/mod.rs +++ b/src/feeds/mod.rs @@ -4,6 +4,7 @@ pub mod rss; pub mod sports; pub mod stocks; pub mod youtube; +pub mod youtube_oauth; use anyhow::Result; use async_trait::async_trait; diff --git a/src/feeds/youtube.rs b/src/feeds/youtube.rs index 5e2982a..8e222ba 100644 --- a/src/feeds/youtube.rs +++ b/src/feeds/youtube.rs @@ -1,4 +1,6 @@ +use super::youtube_oauth::YouTubeOAuth; use super::{FeedData, FeedFetcher, YoutubeVideo}; +use crate::config::YoutubeFeedType; use anyhow::{anyhow, Result}; use async_trait::async_trait; use serde::Deserialize; @@ -6,11 +8,13 @@ use serde::Deserialize; const YOUTUBE_API_BASE: &str = "https://www.googleapis.com/youtube/v3"; pub struct YoutubeFetcher { - api_key: String, + api_key: Option, channels: Vec, search_query: Option, max_videos: usize, client: reqwest::Client, + feed_type: YoutubeFeedType, + oauth: Option, } #[derive(Debug, Deserialize)] @@ -91,27 +95,45 @@ struct ContentDetails { impl YoutubeFetcher { pub fn new( - api_key: String, + api_key: Option, channels: Vec, search_query: Option, max_videos: usize, + feed_type: YoutubeFeedType, + client_id: Option, + client_secret: Option, ) -> Self { + let oauth = match (&client_id, &client_secret) { + (Some(id), Some(secret)) => Some(YouTubeOAuth::new(id.clone(), secret.clone())), + _ => None, + }; + Self { api_key, channels, search_query, max_videos, client: reqwest::Client::new(), + feed_type, + oauth, } } + /// Get the API key, required for public API access + fn get_api_key(&self) -> Result<&str> { + self.api_key + .as_deref() + .ok_or_else(|| anyhow!("API key required for public YouTube access")) + } + async fn search_videos(&self, query: &str) -> Result> { + let api_key = self.get_api_key()?; let url = format!( "{}/search?part=snippet&q={}&type=video&maxResults={}&key={}", YOUTUBE_API_BASE, urlencoding::encode(query), self.max_videos, - self.api_key + api_key ); let response = self.client.get(&url).send().await?; @@ -148,9 +170,10 @@ impl YoutubeFetcher { } async fn get_channel_videos(&self, channel_id: &str) -> Result> { + let api_key = self.get_api_key()?; let url = format!( "{}/search?part=snippet&channelId={}&type=video&order=date&maxResults={}&key={}", - YOUTUBE_API_BASE, channel_id, self.max_videos, self.api_key + YOUTUBE_API_BASE, channel_id, self.max_videos, api_key ); let response = self.client.get(&url).send().await?; @@ -188,12 +211,43 @@ impl YoutubeFetcher { async fn get_video_details(&self, video_ids: &[String]) -> Result> { let ids_param = video_ids.join(","); - let url = format!( - "{}/videos?part=snippet,statistics,contentDetails&id={}&key={}", - YOUTUBE_API_BASE, ids_param, self.api_key - ); - let response = self.client.get(&url).send().await?; + // Use OAuth token if available, otherwise API key + let url = if let Some(ref oauth) = self.oauth { + if oauth.has_valid_tokens() { + format!( + "{}/videos?part=snippet,statistics,contentDetails&id={}", + YOUTUBE_API_BASE, ids_param + ) + } else { + let api_key = self.get_api_key()?; + format!( + "{}/videos?part=snippet,statistics,contentDetails&id={}&key={}", + YOUTUBE_API_BASE, ids_param, api_key + ) + } + } else { + let api_key = self.get_api_key()?; + format!( + "{}/videos?part=snippet,statistics,contentDetails&id={}&key={}", + YOUTUBE_API_BASE, ids_param, api_key + ) + }; + + let response = if let Some(ref oauth) = self.oauth { + if oauth.has_valid_tokens() { + let token = oauth.get_access_token().await?; + self.client + .get(&url) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await? + } else { + self.client.get(&url).send().await? + } + } else { + self.client.get(&url).send().await? + }; if !response.status().is_success() { let status = response.status(); @@ -239,42 +293,354 @@ impl YoutubeFetcher { }) .collect()) } -} -#[async_trait] -impl FeedFetcher for YoutubeFetcher { - async fn fetch(&self) -> Result { - let mut all_videos = Vec::new(); + /// Fetch videos from user's subscriptions (requires OAuth) + async fn get_subscriptions_feed(&self) -> Result> { + let oauth = self.oauth.as_ref().ok_or_else(|| { + anyhow!("OAuth not configured. Set client_id and client_secret in config.") + })?; - // Fetch from search query if provided - if let Some(query) = &self.search_query { - match self.search_videos(query).await { - Ok(mut videos) => all_videos.append(&mut videos), - Err(e) => return Ok(FeedData::Error(format!("Search error: {}", e))), - } + let token = oauth.get_access_token().await?; + + // First, get the user's subscribed channels + let subs_url = format!( + "{}/subscriptions?part=snippet&mine=true&maxResults=25", + YOUTUBE_API_BASE + ); + + let response = self + .client + .get(&subs_url) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + return Err(anyhow!( + "YouTube API error (status {}): {}", + status, + error_text + )); } - // Fetch from channels - for channel_id in &self.channels { - match self.get_channel_videos(channel_id).await { + #[derive(Deserialize)] + struct SubscriptionsResponse { + items: Vec, + } + #[derive(Deserialize)] + struct SubscriptionItem { + snippet: SubscriptionSnippet, + } + #[derive(Deserialize)] + struct SubscriptionSnippet { + #[serde(rename = "resourceId")] + resource_id: ResourceId, + } + #[derive(Deserialize)] + struct ResourceId { + #[serde(rename = "channelId")] + channel_id: String, + } + + let subs_response: SubscriptionsResponse = response.json().await?; + let channel_ids: Vec = subs_response + .items + .into_iter() + .map(|item| item.snippet.resource_id.channel_id) + .collect(); + + if channel_ids.is_empty() { + return Ok(vec![]); + } + + // Get recent videos from subscribed channels using activities endpoint + let mut all_videos = Vec::new(); + for channel_id in channel_ids.iter().take(10) { + // Limit to avoid quota issues + match self.get_channel_videos_oauth(&token, channel_id).await { Ok(mut videos) => all_videos.append(&mut videos), - Err(e) => { - eprintln!("Error fetching channel {}: {}", channel_id, e); - continue; - } + Err(_) => continue, } } - // Limit total videos + all_videos.sort_by(|a, b| b.published.cmp(&a.published)); // Most recent first all_videos.truncate(self.max_videos); + Ok(all_videos) + } + + /// Get channel videos using OAuth token + async fn get_channel_videos_oauth( + &self, + token: &str, + channel_id: &str, + ) -> Result> { + let url = format!( + "{}/search?part=snippet&channelId={}&type=video&order=date&maxResults=5", + YOUTUBE_API_BASE, channel_id + ); + + let response = self + .client + .get(&url) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await?; + + if !response.status().is_success() { + return Err(anyhow!("Failed to fetch channel videos")); + } + + let search_response: YoutubeSearchResponse = response.json().await?; + let video_ids: Vec = search_response + .items + .iter() + .filter_map(|item| { + if let VideoId::Video { video_id } = &item.id { + Some(video_id.clone()) + } else { + None + } + }) + .collect(); + + if video_ids.is_empty() { + return Ok(vec![]); + } + + self.get_video_details(&video_ids).await + } + + /// Fetch user's liked videos playlist (requires OAuth) + async fn get_liked_videos(&self) -> Result> { + let oauth = self.oauth.as_ref().ok_or_else(|| { + anyhow!("OAuth not configured. Set client_id and client_secret in config.") + })?; + + let token = oauth.get_access_token().await?; + + // The liked videos playlist ID is "LL" for the authenticated user + let url = format!( + "{}/playlistItems?part=snippet&playlistId=LL&maxResults={}", + YOUTUBE_API_BASE, self.max_videos + ); + + let response = self + .client + .get(&url) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await?; - if all_videos.is_empty() && self.search_query.is_none() && self.channels.is_empty() { - return Ok(FeedData::Error( - "No search query or channels configured".to_string(), + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + return Err(anyhow!( + "YouTube API error (status {}): {}", + status, + error_text )); } - Ok(FeedData::Youtube(all_videos)) + #[derive(Deserialize)] + struct PlaylistResponse { + items: Vec, + } + #[derive(Deserialize)] + struct PlaylistItem { + snippet: PlaylistSnippet, + } + #[derive(Deserialize)] + struct PlaylistSnippet { + #[serde(rename = "resourceId")] + resource_id: PlaylistResourceId, + } + #[derive(Deserialize)] + struct PlaylistResourceId { + #[serde(rename = "videoId")] + video_id: String, + } + + let playlist_response: PlaylistResponse = response.json().await?; + let video_ids: Vec = playlist_response + .items + .into_iter() + .map(|item| item.snippet.resource_id.video_id) + .collect(); + + if video_ids.is_empty() { + return Ok(vec![]); + } + + self.get_video_details(&video_ids).await + } + + /// Fetch user's Watch Later playlist (requires OAuth) + async fn get_watch_later(&self) -> Result> { + let oauth = self.oauth.as_ref().ok_or_else(|| { + anyhow!("OAuth not configured. Set client_id and client_secret in config.") + })?; + + let token = oauth.get_access_token().await?; + + // First, get the user's channel to find the Watch Later playlist ID + let channels_url = format!( + "{}/channels?part=contentDetails&mine=true", + YOUTUBE_API_BASE + ); + + let response = self + .client + .get(&channels_url) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await?; + + if !response.status().is_success() { + return Err(anyhow!("Failed to get channel info")); + } + + #[derive(Deserialize)] + struct ChannelsResponse { + items: Vec, + } + #[derive(Deserialize)] + struct ChannelItem { + #[serde(rename = "contentDetails")] + content_details: ChannelContentDetails, + } + #[derive(Deserialize)] + struct ChannelContentDetails { + #[serde(rename = "relatedPlaylists")] + related_playlists: RelatedPlaylists, + } + #[derive(Deserialize)] + struct RelatedPlaylists { + #[serde(rename = "watchLater")] + watch_later: Option, + } + + let channels_response: ChannelsResponse = response.json().await?; + let watch_later_id = channels_response + .items + .first() + .and_then(|c| c.content_details.related_playlists.watch_later.clone()) + .ok_or_else(|| anyhow!("Watch Later playlist not found"))?; + + // Fetch the Watch Later playlist items + let url = format!( + "{}/playlistItems?part=snippet&playlistId={}&maxResults={}", + YOUTUBE_API_BASE, watch_later_id, self.max_videos + ); + + let response = self + .client + .get(&url) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await?; + + if !response.status().is_success() { + return Err(anyhow!("Failed to fetch Watch Later playlist")); + } + + #[derive(Deserialize)] + struct PlaylistResponse { + items: Vec, + } + #[derive(Deserialize)] + struct PlaylistItem { + snippet: PlaylistSnippet, + } + #[derive(Deserialize)] + struct PlaylistSnippet { + #[serde(rename = "resourceId")] + resource_id: PlaylistResourceId, + } + #[derive(Deserialize)] + struct PlaylistResourceId { + #[serde(rename = "videoId")] + video_id: String, + } + + let playlist_response: PlaylistResponse = response.json().await?; + let video_ids: Vec = playlist_response + .items + .into_iter() + .map(|item| item.snippet.resource_id.video_id) + .collect(); + + if video_ids.is_empty() { + return Ok(vec![]); + } + + self.get_video_details(&video_ids).await + } +} + +#[async_trait] +impl FeedFetcher for YoutubeFetcher { + async fn fetch(&self) -> Result { + // Handle different feed types + match self.feed_type { + YoutubeFeedType::Subscriptions => match self.get_subscriptions_feed().await { + Ok(videos) if videos.is_empty() => { + Ok(FeedData::Error("No subscription videos found".to_string())) + } + Ok(videos) => Ok(FeedData::Youtube(videos)), + Err(e) => Ok(FeedData::Error(format!("Subscriptions error: {}", e))), + }, + YoutubeFeedType::LikedVideos => match self.get_liked_videos().await { + Ok(videos) if videos.is_empty() => { + Ok(FeedData::Error("No liked videos found".to_string())) + } + Ok(videos) => Ok(FeedData::Youtube(videos)), + Err(e) => Ok(FeedData::Error(format!("Liked videos error: {}", e))), + }, + YoutubeFeedType::WatchLater => match self.get_watch_later().await { + Ok(videos) if videos.is_empty() => { + Ok(FeedData::Error("Watch Later is empty".to_string())) + } + Ok(videos) => Ok(FeedData::Youtube(videos)), + Err(e) => Ok(FeedData::Error(format!("Watch Later error: {}", e))), + }, + YoutubeFeedType::Public => { + // Original public feed behavior + let mut all_videos = Vec::new(); + + // Fetch from search query if provided + if let Some(query) = &self.search_query { + match self.search_videos(query).await { + Ok(mut videos) => all_videos.append(&mut videos), + Err(e) => return Ok(FeedData::Error(format!("Search error: {}", e))), + } + } + + // Fetch from channels + for channel_id in &self.channels { + match self.get_channel_videos(channel_id).await { + Ok(mut videos) => all_videos.append(&mut videos), + Err(e) => { + eprintln!("Error fetching channel {}: {}", channel_id, e); + continue; + } + } + } + + // Limit total videos + all_videos.truncate(self.max_videos); + + if all_videos.is_empty() && self.search_query.is_none() && self.channels.is_empty() + { + return Ok(FeedData::Error( + "No search query or channels configured".to_string(), + )); + } + + Ok(FeedData::Youtube(all_videos)) + } + } } } diff --git a/src/feeds/youtube_oauth.rs b/src/feeds/youtube_oauth.rs new file mode 100644 index 0000000..8f4a40d --- /dev/null +++ b/src/feeds/youtube_oauth.rs @@ -0,0 +1,251 @@ +//! YouTube OAuth 2.0 authentication module +//! +//! Handles OAuth device flow for CLI/TUI applications and token management. + +use anyhow::{anyhow, Result}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const GOOGLE_DEVICE_AUTH_URL: &str = "https://oauth2.googleapis.com/device/code"; +const GOOGLE_TOKEN_URL: &str = "https://oauth2.googleapis.com/token"; + +/// OAuth tokens for YouTube API access +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OAuthTokens { + pub access_token: String, + pub refresh_token: String, + pub expires_at: u64, // Unix timestamp + pub token_type: String, +} + +impl OAuthTokens { + /// Check if the access token is expired (with 5 minute buffer) + pub fn is_expired(&self) -> bool { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + now >= self.expires_at.saturating_sub(300) // 5 minute buffer + } +} + +/// Device authorization response from Google +#[derive(Debug, Deserialize)] +pub struct DeviceAuthResponse { + pub device_code: String, + pub user_code: String, + pub verification_url: String, + pub expires_in: u64, + pub interval: u64, +} + +/// Token response from Google +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, + refresh_token: Option, + expires_in: u64, + token_type: String, +} + +/// Error response from token endpoint +#[derive(Debug, Deserialize)] +struct TokenError { + error: String, + error_description: Option, +} + +/// YouTube OAuth manager +pub struct YouTubeOAuth { + client_id: String, + client_secret: String, + client: reqwest::Client, + tokens_path: PathBuf, +} + +impl YouTubeOAuth { + pub fn new(client_id: String, client_secret: String) -> Self { + let tokens_path = dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".feedtui") + .join("youtube_tokens.json"); + + Self { + client_id, + client_secret, + client: reqwest::Client::new(), + tokens_path, + } + } + + /// Load saved tokens from disk + pub fn load_tokens(&self) -> Option { + std::fs::read_to_string(&self.tokens_path) + .ok() + .and_then(|content| serde_json::from_str(&content).ok()) + } + + /// Save tokens to disk + pub fn save_tokens(&self, tokens: &OAuthTokens) -> Result<()> { + if let Some(parent) = self.tokens_path.parent() { + std::fs::create_dir_all(parent)?; + } + let content = serde_json::to_string_pretty(tokens)?; + std::fs::write(&self.tokens_path, content)?; + Ok(()) + } + + /// Get a valid access token, refreshing if necessary + pub async fn get_access_token(&self) -> Result { + let tokens = self.load_tokens().ok_or_else(|| { + anyhow!("No OAuth tokens found. Run 'feedtui youtube-auth' to authenticate.") + })?; + + if tokens.is_expired() { + let refreshed = self.refresh_tokens(&tokens.refresh_token).await?; + self.save_tokens(&refreshed)?; + Ok(refreshed.access_token) + } else { + Ok(tokens.access_token) + } + } + + /// Start the device authorization flow + /// Returns the device auth response containing the user code to display + pub async fn start_device_flow(&self) -> Result { + let params = [ + ("client_id", self.client_id.as_str()), + ("scope", "https://www.googleapis.com/auth/youtube.readonly"), + ]; + + let response = self + .client + .post(GOOGLE_DEVICE_AUTH_URL) + .form(¶ms) + .send() + .await?; + + if !response.status().is_success() { + let error_text = response.text().await?; + return Err(anyhow!("Device auth failed: {}", error_text)); + } + + let auth_response: DeviceAuthResponse = response.json().await?; + Ok(auth_response) + } + + /// Poll for the token after user authorizes the device + pub async fn poll_for_token(&self, device_code: &str, interval: u64) -> Result { + let params = [ + ("client_id", self.client_id.as_str()), + ("client_secret", self.client_secret.as_str()), + ("device_code", device_code), + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ]; + + loop { + tokio::time::sleep(Duration::from_secs(interval)).await; + + let response = self + .client + .post(GOOGLE_TOKEN_URL) + .form(¶ms) + .send() + .await?; + + let status = response.status(); + let body = response.text().await?; + + if status.is_success() { + let token_response: TokenResponse = serde_json::from_str(&body)?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let tokens = OAuthTokens { + access_token: token_response.access_token, + refresh_token: token_response + .refresh_token + .unwrap_or_else(|| String::new()), + expires_at: now + token_response.expires_in, + token_type: token_response.token_type, + }; + + self.save_tokens(&tokens)?; + return Ok(tokens); + } + + // Check for pending or error + if let Ok(error) = serde_json::from_str::(&body) { + match error.error.as_str() { + "authorization_pending" => continue, + "slow_down" => { + tokio::time::sleep(Duration::from_secs(5)).await; + continue; + } + "access_denied" => return Err(anyhow!("User denied access")), + "expired_token" => return Err(anyhow!("Device code expired")), + _ => { + return Err(anyhow!( + "Token error: {} - {}", + error.error, + error.error_description.unwrap_or_default() + )) + } + } + } + } + } + + /// Refresh the access token using the refresh token + async fn refresh_tokens(&self, refresh_token: &str) -> Result { + let params = [ + ("client_id", self.client_id.as_str()), + ("client_secret", self.client_secret.as_str()), + ("refresh_token", refresh_token), + ("grant_type", "refresh_token"), + ]; + + let response = self + .client + .post(GOOGLE_TOKEN_URL) + .form(¶ms) + .send() + .await?; + + if !response.status().is_success() { + let error_text = response.text().await?; + return Err(anyhow!("Token refresh failed: {}", error_text)); + } + + let token_response: TokenResponse = response.json().await?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + Ok(OAuthTokens { + access_token: token_response.access_token, + refresh_token: token_response + .refresh_token + .unwrap_or_else(|| refresh_token.to_string()), + expires_at: now + token_response.expires_in, + token_type: token_response.token_type, + }) + } + + /// Check if we have valid saved tokens + pub fn has_valid_tokens(&self) -> bool { + self.load_tokens().map(|t| !t.is_expired()).unwrap_or(false) + } + + /// Delete saved tokens + pub fn clear_tokens(&self) -> Result<()> { + if self.tokens_path.exists() { + std::fs::remove_file(&self.tokens_path)?; + } + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs index d279bc7..5e16a86 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,8 @@ mod event; mod feeds; mod ui; +use feeds::youtube_oauth::YouTubeOAuth; + use anyhow::Result; use clap::{Parser, Subcommand}; use std::path::PathBuf; @@ -38,6 +40,17 @@ enum Commands { Config, /// Install the binary to cargo bin directory Install, + /// Authenticate with YouTube for personalized feeds + YoutubeAuth { + /// OAuth client ID (from Google Cloud Console) + #[arg(long)] + client_id: String, + /// OAuth client secret (from Google Cloud Console) + #[arg(long)] + client_secret: String, + }, + /// Clear YouTube authentication tokens + YoutubeLogout, } #[tokio::main] @@ -56,6 +69,15 @@ async fn main() -> Result<()> { Commands::Install => { return show_install_instructions(); } + Commands::YoutubeAuth { + client_id, + client_secret, + } => { + return youtube_auth(client_id, client_secret).await; + } + Commands::YoutubeLogout => { + return youtube_logout(); + } } } @@ -292,3 +314,67 @@ fn show_install_instructions() -> Result<()> { Ok(()) } + +async fn youtube_auth(client_id: String, client_secret: String) -> Result<()> { + println!("=== YouTube Authentication ===\n"); + println!("This will authenticate feedtui with your YouTube account"); + println!("to access personalized feeds (subscriptions, liked videos, etc.).\n"); + + let oauth = YouTubeOAuth::new(client_id, client_secret); + + // Check if already authenticated + if oauth.has_valid_tokens() { + println!("You are already authenticated with YouTube."); + println!("Run 'feedtui youtube-logout' to clear tokens and re-authenticate.\n"); + return Ok(()); + } + + // Start device flow + println!("Starting device authorization flow...\n"); + let device_auth = oauth.start_device_flow().await?; + + println!("Please visit: {}", device_auth.verification_url); + println!("And enter code: {}\n", device_auth.user_code); + println!("Waiting for authorization..."); + + // Poll for token + match oauth + .poll_for_token(&device_auth.device_code, device_auth.interval) + .await + { + Ok(_tokens) => { + println!("\nAuthentication successful!"); + println!("YouTube tokens have been saved."); + println!("\nYou can now use personalized feed types in your config:"); + println!(" feed_type = \"subscriptions\" # Your subscription feed"); + println!(" feed_type = \"liked_videos\" # Your liked videos"); + println!(" feed_type = \"watch_later\" # Your Watch Later playlist"); + } + Err(e) => { + eprintln!("\nAuthentication failed: {}", e); + return Err(e); + } + } + + Ok(()) +} + +fn youtube_logout() -> Result<()> { + println!("=== YouTube Logout ===\n"); + + // Use a dummy OAuth instance just for clearing tokens + let oauth = YouTubeOAuth::new(String::new(), String::new()); + + match oauth.clear_tokens() { + Ok(_) => { + println!("YouTube authentication tokens have been cleared."); + println!("Run 'feedtui youtube-auth' to re-authenticate."); + } + Err(e) => { + eprintln!("Failed to clear tokens: {}", e); + return Err(e); + } + } + + Ok(()) +} diff --git a/src/ui/article_reader.rs b/src/ui/article_reader.rs index 5c90c79..05bb96f 100644 --- a/src/ui/article_reader.rs +++ b/src/ui/article_reader.rs @@ -3,7 +3,9 @@ use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap}, + widgets::{ + Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap, + }, Frame, }; @@ -95,7 +97,11 @@ impl ArticleReader { // Create the main block let block = Block::default() .title(format!(" {} ", item.title)) - .title_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) + .title_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) .borders(Borders::ALL) .border_style(Style::default().fg(Color::Yellow)); @@ -121,17 +127,20 @@ impl ArticleReader { if let Some(ref url) = item.url { lines.push(Line::from(vec![ Span::styled("URL: ", Style::default().fg(Color::DarkGray)), - Span::styled(url, Style::default().fg(Color::Blue).add_modifier(Modifier::UNDERLINED)), + Span::styled( + url, + Style::default() + .fg(Color::Blue) + .add_modifier(Modifier::UNDERLINED), + ), ])); } lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled( - "─".repeat(inner.width.saturating_sub(2) as usize), - Style::default().fg(Color::DarkGray), - ), - ])); + lines.push(Line::from(vec![Span::styled( + "─".repeat(inner.width.saturating_sub(2) as usize), + Style::default().fg(Color::DarkGray), + )])); lines.push(Line::from("")); // Description/content @@ -149,7 +158,9 @@ impl ArticleReader { } else { lines.push(Line::from(Span::styled( "No description available.", - Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC), + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC), ))); lines.push(Line::from("")); lines.push(Line::from(Span::styled( @@ -159,12 +170,10 @@ impl ArticleReader { } lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled( - "─".repeat(inner.width.saturating_sub(2) as usize), - Style::default().fg(Color::DarkGray), - ), - ])); + lines.push(Line::from(vec![Span::styled( + "─".repeat(inner.width.saturating_sub(2) as usize), + Style::default().fg(Color::DarkGray), + )])); // Help text lines.push(Line::from("")); diff --git a/src/ui/widgets/hackernews.rs b/src/ui/widgets/hackernews.rs index 4f35922..0c7a13c 100644 --- a/src/ui/widgets/hackernews.rs +++ b/src/ui/widgets/hackernews.rs @@ -182,7 +182,7 @@ impl FeedWidget for HackernewsWidget { } /// Get the HN discussion URL for the selected story - fn get_selected_discussion_url(&self) -> Option{ + fn get_selected_discussion_url(&self) -> Option { let idx = self.scroll_state.selected()?; let story = self.stories.get(idx)?; Some(format!("https://news.ycombinator.com/item?id={}", story.id)) diff --git a/src/ui/widgets/mod.rs b/src/ui/widgets/mod.rs index a3cb84f..0645b16 100644 --- a/src/ui/widgets/mod.rs +++ b/src/ui/widgets/mod.rs @@ -46,6 +46,4 @@ pub trait FeedWidget: Send + Sync { None } fn get_selected_discussion_url(&self) -> Option; - - } diff --git a/src/ui/widgets/youtube.rs b/src/ui/widgets/youtube.rs index fbefde1..359b536 100644 --- a/src/ui/widgets/youtube.rs +++ b/src/ui/widgets/youtube.rs @@ -158,6 +158,9 @@ impl FeedWidget for YoutubeWidget { self.config.channels.clone(), self.config.search_query.clone(), self.config.max_videos, + self.config.feed_type.clone(), + self.config.client_id.clone(), + self.config.client_secret.clone(), )) }