diff --git a/.gitignore b/.gitignore index daf0ce5..88c2f46 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -/target -/*.zip -*.zip +/target +/*.zip +*.zip diff --git a/Cargo.lock b/Cargo.lock index 782efaa..9afa0b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,2764 +1,2764 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "anyhow" -version = "1.0.101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" - -[[package]] -name = "atomic" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "backtrace" -version = "0.3.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-link", -] - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bit-set" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" - -[[package]] -name = "bytemuck" -version = "1.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" - -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" - -[[package]] -name = "castaway" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" -dependencies = [ - "rustversion", -] - -[[package]] -name = "cc" -version = "1.2.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "cjtui" -version = "0.1.0" -dependencies = [ - "color-eyre", - "crossterm", - "ratatui", - "reqwest", - "serde", - "serde_json", -] - -[[package]] -name = "color-eyre" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" -dependencies = [ - "backtrace", - "color-spantrace", - "eyre", - "indenter", - "once_cell", - "owo-colors", - "tracing-error", -] - -[[package]] -name = "color-spantrace" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" -dependencies = [ - "once_cell", - "owo-colors", - "tracing-core", - "tracing-error", -] - -[[package]] -name = "compact_str" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "static_assertions", -] - -[[package]] -name = "convert_case" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] - -[[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.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" -dependencies = [ - "bitflags 2.10.0", - "crossterm_winapi", - "derive_more", - "document-features", - "mio", - "parking_lot", - "rustix", - "signal-hook", - "signal-hook-mio", - "winapi", -] - -[[package]] -name = "crossterm_winapi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" -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 = "csscolorparser" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" -dependencies = [ - "lab", - "phf", -] - -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.115", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core", - "quote", - "syn 2.0.115", -] - -[[package]] -name = "deltae" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" - -[[package]] -name = "deranged" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" -dependencies = [ - "powerfmt", -] - -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.115", -] - -[[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 = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.115", -] - -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "euclid" -version = "0.22.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" -dependencies = [ - "num-traits", -] - -[[package]] -name = "eyre" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" -dependencies = [ - "indenter", - "once_cell", -] - -[[package]] -name = "fancy-regex" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" -dependencies = [ - "bit-set", - "regex", -] - -[[package]] -name = "filedescriptor" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" -dependencies = [ - "libc", - "thiserror 1.0.69", - "winapi", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "finl_unicode" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" - -[[package]] -name = "fixedbitset" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures-channel" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" - -[[package]] -name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-sink" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" - -[[package]] -name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-util" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" -dependencies = [ - "futures-core", - "futures-io", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi", - "wasip2", - "wasm-bindgen", -] - -[[package]] -name = "gimli" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[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 = "hyper" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "http", - "http-body", - "httparse", - "itoa", - "pin-project-lite", - "pin-utils", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "icu_collections" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" - -[[package]] -name = "icu_properties" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" - -[[package]] -name = "icu_provider" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "indenter" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" - -[[package]] -name = "indoc" -version = "2.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" -dependencies = [ - "rustversion", -] - -[[package]] -name = "instability" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" -dependencies = [ - "darling", - "indoc", - "proc-macro2", - "quote", - "syn 2.0.115", -] - -[[package]] -name = "ipnet" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" - -[[package]] -name = "iri-string" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" - -[[package]] -name = "js-sys" -version = "0.3.85" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "kasuari" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b" -dependencies = [ - "hashbrown", - "portable-atomic", - "thiserror 2.0.18", -] - -[[package]] -name = "lab" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "libc" -version = "0.2.182" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" - -[[package]] -name = "line-clipping" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" -dependencies = [ - "bitflags 2.10.0", -] - -[[package]] -name = "linux-raw-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" - -[[package]] -name = "litemap" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" - -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "lru" -version = "0.16.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" -dependencies = [ - "hashbrown", -] - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "mac_address" -version = "1.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" -dependencies = [ - "nix", - "winapi", -] - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "memmem" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", -] - -[[package]] -name = "mio" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" -dependencies = [ - "libc", - "log", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "nix" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" -dependencies = [ - "bitflags 2.10.0", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", -] - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "num-conv" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" - -[[package]] -name = "num-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.115", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "num_threads" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" -dependencies = [ - "libc", -] - -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "ordered-float" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" -dependencies = [ - "num-traits", -] - -[[package]] -name = "owo-colors" -version = "4.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pest" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" -dependencies = [ - "memchr", - "ucd-trie", -] - -[[package]] -name = "pest_derive" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" -dependencies = [ - "pest", - "pest_generator", -] - -[[package]] -name = "pest_generator" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" -dependencies = [ - "pest", - "pest_meta", - "proc-macro2", - "quote", - "syn 2.0.115", -] - -[[package]] -name = "pest_meta" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" -dependencies = [ - "pest", - "sha2", -] - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros", - "phf_shared", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator", - "phf_shared", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared", - "rand 0.8.5", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", - "syn 2.0.115", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - -[[package]] -name = "potential_utf" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" -dependencies = [ - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.2", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.60.2", -] - -[[package]] -name = "quote" -version = "1.0.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -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 = [ - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "ratatui" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" -dependencies = [ - "instability", - "ratatui-core", - "ratatui-crossterm", - "ratatui-macros", - "ratatui-termwiz", - "ratatui-widgets", -] - -[[package]] -name = "ratatui-core" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" -dependencies = [ - "bitflags 2.10.0", - "compact_str", - "hashbrown", - "indoc", - "itertools", - "kasuari", - "lru", - "strum", - "thiserror 2.0.18", - "unicode-segmentation", - "unicode-truncate", - "unicode-width", -] - -[[package]] -name = "ratatui-crossterm" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" -dependencies = [ - "cfg-if", - "crossterm", - "instability", - "ratatui-core", -] - -[[package]] -name = "ratatui-macros" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" -dependencies = [ - "ratatui-core", - "ratatui-widgets", -] - -[[package]] -name = "ratatui-termwiz" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" -dependencies = [ - "ratatui-core", - "termwiz", -] - -[[package]] -name = "ratatui-widgets" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" -dependencies = [ - "bitflags 2.10.0", - "hashbrown", - "indoc", - "instability", - "itertools", - "line-clipping", - "ratatui-core", - "strum", - "time", - "unicode-segmentation", - "unicode-width", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.10.0", -] - -[[package]] -name = "regex" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" - -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustc-demangle" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" - -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" -dependencies = [ - "bitflags 2.10.0", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" -dependencies = [ - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -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 = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "semver" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.115", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[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 = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook-mio" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" -dependencies = [ - "libc", - "mio", - "signal-hook", -] - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "siphasher" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "socket2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" -dependencies = [ - "libc", - "windows-sys 0.60.2", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.115", -] - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.115" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.115", -] - -[[package]] -name = "terminfo" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" -dependencies = [ - "fnv", - "nom", - "phf", - "phf_codegen", -] - -[[package]] -name = "termios" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" -dependencies = [ - "libc", -] - -[[package]] -name = "termwiz" -version = "0.23.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" -dependencies = [ - "anyhow", - "base64", - "bitflags 2.10.0", - "fancy-regex", - "filedescriptor", - "finl_unicode", - "fixedbitset", - "hex", - "lazy_static", - "libc", - "log", - "memmem", - "nix", - "num-derive", - "num-traits", - "ordered-float", - "pest", - "pest_derive", - "phf", - "sha2", - "signal-hook", - "siphasher", - "terminfo", - "termios", - "thiserror 1.0.69", - "ucd-trie", - "unicode-segmentation", - "vtparse", - "wezterm-bidi", - "wezterm-blob-leases", - "wezterm-color-types", - "wezterm-dynamic", - "wezterm-input-types", - "winapi", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.115", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.115", -] - -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "libc", - "num-conv", - "num_threads", - "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 = "tinystr" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.49.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "socket2", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[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", -] - -[[package]] -name = "tower-http" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" -dependencies = [ - "bitflags 2.10.0", - "bytes", - "futures-util", - "http", - "http-body", - "iri-string", - "pin-project-lite", - "tower", - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", - "valuable", -] - -[[package]] -name = "tracing-error" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" -dependencies = [ - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" -dependencies = [ - "sharded-slab", - "thread_local", - "tracing-core", -] - -[[package]] -name = "try-lock" -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 = "ucd-trie" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" - -[[package]] -name = "unicode-ident" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" - -[[package]] -name = "unicode-segmentation" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" - -[[package]] -name = "unicode-truncate" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" -dependencies = [ - "itertools", - "unicode-segmentation", - "unicode-width", -] - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "uuid" -version = "1.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" -dependencies = [ - "atomic", - "getrandom 0.3.4", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "vtparse" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" -dependencies = [ - "cfg-if", - "futures-util", - "js-sys", - "once_cell", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.115", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "web-sys" -version = "0.3.85" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-roots" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "wezterm-bidi" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" -dependencies = [ - "log", - "wezterm-dynamic", -] - -[[package]] -name = "wezterm-blob-leases" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" -dependencies = [ - "getrandom 0.3.4", - "mac_address", - "sha2", - "thiserror 1.0.69", - "uuid", -] - -[[package]] -name = "wezterm-color-types" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" -dependencies = [ - "csscolorparser", - "deltae", - "lazy_static", - "wezterm-dynamic", -] - -[[package]] -name = "wezterm-dynamic" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" -dependencies = [ - "log", - "ordered-float", - "strsim", - "thiserror 1.0.69", - "wezterm-dynamic-derive", -] - -[[package]] -name = "wezterm-dynamic-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "wezterm-input-types" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" -dependencies = [ - "bitflags 1.3.2", - "euclid", - "lazy_static", - "serde", - "wezterm-dynamic", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" - -[[package]] -name = "writeable" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - -[[package]] -name = "yoke" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.115", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.39" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.39" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.115", -] - -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.115", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - -[[package]] -name = "zerotrie" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.115", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cjtui" +version = "0.1.0" +dependencies = [ + "color-eyre", + "crossterm", + "ratatui", + "reqwest", + "serde", + "serde_json", +] + +[[package]] +name = "color-eyre" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[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.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +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 = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.115", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.115", +] + +[[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 = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "euclid" +version = "0.22.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" +dependencies = [ + "num-traits", +] + +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[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 = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kasuari" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b" +dependencies = [ + "hashbrown", + "portable-atomic", + "thiserror 2.0.18", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "line-clipping" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "owo-colors" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +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 = [ + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "ratatui" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags 2.10.0", + "compact_str", + "hashbrown", + "indoc", + "itertools", + "kasuari", + "lru", + "strum", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.10.0", + "hashbrown", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +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 = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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 = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.10.0", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "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 = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[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", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" +dependencies = [ + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "sharded-slab", + "thread_local", + "tracing-core", +] + +[[package]] +name = "try-lock" +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 = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "atomic", + "getrandom 0.3.4", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.115", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 215d20c..25b7990 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,13 @@ -[package] -name = "cjtui" -version = "0.1.0" -edition = "2024" -authors = ["Christopher Warren Johnson III"] - -[dependencies] -ratatui = "0.30.0" -color-eyre = "0.6.3" -crossterm = "0.29.0" -serde_json ="1" -reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } +[package] +name = "cjtui" +version = "0.1.0" +edition = "2024" +authors = ["Christopher Warren Johnson III"] + +[dependencies] +ratatui = "0.30.0" +color-eyre = "0.6.3" +crossterm = "0.29.0" +serde_json ="1" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } serde = { version = "1", features = ["derive"] } \ No newline at end of file diff --git a/README.md b/README.md index c972fea..54f964f 100644 --- a/README.md +++ b/README.md @@ -1,123 +1,123 @@ -# CjTUI -*Chris Jellyfin Terminal User Interface* - -This is the first actually useful programming project I have completed. -I still can't write in rust because of generative AI. -Maybe one day when I am better at programming I will refactor the codebase and figure out how to hand code a transcoding option that actually is compatible with Jellyfin API. - -Linux Only Curreently. - -## Features -* **Blazingly Fast Speed** -* Online and Offline Playback -* Robust Download Connectivity -* Individual Download and Batch Download Support -* Download Queue Management -* Local Media Management -* Autoplay and Resume functionality -* Sends Current Watch Position to the Server -* Playlist and Collection Support -* Persistent Auth - -## System Dependencies on -**MPV** and **ARIA2C** - -## Persistent Storage -All application data is stored in: -~/.config/cjtui/ - -* config.toml: Server URL and API key is stored. -* downloads/: Local download data, used for local playback and offline navigation. -* temp/: Temporary download data during ARIA2C functionality. - -## Controls -##### __General Interface Controls__ -* **Up**/K: Up -* **Right**/L/**Enter**: Drilldown/Enter -* **Down**/J: Down -* **Left**/H/**Backspace**/Esc: Back -* q: Return to the home screen or quit. -* /: Search: Click once to search and clicking / multiple times cycles through search categories. - -##### __How to Watch Media__ -* p: Play: Starts a show playing at the beginning and will use offline playback if available. -* P: AutoPlay: Starts a show playing at the beginning and will use offline playback if available. Turns on auto play mode which will play the next episode in series or song in the playlist. -* r: Resume: If there is a pause position online, it will stream beginning at the paused position. -* R: AutoResume: Resume + Autoplay flag is on. -* w: Watched: Marks as watched. -* W: Not Watched: Marks as unwatched. - -##### __Media Player Control__ -* Space: Pause -* x: close mpv -* f: full screen media player -* a: cycle audio track -* s: cycle subtitles -* v: change subtitle visibility. - -##### __Local File Management__ -* d: Queue file for download -* D: Enter the download visual queue. -* (d on an item in the queue): Remove item from queue. -* X: Deletes the local file if the file exists. -* o: Enter offline playback/Attempt to ping the server to join online mode. (Local downloaded database is available during this mode) - -##### __Admin Controls__ -* /\reset"Enter": Deletes the config file and exits the program. Reloading will prompt URL, Username, and Password Input. - -## Installation Instructions - -### __Nix/NixOS__ -#### __Run without installing:__ -```bash -nix run git+https://git.cwj3rcb.com/siege/CjTui -``` - -#### __Build manually:__ -```bash -nix build git+https://git.cwj3rcb.com/siege/CjTui -./result/bin/cjtui -``` - -#### __Install on NixOS with flakes:__ -Add CJTUI to your `flake.nix` inputs -```nix -inputs.cjtui.url = "git+https://git.cwj3rcb.com/siege/CjTui"; -``` - -Then install the package -```nix -environment.systemPackages = [ - cjtui.packages.${pkgs.system}.default -]; -``` - -If your packages are in `configuration.nix`, pass `cjtui` through `specialArgs` from your system flake - -```nix -specialArgs = { - inherit cjtui; -}; -``` - -Then your `configuration.nix` can accept it - -```nix -{ pkgs, cjtui, ... }: -``` - -CJTUI pins its own `nixpkgs` input so it uses tested versions of `mpv` and `aria2c`. - -Do **not** add -```nix -inputs.cjtui.inputs.nixpkgs.follows = "nixpkgs"; -``` - -unless you intentionally want CJTUI to use your system’s versions of `mpv` and `aria2c`. - -### __General Install__ -Install mpv and aria2c with your package manager of choice then -> - -```bash -cargo install --git https://git.cwj3rcb.com/siege/CjTui +# CjTUI +*Chris Jellyfin Terminal User Interface* + +This is the first actually useful programming project I have completed. +I still can't write in rust because of generative AI. +Maybe one day when I am better at programming I will refactor the codebase and figure out how to hand code a transcoding option that actually is compatible with Jellyfin API. + +Linux Only Curreently. + +## Features +* **Blazingly Fast Speed** +* Online and Offline Playback +* Robust Download Connectivity +* Individual Download and Batch Download Support +* Download Queue Management +* Local Media Management +* Autoplay and Resume functionality +* Sends Current Watch Position to the Server +* Playlist and Collection Support +* Persistent Auth + +## System Dependencies on +**MPV** and **ARIA2C** + +## Persistent Storage +All application data is stored in: +~/.config/cjtui/ + +* config.toml: Server URL and API key is stored. +* downloads/: Local download data, used for local playback and offline navigation. +* temp/: Temporary download data during ARIA2C functionality. + +## Controls +##### __General Interface Controls__ +* **Up**/K: Up +* **Right**/L/**Enter**: Drilldown/Enter +* **Down**/J: Down +* **Left**/H/**Backspace**/Esc: Back +* q: Return to the home screen or quit. +* /: Search: Click once to search and clicking / multiple times cycles through search categories. + +##### __How to Watch Media__ +* p: Play: Starts a show playing at the beginning and will use offline playback if available. +* P: AutoPlay: Starts a show playing at the beginning and will use offline playback if available. Turns on auto play mode which will play the next episode in series or song in the playlist. +* r: Resume: If there is a pause position online, it will stream beginning at the paused position. +* R: AutoResume: Resume + Autoplay flag is on. +* w: Watched: Marks as watched. +* W: Not Watched: Marks as unwatched. + +##### __Media Player Control__ +* Space: Pause +* x: close mpv +* f: full screen media player +* a: cycle audio track +* s: cycle subtitles +* v: change subtitle visibility. + +##### __Local File Management__ +* d: Queue file for download +* D: Enter the download visual queue. +* (d on an item in the queue): Remove item from queue. +* X: Deletes the local file if the file exists. +* o: Enter offline playback/Attempt to ping the server to join online mode. (Local downloaded database is available during this mode) + +##### __Admin Controls__ +* /\reset"Enter": Deletes the config file and exits the program. Reloading will prompt URL, Username, and Password Input. + +## Installation Instructions + +### __Nix/NixOS__ +#### __Run without installing:__ +```bash +nix run git+https://git.cwj3rcb.com/siege/CjTui +``` + +#### __Build manually:__ +```bash +nix build git+https://git.cwj3rcb.com/siege/CjTui +./result/bin/cjtui +``` + +#### __Install on NixOS with flakes:__ +Add CJTUI to your `flake.nix` inputs +```nix +inputs.cjtui.url = "git+https://git.cwj3rcb.com/siege/CjTui"; +``` + +Then install the package +```nix +environment.systemPackages = [ + cjtui.packages.${pkgs.system}.default +]; +``` + +If your packages are in `configuration.nix`, pass `cjtui` through `specialArgs` from your system flake + +```nix +specialArgs = { + inherit cjtui; +}; +``` + +Then your `configuration.nix` can accept it + +```nix +{ pkgs, cjtui, ... }: +``` + +CJTUI pins its own `nixpkgs` input so it uses tested versions of `mpv` and `aria2c`. + +Do **not** add +```nix +inputs.cjtui.inputs.nixpkgs.follows = "nixpkgs"; +``` + +unless you intentionally want CJTUI to use your system’s versions of `mpv` and `aria2c`. + +### __General Install__ +Install mpv and aria2c with your package manager of choice then -> + +```bash +cargo install --git https://git.cwj3rcb.com/siege/CjTui ``` \ No newline at end of file diff --git a/flake.lock b/flake.lock index 0ee7307..94bb78d 100644 --- a/flake.lock +++ b/flake.lock @@ -1,27 +1,27 @@ -{ - "nodes": { - "nixpkgs": { - "locked": { - "lastModified": 1781577229, - "narHash": "sha256-lrp67w8AulE9Ks53n27I45ADSzbOCn4H+CNW1Ck8B+8=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "567a49d1913ce81ac6e9582e3553dd90a955875f", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "root": { - "inputs": { - "nixpkgs": "nixpkgs" - } - } - }, - "root": "root", - "version": 7 -} +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1781577229, + "narHash": "sha256-lrp67w8AulE9Ks53n27I45ADSzbOCn4H+CNW1Ck8B+8=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "567a49d1913ce81ac6e9582e3553dd90a955875f", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix index 9e73e68..bcff160 100644 --- a/flake.nix +++ b/flake.nix @@ -1,53 +1,53 @@ -{ - description = "CJTUI - Chris Jellyfin Terminal User Interface"; - - inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - }; - - outputs = { self, nixpkgs }: - let - systems = [ "x86_64-linux" "aarch64-linux" ]; - - forAllSystems = nixpkgs.lib.genAttrs systems; - - pkgsFor = system: import nixpkgs { - inherit system; - }; - in { - packages = forAllSystems (system: - let - pkgs = pkgsFor system; - in { - default = pkgs.rustPlatform.buildRustPackage { - pname = "cjtui"; - version = "0.1.0"; - - src = ./.; - - cargoLock = { - lockFile = ./Cargo.lock; - }; - - nativeBuildInputs = [ - pkgs.makeWrapper - ]; - - postInstall = '' - wrapProgram $out/bin/cjtui \ - --prefix PATH : ${pkgs.lib.makeBinPath [ - pkgs.mpv - pkgs.aria2 - ]} - ''; - }; - }); - - apps = forAllSystems (system: { - default = { - type = "app"; - program = "${self.packages.${system}.default}/bin/cjtui"; - }; - }); - }; +{ + description = "CJTUI - Chris Jellyfin Terminal User Interface"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = { self, nixpkgs }: + let + systems = [ "x86_64-linux" "aarch64-linux" ]; + + forAllSystems = nixpkgs.lib.genAttrs systems; + + pkgsFor = system: import nixpkgs { + inherit system; + }; + in { + packages = forAllSystems (system: + let + pkgs = pkgsFor system; + in { + default = pkgs.rustPlatform.buildRustPackage { + pname = "cjtui"; + version = "0.1.0"; + + src = ./.; + + cargoLock = { + lockFile = ./Cargo.lock; + }; + + nativeBuildInputs = [ + pkgs.makeWrapper + ]; + + postInstall = '' + wrapProgram $out/bin/cjtui \ + --prefix PATH : ${pkgs.lib.makeBinPath [ + pkgs.mpv + pkgs.aria2 + ]} + ''; + }; + }); + + apps = forAllSystems (system: { + default = { + type = "app"; + program = "${self.packages.${system}.default}/bin/cjtui"; + }; + }); + }; } \ No newline at end of file diff --git a/src/core/config.rs b/src/core/config.rs index fa5b873..c493d98 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -1,105 +1,156 @@ -use std::{fs, io, path::PathBuf}; - -use super::paths; - -#[derive(Debug, Clone)] -pub struct Config { - pub base_url: String, - pub access_token: Option, - pub user_id: Option, -} - - -#[derive(Debug)] -pub enum ConfigError { - NotFound(PathBuf), - Io(io::Error), - Parse(String), -} - -impl From for ConfigError { - fn from(e: io::Error) -> Self { - Self::Io(e) - } -} - -pub fn config_path() -> Option { - paths::app_config_dir().map(|d| d.join("config.toml")) -} - -// Very small TOML-ish parser for now (no deps). -// Format: -// base_url = "http://..." -// access_token = "..." -pub fn load_config() -> Result { - let path = config_path().ok_or_else(|| ConfigError::Parse("No HOME/XDG dirs".into()))?; - if !path.exists() { - return Err(ConfigError::NotFound(path)); - } - - let s = fs::read_to_string(&path)?; - parse_config(&s).map_err(ConfigError::Parse) -} - -fn parse_config(s: &str) -> Result { - let mut base_url: Option = None; - let mut access_token: Option = None; - let mut user_id: Option = None; - - for (i, line) in s.lines().enumerate() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - - let (k, v) = line - .split_once('=') - .ok_or_else(|| format!("Line {}: expected key = value", i + 1))?; - - let key = k.trim(); - let mut val = v.trim().to_string(); - - // strip quotes if present - if val.starts_with('"') && val.ends_with('"') && val.len() >= 2 { - val = val[1..val.len() - 1].to_string(); - } - - match key { - "base_url" => base_url = Some(val), - "access_token" => access_token = Some(val), - "user_id" => user_id = Some(val), - _ => {} - } - } - - let base_url = base_url.ok_or_else(|| "Missing base_url".to_string())?; - - Ok(Config { - base_url, - access_token, - user_id, - }) -} - -pub fn save_config(cfg: &Config) -> Result<(), ConfigError> { - let dir = paths::app_config_dir() - .ok_or_else(|| ConfigError::Parse("No HOME/XDG dirs".into()))?; - std::fs::create_dir_all(&dir)?; - - let path = dir.join("config.toml"); - - let mut out = String::new(); - - out.push_str(&format!("base_url = \"{}\"\n", cfg.base_url)); - - if let Some(t) = &cfg.access_token { - out.push_str(&format!("access_token = \"{}\"\n", t)); - } - - if let Some(u) = &cfg.user_id { - out.push_str(&format!("user_id = \"{}\"\n", u)); - } - - std::fs::write(path, out)?; - Ok(()) -} +use std::{fs, io, path::PathBuf}; + +use super::paths; + +#[derive(Debug, Clone)] +pub struct Config { + pub base_url: String, + pub access_token: Option, + pub user_id: Option, + /// Stable, per-installation identifier sent to Jellyfin as DeviceId. + /// Must be unique per machine: Jellyfin keys sessions on (DeviceId, Client), + /// so two machines sharing one DeviceId invalidate each other's tokens. + pub device_id: Option, +} + + +#[derive(Debug)] +pub enum ConfigError { + NotFound(PathBuf), + Io(io::Error), + Parse(String), +} + +impl From for ConfigError { + fn from(e: io::Error) -> Self { + Self::Io(e) + } +} + +pub fn config_path() -> Option { + paths::app_config_dir().map(|d| d.join("config.toml")) +} + +// Very small TOML-ish parser for now (no deps). +// Format: +// base_url = "http://..." +// access_token = "..." +pub fn load_config() -> Result { + let path = config_path().ok_or_else(|| ConfigError::Parse("No HOME/XDG dirs".into()))?; + if !path.exists() { + return Err(ConfigError::NotFound(path)); + } + + let s = fs::read_to_string(&path)?; + parse_config(&s).map_err(ConfigError::Parse) +} + +fn parse_config(s: &str) -> Result { + let mut base_url: Option = None; + let mut access_token: Option = None; + let mut user_id: Option = None; + let mut device_id: Option = None; + + for (i, line) in s.lines().enumerate() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + let (k, v) = line + .split_once('=') + .ok_or_else(|| format!("Line {}: expected key = value", i + 1))?; + + let key = k.trim(); + let mut val = v.trim().to_string(); + + // strip quotes if present + if val.starts_with('"') && val.ends_with('"') && val.len() >= 2 { + val = val[1..val.len() - 1].to_string(); + } + + match key { + "base_url" => base_url = Some(val), + "access_token" => access_token = Some(val), + "user_id" => user_id = Some(val), + "device_id" => device_id = Some(val), + _ => {} + } + } + + let base_url = base_url.ok_or_else(|| "Missing base_url".to_string())?; + + Ok(Config { + base_url, + access_token, + user_id, + device_id, + }) +} + +pub fn save_config(cfg: &Config) -> Result<(), ConfigError> { + let dir = paths::app_config_dir() + .ok_or_else(|| ConfigError::Parse("No HOME/XDG dirs".into()))?; + std::fs::create_dir_all(&dir)?; + + let path = dir.join("config.toml"); + + let mut out = String::new(); + + out.push_str(&format!("base_url = \"{}\"\n", cfg.base_url)); + + if let Some(t) = &cfg.access_token { + out.push_str(&format!("access_token = \"{}\"\n", t)); + } + + if let Some(u) = &cfg.user_id { + out.push_str(&format!("user_id = \"{}\"\n", u)); + } + + if let Some(d) = &cfg.device_id { + out.push_str(&format!("device_id = \"{}\"\n", d)); + } + + std::fs::write(path, out)?; + Ok(()) +} + +/// Returns this installation's DeviceId, generating and persisting one on first use. +/// +/// Jellyfin treats a DeviceId as a physical device. Logging in from a second +/// machine with the same DeviceId makes the server rebind that device's session +/// and revoke the first machine's access token, so this value must never be +/// shared between installs (and must survive restarts, or every launch would +/// register a fresh device on the server). +pub fn ensure_device_id(cfg: &mut Config) -> String { + if let Some(d) = cfg.device_id.as_deref() { + if !d.trim().is_empty() { + return d.to_string(); + } + } + + let id = generate_device_id(); + cfg.device_id = Some(id.clone()); + // Best effort: if the write fails we still use the id for this run, and try + // again next launch rather than failing the login outright. + let _ = save_config(cfg); + id +} + +fn generate_device_id() -> String { + // The kernel hands us a real random UUID, no crate needed. + if let Ok(s) = fs::read_to_string("/proc/sys/kernel/random/uuid") { + let s = s.trim(); + if !s.is_empty() { + return s.to_string(); + } + } + + // Fallback for anything without procfs: mix clock and pid. + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + + format!("cjtui-{:x}-{:x}", nanos, std::process::id()) +} diff --git a/src/core/downloads.rs b/src/core/downloads.rs index 9d346b9..8ceb24d 100644 --- a/src/core/downloads.rs +++ b/src/core/downloads.rs @@ -1,1364 +1,1364 @@ - -use std::{ - collections::{VecDeque, HashSet}, - fs, - path::{Path, PathBuf}, - process::{Child, Command, Stdio}, - sync::{mpsc, Arc, Mutex}, - thread, - time::{Duration, Instant}, - net::TcpStream, - io::{Read, Write}, -}; - -use serde_json::{json, Value}; - -use crate::core::{config::Config, jellyfin::{JellyfinClient, JfItem, LibraryQuery}, paths}; - -#[derive(Clone, Debug)] -pub enum DownloadKind { - Movie, - Episode { series: String, season: Option, episode: Option }, - Track { artist: Option, album: Option, track_no: Option }, - Other, -} - -#[derive(Clone, Debug)] -pub enum DownloadStatus { - Queued, - Downloading, - Finalizing, - Completed, - Failed(String), - Cancelled, -} - -#[derive(Clone, Debug)] -pub struct DownloadItem { - pub item_id: String, - pub title: String, - pub kind: DownloadKind, - pub final_rel_path: PathBuf, // relative to downloads root - pub status: DownloadStatus, - pub added_at: Instant, - - pub bytes_downloaded: u64, - pub bytes_total: Option, - pub download_speed: u64, // bytes/sec - pub eta_secs: Option, // seconds remaining - - // aria2 - pub gid: Option, - - pub playlist_links: Vec, -} - -#[derive(Debug)] -pub enum DownloadCommand { - Enqueue(Vec), - EnqueuePlaylistItem { - item: JfItem, - playlist_name: String, - playlist_index: usize, - }, - Cancel { index: usize }, - RemoveQueued { index: usize }, - Shutdown, -} - - -#[derive(Default)] -pub struct DownloadState { - pub queue: VecDeque, - pub active_gid: Option, - pub last_message: Option, -} - -pub struct DownloadManager { - tx: mpsc::Sender, - pub state: Arc>, -} - -#[derive(Clone, Debug)] -pub struct LocalEntry { - pub item_id: String, - pub title: String, - pub path: PathBuf, -} - -#[derive(Clone, Debug)] -pub struct PlaylistLink { - pub playlist_name: String, - pub playlist_index: usize, // 0-based - pub title: String, -} - -/// Scan downloads root for marker files and return local playable entries. -/// Title is taken from marker JSON if present, else from the media filename stem. -pub fn scan_local_entries() -> Vec { - fn walk(dir: &Path, out: &mut Vec) { - let rd = match std::fs::read_dir(dir) { - Ok(r) => r, - Err(_) => return, - }; - - for ent in rd.flatten() { - let p = ent.path(); - - if p.is_dir() { - walk(&p, out); - continue; - } - - let name = match p.file_name().and_then(|s| s.to_str()) { - Some(s) => s, - None => continue, - }; - - if !name.ends_with(DOWNLOAD_MARKER_EXT) { - continue; - } - - let raw = match std::fs::read_to_string(&p) { - Ok(s) => s, - Err(_) => continue, - }; - - let v: serde_json::Value = match serde_json::from_str(raw.trim()) { - Ok(v) => v, - Err(_) => continue, - }; - - let item_id = match v.get("item_id").and_then(|x| x.as_str()) { - Some(s) if !s.is_empty() => s.to_string(), - _ => continue, - }; - - // derive media path: "." -> "" - let suffix = format!(".{DOWNLOAD_MARKER_EXT}"); - if !name.ends_with(&suffix) { - continue; - } - let media_name = &name[..name.len() - suffix.len()]; - let media_path = p.with_file_name(media_name); - if !media_path.exists() { - continue; - } - - // title: prefer json field if you ever add it, else from filename - let title = v - .get("title") - .and_then(|x| x.as_str()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| { - media_path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or(media_name) - .to_string() - }); - - out.push(LocalEntry { - item_id, - title, - path: media_path, - }); - } - } - - let mut out = Vec::new(); - if let Ok(root) = downloads_root() { - walk(&root, &mut out); - } - - // Stable sort: title - out.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())); - out -} - - -impl DownloadManager { - pub fn start(cfg: Config) -> Self { - let state = Arc::new(Mutex::new(DownloadState::default())); - let (tx, rx) = mpsc::channel::(); - - // cleanup temp on start - let _ = cleanup_temp_dir(); - - let state_bg = Arc::clone(&state); - thread::spawn(move || worker_loop(cfg, state_bg, rx)); - - Self { tx, state } - } - - pub fn send(&self, cmd: DownloadCommand) { - let _ = self.tx.send(cmd); - } -} - -// ---------- directory helpers ---------- - -const DOWNLOAD_MARKER_EXT: &str = "cjtui.json"; - -fn marker_path_for_media(media_path: &Path) -> PathBuf { - // Example: - // Movie.mkv -> Movie.mkv.cjtui.json - let file_name = media_path - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("download"); - - media_path - .parent() - .unwrap_or_else(|| Path::new(".")) - .join(format!("{file_name}.{DOWNLOAD_MARKER_EXT}")) -} - -fn write_download_marker(media_path: &Path, item_id: &str) -> Result<(), String> { - let marker = marker_path_for_media(media_path); - - let payload = json!({ - "item_id": item_id, - "saved_at_unix": std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .ok() - .map(|d| d.as_secs()) - .unwrap_or(0), - "media_file": media_path.file_name().and_then(|s| s.to_str()).unwrap_or(""), - }); - - fs::write(&marker, payload.to_string()) - .map_err(|e| format!("write marker failed: {e}"))?; - - Ok(()) -} - -fn scan_dir_for_markers(dir: &Path, out: &mut HashSet) -> Result<(), String> { - if !dir.exists() { - return Ok(()); - } - - for ent in fs::read_dir(dir).map_err(|e| format!("read_dir failed: {e}"))? { - let p = ent.map_err(|e| format!("dir entry failed: {e}"))?.path(); - - if p.is_dir() { - scan_dir_for_markers(&p, out)?; - continue; - } - - // Look for *.cjtui.json - let name = p.file_name().and_then(|s| s.to_str()).unwrap_or(""); - if !name.ends_with(DOWNLOAD_MARKER_EXT) { - continue; - } - - let raw = match fs::read_to_string(&p) { - Ok(s) => s, - Err(_) => continue, - }; - - let v: Value = match serde_json::from_str(raw.trim()) { - Ok(v) => v, - Err(_) => continue, - }; - - if let Some(id) = v.get("item_id").and_then(|x| x.as_str()) { - if !id.trim().is_empty() { - out.insert(id.to_string()); - } - } - } - - Ok(()) -} - -/// Public: scan the downloads root and return set of downloaded Jellyfin item ids. -pub fn scan_downloaded_ids() -> Result, String> { - let root = downloads_root()?; - let mut out = HashSet::new(); - scan_dir_for_markers(&root, &mut out)?; - Ok(out) -} - - -pub fn downloads_root() -> Result { - let dir = paths::app_config_dir().ok_or_else(|| "no config dir".to_string())?; - Ok(dir.join("downloads")) -} - -pub fn temp_root() -> Result { - let dir = paths::app_config_dir().ok_or_else(|| "no config dir".to_string())?; - Ok(dir.join("temp")) -} - -pub fn ensure_dirs() -> Result<(), String> { - fs::create_dir_all(downloads_root().map_err(|e| e.to_string())?).map_err(|e| e.to_string())?; - fs::create_dir_all(temp_root().map_err(|e| e.to_string())?).map_err(|e| e.to_string())?; - Ok(()) -} - -pub fn cleanup_temp_dir() -> Result<(), String> { - let tmp = temp_root()?; - if tmp.exists() { - fs::remove_dir_all(&tmp).map_err(|e| format!("remove temp: {e}"))?; - } - fs::create_dir_all(&tmp).map_err(|e| format!("create temp: {e}"))?; - fs::create_dir_all(tmp.join("jobs")).map_err(|e| format!("create temp/jobs: {e}"))?; - Ok(()) -} - -// ---------- queue building (expansion) ---------- - -pub fn expand_for_enqueue( - jf: &JellyfinClient, - user_id: &str, - item: &JfItem, -) -> Result, String> { - let t = item.item_type.as_deref().unwrap_or(""); - match t { - "Movie" => Ok(vec![make_download_item_movie(item)?]), - "Episode" => Ok(vec![make_download_item_episode(item)?]), - "Audio" => Ok(vec![make_download_item_track(item)?]), - - // Containers: - "Season" => { - // episodes under Season (non-recursive) - list_all_items(jf, user_id, LibraryQuery::all().with_parent(item.id.clone()).with_item_types("Episode"), false)? - .into_iter() - .map(|ep| make_download_item_episode(&ep)) - .collect() - } - "Series" => { - // episodes under Series (recursive) - let mut q = LibraryQuery::all() - .with_parent(item.id.clone()) - .with_item_types("Episode"); - q.recursive = true; - list_all_items(jf, user_id, q, true)? - .into_iter() - .map(|ep| make_download_item_episode(&ep)) - .collect() - } - "MusicAlbum" => { - // tracks under album - list_all_items(jf, user_id, LibraryQuery::all().with_parent(item.id.clone()).with_item_types("Audio"), false)? - .into_iter() - .map(|tr| make_download_item_track(&tr)) - .collect() - } - "Playlist" => { - // playlist items via special endpoint - list_all_playlist_items(jf, user_id, &item.id)? - .into_iter() - .map(|it| { - let tt = it.item_type.as_deref().unwrap_or(""); - match tt { - "Audio" => make_download_item_track(&it), - "Episode" => make_download_item_episode(&it), - "Movie" => make_download_item_movie(&it), - _ => make_download_item_other(&it), - } - }) - .collect() - } - - _ => Ok(vec![make_download_item_other(item)?]), - } -} - -fn list_all_items( - jf: &JellyfinClient, - user_id: &str, - query: LibraryQuery, - _maybe_large: bool, -) -> Result, String> { - // user-driven action, so paging here is fine - const LIMIT: usize = 200; - let mut start = 0usize; - let mut out = Vec::new(); - loop { - let page = jf.list_items(user_id, query.clone(), start, LIMIT)?; - out.extend(page.items.into_iter()); - if out.len() >= page.total || page.total == 0 { - break; - } - start += LIMIT; - } - Ok(out) -} - -fn list_all_playlist_items(jf: &JellyfinClient, user_id: &str, playlist_id: &str) -> Result, String> { - const LIMIT: usize = 200; - let mut start = 0usize; - let mut out = Vec::new(); - loop { - let page = jf.playlist_items(user_id, playlist_id, start, LIMIT)?; - out.extend(page.items.into_iter()); - if out.len() >= page.total || page.total == 0 { - break; - } - start += LIMIT; - } - Ok(out) -} - -// ---------- naming ---------- - -fn playlist_link_abs_path( - downloads_root: &Path, - playlist_name: &str, - playlist_index: usize, - title: &str, - ext: &str, -) -> PathBuf { - let plist = sanitize_component(playlist_name); - let t = sanitize_component(title); - - let idx = format!("{:03}", playlist_index + 1); - let file = if ext.is_empty() { - format!("{idx} {t}") - } else { - format!("{idx} {t}.{ext}") - }; - - downloads_root - .join("playlists") - .join(plist) - .join(file) -} - -fn ensure_playlist_link( - final_media_path: &Path, - playlist_name: &str, - playlist_index: usize, - title: &str, -) -> Result<(), String> { - let dl_root = downloads_root()?; - - let ext = final_media_path - .extension() - .and_then(|s| s.to_str()) - .unwrap_or(""); - - let link_abs = playlist_link_abs_path(&dl_root, playlist_name, playlist_index, title, ext); - - if let Some(parent) = link_abs.parent() { - fs::create_dir_all(parent).map_err(|e| format!("mk playlist dir: {e}"))?; - } - - // If exists, do nothing (idempotent) - if link_abs.exists() { - return Ok(()); - } - - // Prefer hardlink (fast, no duplication). If cross-device, fall back to copy. - match fs::hard_link(final_media_path, &link_abs) { - Ok(_) => Ok(()), - Err(e) => { - // EXDEV / permission / etc -> fallback copy - fs::copy(final_media_path, &link_abs) - .map(|_| ()) - .map_err(|e2| format!("playlist link failed: {e} / copy failed: {e2}")) - } - } -} - - -fn sanitize_component(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for ch in s.chars() { - // conservative sanitization for Linux/Windows compatibility - if matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' ) { - out.push('_'); - } else { - out.push(ch); - } - } - let out = out.trim().to_string(); - if out.is_empty() { "_".into() } else { out.chars().take(100).collect() } -} - -fn make_download_item_movie(item: &JfItem) -> Result { - let name = sanitize_component(&item.name); - let rel = PathBuf::from("movies").join(format!("{name}.watch")); - Ok(DownloadItem { - item_id: item.id.clone(), - title: item.name.clone(), - kind: DownloadKind::Movie, - final_rel_path: rel, - status: DownloadStatus::Queued, - added_at: Instant::now(), - bytes_downloaded: 0, - bytes_total: None, - download_speed: 0, - eta_secs: None, - gid: None, - playlist_links: Vec::new(), - }) -} - -fn make_download_item_episode(item: &JfItem) -> Result { - let series = item.series_name.clone().unwrap_or_else(|| "Unknown Show".into()); - let series_s = sanitize_component(&series); - let season_no = item.parent_index_number; - let ep_no = item.index_number; - - let season_folder = season_no - .map(|n| format!("Season-{}", n)) - .unwrap_or_else(|| "Season-0".into()); - - let title_s = sanitize_component(&item.name); - - let filename = match (season_no, ep_no) { - (Some(s), Some(e)) => format!("S{:02}E{:02} - {title_s}.watch", s, e), - _ => format!("{title_s}.watch"), - }; - - let rel = PathBuf::from("shows") - .join(series_s) - .join(season_folder) - .join(filename); - - Ok(DownloadItem { - item_id: item.id.clone(), - title: format!("{series} - {}", item.name), - kind: DownloadKind::Episode { - series, - season: season_no, - episode: ep_no, - }, - final_rel_path: rel, - status: DownloadStatus::Queued, - added_at: Instant::now(), - - bytes_downloaded: 0, - bytes_total: None, - download_speed: 0, - eta_secs: None, - - gid: None, - playlist_links: Vec::new(), - }) -} - -fn make_download_item_track(item: &JfItem) -> Result { - let artist = item - .album_artist - .clone() - .or_else(|| item.artists.get(0).cloned()); - - let album = item.album.clone(); - let track_no = item.index_number; - - let artist_s = sanitize_component(artist.as_deref().unwrap_or("Unknown Artist")); - let album_s = sanitize_component(album.as_deref().unwrap_or("Unknown Album")); - let title_s = sanitize_component(&item.name); - - let filename = match track_no { - Some(n) => format!("{:02} - {title_s}.listen", n), - None => format!("{title_s}.listen"), - }; - - let rel = PathBuf::from("music") - .join(artist_s) - .join(album_s) - .join(filename); - - Ok(DownloadItem { - item_id: item.id.clone(), - title: item.name.clone(), - kind: DownloadKind::Track { - artist, - album, - track_no, - }, - final_rel_path: rel, - status: DownloadStatus::Queued, - added_at: Instant::now(), - - bytes_downloaded: 0, - bytes_total: None, - download_speed: 0, - eta_secs: None, - - gid: None, - playlist_links: Vec::new(), - }) -} - -fn make_download_item_other(item: &JfItem) -> Result { - let name = sanitize_component(&item.name); - let rel = PathBuf::from("other").join(format!("{name}.watch")); - - Ok(DownloadItem { - item_id: item.id.clone(), - title: item.name.clone(), - kind: DownloadKind::Other, - final_rel_path: rel, - status: DownloadStatus::Queued, - added_at: Instant::now(), - - bytes_downloaded: 0, - bytes_total: None, - download_speed: 0, - eta_secs: None, - - gid: None, - playlist_links: Vec::new(), - }) -} - - -fn tmp_dir_of_item(it: &DownloadItem) -> Result { - temp_dir_for(it) -} - -// Find the actual downloaded media file in tmp dir. -// We ignore aria2 metadata (*.aria2) and partials (*.part). -fn find_real_downloaded_file(tmp_dir: &Path) -> Result { - let mut best: Option<(u64, PathBuf)> = None; - - let rd = fs::read_dir(tmp_dir).map_err(|e| format!("read temp dir: {e}"))?; - for ent in rd { - let p = ent.map_err(|e| format!("read dir entry: {e}"))?.path(); - if !p.is_file() { - continue; - } - let name = p.file_name().and_then(|s| s.to_str()).unwrap_or(""); - - if name.ends_with(".aria2") || name.ends_with(".part") { - continue; - } - - let sz = fs::metadata(&p).map_err(|e| format!("stat temp file: {e}"))?.len(); - match &best { - None => best = Some((sz, p)), - Some((best_sz, _)) => { - if sz > *best_sz { - best = Some((sz, p)); - } - } - } - } - - best.map(|(_, p)| p) - .ok_or_else(|| "no downloaded media file found in temp dir".to_string()) -} - - - -// ---------- worker loop ---------- - -fn worker_loop(cfg: Config, state: Arc>, rx: mpsc::Receiver) { - let _ = ensure_dirs(); - - let token = cfg.access_token.clone().unwrap_or_default(); - - let mut shutdown = false; - - let mut aria2_daemon = match start_aria2_daemon() { - Ok(c) => c, - Err(e) => { - if let Ok(mut st) = state.lock() { - st.last_message = Some(format!("downloads disabled: {e}")); - } - return; - } - }; - - while !shutdown { - // ------------------------- - // 1) Handle incoming commands - // ------------------------- - while let Ok(cmd) = rx.try_recv() { - match cmd { - DownloadCommand::Enqueue(mut items) => { - if let Ok(mut st) = state.lock() { - // dedupe by item_id + final_rel_path - for it in items.drain(..) { - let already = st.queue.iter().any(|q| { - q.item_id == it.item_id && q.final_rel_path == it.final_rel_path - }); - if !already { - st.queue.push_back(it); - } - } - st.last_message = Some("queued".into()); - } - } - - DownloadCommand::Cancel { index } => { - if index == 0 { - // cancel the active download (front) - let gid = state.lock().ok().and_then(|s| s.active_gid.clone()); - - if let Some(g) = gid { - let _ = aria2_rpc_call("aria2.forceRemove", json!([g.clone()])); - let _ = aria2_rpc_call("aria2.removeDownloadResult", json!([g])); - } - - // mark cancelled + delete temp - let active_it = state.lock().ok().and_then(|s| s.queue.front().cloned()); - if let Some(it) = active_it { - if let Ok(mut st) = state.lock() { - if let Some(front) = st.queue.front_mut() { - front.status = DownloadStatus::Cancelled; - } - st.last_message = Some("cancelled active download".into()); - st.active_gid = None; - } - let _ = delete_temp_for(&it); - } - - // remove from queue - if let Ok(mut st) = state.lock() { - let _ = st.queue.pop_front(); - } - } else { - // cancel queued item (not active) - if let Ok(mut st) = state.lock() { - if index < st.queue.len() { - let mut v: Vec<_> = st.queue.drain(..).collect(); - if index < v.len() { - let mut it = v.remove(index); - it.status = DownloadStatus::Cancelled; - let _ = delete_temp_for(&it); - } - st.queue = v.into(); - st.last_message = Some("removed from queue".into()); - } - } - } - } - - DownloadCommand::RemoveQueued { index } => { - if let Ok(mut st) = state.lock() { - if index > 0 && index < st.queue.len() { - let mut v: Vec<_> = st.queue.drain(..).collect(); - let mut it = v.remove(index); - it.status = DownloadStatus::Cancelled; - let _ = delete_temp_for(&it); - st.queue = v.into(); - st.last_message = Some("removed from queue".into()); - } - } - } - - DownloadCommand::EnqueuePlaylistItem { item, playlist_name, playlist_index } => { - // If already downloaded, just create the playlist link immediately. - if let Some(local) = local_media_path_for_item_id(&item.id) { - let _ = ensure_playlist_link(&local, &playlist_name, playlist_index, &item.name); - if let Ok(mut st) = state.lock() { - st.last_message = Some("playlist link created".into()); - } - continue; - } - - // Build a DownloadItem like normal, but attach a playlist link intent. - let tt = item.item_type.as_deref().unwrap_or(""); - let mut di = match tt { - "Audio" => make_download_item_track(&item), - "Episode" => make_download_item_episode(&item), - "Movie" => make_download_item_movie(&item), - _ => make_download_item_other(&item), - }; - - let mut di = match di { - Ok(v) => v, - Err(e) => { - if let Ok(mut st) = state.lock() { - st.last_message = Some(format!("enqueue playlist item failed: {e}")); - } - continue; - } - }; - - di.playlist_links.push(PlaylistLink { - playlist_name, - playlist_index, - title: item.name.clone(), - }); - - if let Ok(mut st) = state.lock() { - // If same media already queued, merge playlist link intents. - if let Some(existing) = st.queue.iter_mut().find(|q| q.item_id == di.item_id && q.final_rel_path == di.final_rel_path) { - for pl in di.playlist_links.drain(..) { - let already = existing.playlist_links.iter().any(|x| - x.playlist_name == pl.playlist_name && x.playlist_index == pl.playlist_index - ); - if !already { - existing.playlist_links.push(pl); - } - } - st.last_message = Some("queued (merged playlist link)".into()); - } else { - st.queue.push_back(di); - st.last_message = Some("queued (playlist)".into()); - } - } - } - - - DownloadCommand::Shutdown => { - shutdown = true; - } - } - } - - if shutdown { - break; - } - - // ------------------------- - // 2) If nothing active, start next - // ------------------------- - let (has_queue, has_active_gid, next_item) = { - let st = match state.lock() { - Ok(s) => s, - Err(_) => break, - }; - - let has_queue = !st.queue.is_empty(); - let has_active_gid = st.active_gid.is_some(); - let next_item = st.queue.front().cloned(); - - (has_queue, has_active_gid, next_item) - }; - - if !has_queue { - thread::sleep(Duration::from_millis(120)); - continue; - } - - if !has_active_gid { - if let Some(it) = next_item { - // mark downloading - if let Ok(mut st) = state.lock() { - if let Some(front) = st.queue.front_mut() { - front.status = DownloadStatus::Downloading; - } - } - - match aria2_add_download(&cfg.base_url, &token, &it) { - Ok(gid) => { - if let Ok(mut st) = state.lock() { - if let Some(front) = st.queue.front_mut() { - front.gid = Some(gid.clone()); - front.status = DownloadStatus::Downloading; - } - st.active_gid = Some(gid); - st.last_message = Some(format!("downloading {}", it.title)); - } - } - Err(e) => { - if let Ok(mut st) = state.lock() { - if let Some(front) = st.queue.front_mut() { - front.status = DownloadStatus::Failed(e.clone()); - } - st.last_message = Some(format!("download failed: {e}")); - let _ = st.queue.pop_front(); - st.active_gid = None; - } - } - } - } - - thread::sleep(Duration::from_millis(80)); - continue; - } - - // ------------------------- - // 3) Poll progress + handle completion - // ------------------------- - poll_progress_for_front(&state); - - match aria2_front_status(&state).as_deref() { - Some("complete") => { - // mark finalizing - if let Ok(mut st) = state.lock() { - if let Some(front) = st.queue.front_mut() { - front.status = DownloadStatus::Finalizing; - } - } - - // finalize - if let Err(e) = finalize_front(&state) { - if let Ok(mut st) = state.lock() { - if let Some(front) = st.queue.front_mut() { - front.status = DownloadStatus::Failed(e.clone()); - } - st.last_message = Some(e); - let _ = st.queue.pop_front(); - st.active_gid = None; - } - } else { - // remove aria2 result - let gid = state.lock().ok().and_then(|s| s.active_gid.clone()); - if let Some(g) = gid { - let _ = aria2_rpc_call("aria2.removeDownloadResult", json!([g])); - } - - if let Ok(mut st) = state.lock() { - let _ = st.queue.pop_front(); - st.active_gid = None; - st.last_message = Some("download complete".into()); - } - } - } - - Some("error") => { - if let Ok(mut st) = state.lock() { - if let Some(front) = st.queue.front_mut() { - front.status = DownloadStatus::Failed("aria2 status=error".into()); - } - st.last_message = Some("aria2 failed".into()); - let _ = st.queue.pop_front(); - st.active_gid = None; - } - } - - Some("active") | Some("waiting") => { - thread::sleep(Duration::from_millis(200)); - } - - _ => { - thread::sleep(Duration::from_millis(200)); - } - } - } - - // ------------------------- - // shutdown cleanup - // ------------------------- - let _ = aria2_rpc_call("aria2.shutdown", json!([])); - let _ = aria2_daemon.kill(); - let _ = aria2_daemon.wait(); - let _ = cleanup_temp_dir(); -} - - - -// Each download gets its own isolated temp folder so deletes can't affect others. -// temp/jobs// -fn temp_dir_for(it: &DownloadItem) -> Result { - let tmp = temp_root()?; - Ok(tmp.join("jobs").join(&it.item_id)) -} - -fn delete_temp_for(it: &DownloadItem) -> Result<(), String> { - let dir = temp_dir_for(it)?; - if dir.exists() { - fs::remove_dir_all(&dir).map_err(|e| format!("rm temp: {e}"))?; - } - Ok(()) -} - -fn aria2_add_download(base_url: &str, token: &str, it: &DownloadItem) -> Result { - let base = base_url.trim_end_matches('/'); - let url = format!("{}/Items/{}/Download", base, it.item_id); - - let tmp_dir = temp_dir_for(it)?; - if tmp_dir.exists() { - let _ = fs::remove_dir_all(&tmp_dir); - } - fs::create_dir_all(&tmp_dir).map_err(|e| format!("mk temp dir: {e}"))?; - - // aria2 options - let opts = json!({ - "dir": tmp_dir.to_string_lossy(), - "allow-overwrite": "true", - "auto-file-renaming": "false", - "continue": "true", - "check-certificate": "true", - "content-disposition": "true", - "header": format!("X-Emby-Token: {token}"), - }); - - // params: [ [uri], options ] - let result = aria2_rpc_call("aria2.addUri", json!([[url], opts]))?; - - result.as_str() - .map(|s| s.to_string()) - .ok_or_else(|| format!("aria2.addUri returned unexpected result: {result}")) -} - -fn poll_progress_for_front(state: &Arc>) { - let gid = { - let st = match state.lock() { - Ok(s) => s, - Err(_) => return, - }; - st.queue.front().and_then(|it| it.gid.clone()) - }; - - let gid = match gid { - Some(g) => g, - None => return, - }; - - let res = match aria2_rpc_call("aria2.tellStatus", json!([gid, ["status","completedLength","totalLength","downloadSpeed"]])) { - Ok(v) => v, - Err(_) => return, - }; - - let status = res.get("status").and_then(|v| v.as_str()).unwrap_or(""); - let completed = res.get("completedLength").and_then(|v| v.as_str()).and_then(|s| s.parse::().ok()).unwrap_or(0); - let total = res.get("totalLength").and_then(|v| v.as_str()).and_then(|s| s.parse::().ok()); - let speed = res.get("downloadSpeed").and_then(|v| v.as_str()).and_then(|s| s.parse::().ok()).unwrap_or(0); - - let eta = match (total, speed) { - (Some(t), s) if t > completed && s > 0 => Some((t - completed) / s), - _ => None, - }; - - if let Ok(mut st) = state.lock() { - if let Some(front) = st.queue.front_mut() { - front.bytes_downloaded = completed; - front.bytes_total = total; - front.download_speed = speed; - front.eta_secs = eta; - - // optional: if aria2 says error, reflect it - if status == "error" { - front.status = DownloadStatus::Failed("aria2 error".into()); - } - } - } -} - - -fn finalize_front(state: &Arc>) -> Result<(), String> { - let (it, tmp_dir, final_abs_watch) = { - let st = state.lock().map_err(|_| "lock poisoned".to_string())?; - let it = st.queue.front().ok_or_else(|| "no front".to_string())?.clone(); - let tmp_dir = tmp_dir_of_item(&it)?; - let dl_root = downloads_root()?; - let final_abs_watch = dl_root.join(&it.final_rel_path); - (it, tmp_dir, final_abs_watch) - }; - - // Ensure final dir exists - let final_parent = final_abs_watch - .parent() - .ok_or_else(|| "no final parent".to_string())?; - fs::create_dir_all(final_parent).map_err(|e| format!("mk final dir: {e}"))?; - - // Find real downloaded file (with real extension) inside tmp_dir - let real_file = find_real_downloaded_file(&tmp_dir)?; - - // Build final filename: - // final_rel_path ends in "...something.watch/.listen" - // Keep stem, replace extension with actual media extension - let real_ext = real_file - .extension() - .and_then(|s| s.to_str()) - .unwrap_or("") - .to_string(); - - let stem = final_abs_watch - .file_stem() - .and_then(|s| s.to_str()) - .ok_or_else(|| "bad final filename stem".to_string())? - .to_string(); - - let final_abs = if real_ext.is_empty() { - final_abs_watch.clone() - } else { - final_parent.join(format!("{stem}.{real_ext}")) - }; - - // Move file into place - fs::rename(&real_file, &final_abs).map_err(|e| format!("move to final failed: {e}"))?; - - // ✅ NEW: write marker so we can detect "downloaded" later - // (If marker fails, we still consider download successful.) - let _ = write_download_marker(&final_abs, &it.item_id); - - // NEW: create playlist links (best-effort) - for pl in &it.playlist_links { - let _ = ensure_playlist_link(&final_abs, &pl.playlist_name, pl.playlist_index, &pl.title); - } - - // Cleanup temp folder fully - let _ = fs::remove_dir_all(&tmp_dir); - - Ok(()) -} - - -const ARIA2_RPC_HOST: &str = "127.0.0.1"; -const ARIA2_RPC_PORT: u16 = 6800; - -fn aria2_http_post(body: &str) -> Result { - let mut stream = TcpStream::connect((ARIA2_RPC_HOST, ARIA2_RPC_PORT)) - .map_err(|e| format!("aria2 rpc connect failed: {e}"))?; - - let req = format!( - "POST /jsonrpc HTTP/1.1\r\nHost: {}:{}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - ARIA2_RPC_HOST, - ARIA2_RPC_PORT, - body.len(), - body - ); - - stream.write_all(req.as_bytes()).map_err(|e| format!("aria2 rpc write failed: {e}"))?; - - let mut resp = String::new(); - stream.read_to_string(&mut resp).map_err(|e| format!("aria2 rpc read failed: {e}"))?; - - // split headers/body - let (_hdrs, body) = resp.split_once("\r\n\r\n").ok_or("aria2 rpc bad http response")?; - Ok(body.to_string()) -} - -fn aria2_rpc_call(method: &str, params: Value) -> Result { - let payload = json!({ - "jsonrpc": "2.0", - "id": "cjtui", - "method": method, - "params": params, - }); - - let body = payload.to_string(); - let raw = aria2_http_post(&body)?; - let v: Value = serde_json::from_str(raw.trim()).map_err(|e| format!("aria2 rpc json parse failed: {e} ({raw})"))?; - - if let Some(err) = v.get("error") { - return Err(format!("aria2 rpc error: {err}")); - } - Ok(v.get("result").cloned().unwrap_or(Value::Null)) -} - -fn start_aria2_daemon() -> Result { - let mut cmd = Command::new("aria2c"); - cmd.arg("--enable-rpc=true") - .arg("--rpc-listen-all=false") - .arg(format!("--rpc-listen-port={}", ARIA2_RPC_PORT)) - .arg("--rpc-allow-origin-all=true") - .arg("--rpc-max-request-size=2M") - .arg("--check-certificate=true") - .arg("--continue=true") - .arg("--max-tries=0") - .arg("--retry-wait=5") - .arg("--timeout=60") - .arg("--connect-timeout=10") - .arg("--summary-interval=1") - .arg("--console-log-level=warn") - .arg("--file-allocation=none") - .arg("--content-disposition=true") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - - let child = cmd.spawn().map_err(|e| format!("spawn aria2c rpc daemon failed: {e}"))?; - - // quick readiness check (try rpc a few times) - let start = Instant::now(); - while start.elapsed() < Duration::from_millis(800) { - if aria2_rpc_call("aria2.getVersion", json!([])).is_ok() { - return Ok(child); - } - thread::sleep(Duration::from_millis(40)); - } - - Err("aria2 rpc daemon did not become ready".into()) -} - -fn aria2_front_status(state: &Arc>) -> Option { - let gid = { - let st = state.lock().ok()?; - st.queue.front().and_then(|it| it.gid.clone()) - }?; - let res = aria2_rpc_call("aria2.tellStatus", json!([gid, ["status"]])).ok()?; - res.get("status").and_then(|v| v.as_str()).map(|s| s.to_string()) -} - - -/// If this item_id has a local download marker, return the local media file path. -/// Marker files are written alongside the media file as: -/// . -/// where DOWNLOAD_MARKER_EXT == "cjtui.json" -pub fn local_media_path_for_item_id(item_id: &str) -> Option { - fn walk(dir: &Path, item_id: &str) -> Option { - if !dir.exists() { - return None; - } - - let rd = fs::read_dir(dir).ok()?; - for ent in rd { - let p = ent.ok()?.path(); - - if p.is_dir() { - if let Some(found) = walk(&p, item_id) { - return Some(found); - } - continue; - } - - let name = p.file_name().and_then(|s| s.to_str()).unwrap_or(""); - if !name.ends_with(DOWNLOAD_MARKER_EXT) { - continue; - } - - let raw = fs::read_to_string(&p).ok()?; - let v: Value = serde_json::from_str(raw.trim()).ok()?; - let id = v.get("item_id").and_then(|x| x.as_str())?; - - if id == item_id { - // Marker file is "." - // So the media file path is the same path with that suffix stripped. - let suffix = format!(".{DOWNLOAD_MARKER_EXT}"); - if !name.ends_with(&suffix) { - continue; - } - let media_name = &name[..name.len() - suffix.len()]; - let media_path = p.with_file_name(media_name); - - if media_path.exists() { - return Some(media_path); - } - } - } - - None - } - - let root = downloads_root().ok()?; - walk(&root, item_id) -} - - -// Delete ALL local copies of an item by scanning *.cjtui.json markers recursively. -// Returns how many media files we attempted to remove (count of matched markers). -pub fn delete_local_by_item_id(item_id: &str) -> Result { - let root = downloads_root()?; - let mut deleted = 0usize; - delete_in_dir_by_item_id(&root, item_id, &mut deleted)?; - Ok(deleted) -} - -// Delete local content based on a concrete local file path. -// If there is a sibling marker, we read item_id and delete ALL copies (same as online behavior). -// If there is no marker, we delete just that file + sidecars. -pub fn delete_local_by_path(path: &Path) -> Result { - // If there is a sibling marker, prefer item_id-based deletion (removes hardlinks elsewhere too). - if let Some(item_id) = item_id_for_media_path(path)? { - return delete_local_by_item_id(&item_id); - } - - // No marker => delete just this file + any obvious sidecars. - delete_file_and_sidecars(path)?; - Ok(1) -} - -// -------------------- -// Internal helpers -// -------------------- - -fn delete_in_dir_by_item_id(dir: &Path, item_id: &str, deleted: &mut usize) -> Result<(), String> { - if !dir.exists() { - return Ok(()); - } - - for ent in fs::read_dir(dir).map_err(|e| format!("read_dir failed: {e}"))? { - let p = ent.map_err(|e| format!("dir entry failed: {e}"))?.path(); - - if p.is_dir() { - delete_in_dir_by_item_id(&p, item_id, deleted)?; - continue; - } - - // marker files only - let name = p.file_name().and_then(|s| s.to_str()).unwrap_or(""); - if !name.ends_with(DOWNLOAD_MARKER_EXT) { - continue; - } - - let raw = match fs::read_to_string(&p) { - Ok(s) => s, - Err(_) => continue, - }; - - let v: Value = match serde_json::from_str(raw.trim()) { - Ok(v) => v, - Err(_) => continue, - }; - - let mid = match v.get("item_id").and_then(|x| x.as_str()) { - Some(s) => s.trim(), - None => continue, - }; - - if mid != item_id { - continue; - } - - // If we matched the item_id, delete: - // - media file referenced by marker (if present) - // - marker itself - // - media sidecars (.aria2/.part) - if let Some(media_name) = v.get("media_file").and_then(|x| x.as_str()) { - let media_path = p - .parent() - .unwrap_or(dir) - .join(media_name); - - let _ = delete_file_and_sidecars(&media_path); - } - - let _ = fs::remove_file(&p); // remove marker - *deleted += 1; - } - - Ok(()) -} - -// Reads sibling marker ".cjtui.json" and returns item_id if present. -fn item_id_for_media_path(media_path: &Path) -> Result, String> { - let fname = match media_path.file_name().and_then(|s| s.to_str()) { - Some(s) => s, - None => return Ok(None), - }; - - let marker = media_path - .parent() - .unwrap_or_else(|| Path::new(".")) - .join(format!("{fname}.{DOWNLOAD_MARKER_EXT}")); - - if !marker.exists() { - return Ok(None); - } - - let raw = fs::read_to_string(&marker).map_err(|e| format!("read marker failed: {e}"))?; - let v: Value = serde_json::from_str(raw.trim()).map_err(|e| format!("marker json parse failed: {e}"))?; - - Ok(v.get("item_id").and_then(|x| x.as_str()).map(|s| s.to_string())) -} - -fn delete_file_and_sidecars(path: &Path) -> Result<(), String> { - // delete main file - let _ = fs::remove_file(path); - - // delete sibling marker (if any) - if let Some(fname) = path.file_name().and_then(|s| s.to_str()) { - let marker = path - .parent() - .unwrap_or_else(|| Path::new(".")) - .join(format!("{fname}.{DOWNLOAD_MARKER_EXT}")); - let _ = fs::remove_file(marker); - } - - // delete obvious aria2 artifacts - if let Some(fname) = path.file_name().and_then(|s| s.to_str()) { - let parent = path.parent().unwrap_or_else(|| Path::new(".")); - - let aria2 = parent.join(format!("{fname}.aria2")); - let part = parent.join(format!("{fname}.part")); - - let _ = fs::remove_file(aria2); - let _ = fs::remove_file(part); - } - - Ok(()) -} + +use std::{ + collections::{VecDeque, HashSet}, + fs, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + sync::{mpsc, Arc, Mutex}, + thread, + time::{Duration, Instant}, + net::TcpStream, + io::{Read, Write}, +}; + +use serde_json::{json, Value}; + +use crate::core::{config::Config, jellyfin::{JellyfinClient, JfItem, LibraryQuery}, paths}; + +#[derive(Clone, Debug)] +pub enum DownloadKind { + Movie, + Episode { series: String, season: Option, episode: Option }, + Track { artist: Option, album: Option, track_no: Option }, + Other, +} + +#[derive(Clone, Debug)] +pub enum DownloadStatus { + Queued, + Downloading, + Finalizing, + Completed, + Failed(String), + Cancelled, +} + +#[derive(Clone, Debug)] +pub struct DownloadItem { + pub item_id: String, + pub title: String, + pub kind: DownloadKind, + pub final_rel_path: PathBuf, // relative to downloads root + pub status: DownloadStatus, + pub added_at: Instant, + + pub bytes_downloaded: u64, + pub bytes_total: Option, + pub download_speed: u64, // bytes/sec + pub eta_secs: Option, // seconds remaining + + // aria2 + pub gid: Option, + + pub playlist_links: Vec, +} + +#[derive(Debug)] +pub enum DownloadCommand { + Enqueue(Vec), + EnqueuePlaylistItem { + item: JfItem, + playlist_name: String, + playlist_index: usize, + }, + Cancel { index: usize }, + RemoveQueued { index: usize }, + Shutdown, +} + + +#[derive(Default)] +pub struct DownloadState { + pub queue: VecDeque, + pub active_gid: Option, + pub last_message: Option, +} + +pub struct DownloadManager { + tx: mpsc::Sender, + pub state: Arc>, +} + +#[derive(Clone, Debug)] +pub struct LocalEntry { + pub item_id: String, + pub title: String, + pub path: PathBuf, +} + +#[derive(Clone, Debug)] +pub struct PlaylistLink { + pub playlist_name: String, + pub playlist_index: usize, // 0-based + pub title: String, +} + +/// Scan downloads root for marker files and return local playable entries. +/// Title is taken from marker JSON if present, else from the media filename stem. +pub fn scan_local_entries() -> Vec { + fn walk(dir: &Path, out: &mut Vec) { + let rd = match std::fs::read_dir(dir) { + Ok(r) => r, + Err(_) => return, + }; + + for ent in rd.flatten() { + let p = ent.path(); + + if p.is_dir() { + walk(&p, out); + continue; + } + + let name = match p.file_name().and_then(|s| s.to_str()) { + Some(s) => s, + None => continue, + }; + + if !name.ends_with(DOWNLOAD_MARKER_EXT) { + continue; + } + + let raw = match std::fs::read_to_string(&p) { + Ok(s) => s, + Err(_) => continue, + }; + + let v: serde_json::Value = match serde_json::from_str(raw.trim()) { + Ok(v) => v, + Err(_) => continue, + }; + + let item_id = match v.get("item_id").and_then(|x| x.as_str()) { + Some(s) if !s.is_empty() => s.to_string(), + _ => continue, + }; + + // derive media path: "." -> "" + let suffix = format!(".{DOWNLOAD_MARKER_EXT}"); + if !name.ends_with(&suffix) { + continue; + } + let media_name = &name[..name.len() - suffix.len()]; + let media_path = p.with_file_name(media_name); + if !media_path.exists() { + continue; + } + + // title: prefer json field if you ever add it, else from filename + let title = v + .get("title") + .and_then(|x| x.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| { + media_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(media_name) + .to_string() + }); + + out.push(LocalEntry { + item_id, + title, + path: media_path, + }); + } + } + + let mut out = Vec::new(); + if let Ok(root) = downloads_root() { + walk(&root, &mut out); + } + + // Stable sort: title + out.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())); + out +} + + +impl DownloadManager { + pub fn start(cfg: Config) -> Self { + let state = Arc::new(Mutex::new(DownloadState::default())); + let (tx, rx) = mpsc::channel::(); + + // cleanup temp on start + let _ = cleanup_temp_dir(); + + let state_bg = Arc::clone(&state); + thread::spawn(move || worker_loop(cfg, state_bg, rx)); + + Self { tx, state } + } + + pub fn send(&self, cmd: DownloadCommand) { + let _ = self.tx.send(cmd); + } +} + +// ---------- directory helpers ---------- + +const DOWNLOAD_MARKER_EXT: &str = "cjtui.json"; + +fn marker_path_for_media(media_path: &Path) -> PathBuf { + // Example: + // Movie.mkv -> Movie.mkv.cjtui.json + let file_name = media_path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("download"); + + media_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(format!("{file_name}.{DOWNLOAD_MARKER_EXT}")) +} + +fn write_download_marker(media_path: &Path, item_id: &str) -> Result<(), String> { + let marker = marker_path_for_media(media_path); + + let payload = json!({ + "item_id": item_id, + "saved_at_unix": std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .map(|d| d.as_secs()) + .unwrap_or(0), + "media_file": media_path.file_name().and_then(|s| s.to_str()).unwrap_or(""), + }); + + fs::write(&marker, payload.to_string()) + .map_err(|e| format!("write marker failed: {e}"))?; + + Ok(()) +} + +fn scan_dir_for_markers(dir: &Path, out: &mut HashSet) -> Result<(), String> { + if !dir.exists() { + return Ok(()); + } + + for ent in fs::read_dir(dir).map_err(|e| format!("read_dir failed: {e}"))? { + let p = ent.map_err(|e| format!("dir entry failed: {e}"))?.path(); + + if p.is_dir() { + scan_dir_for_markers(&p, out)?; + continue; + } + + // Look for *.cjtui.json + let name = p.file_name().and_then(|s| s.to_str()).unwrap_or(""); + if !name.ends_with(DOWNLOAD_MARKER_EXT) { + continue; + } + + let raw = match fs::read_to_string(&p) { + Ok(s) => s, + Err(_) => continue, + }; + + let v: Value = match serde_json::from_str(raw.trim()) { + Ok(v) => v, + Err(_) => continue, + }; + + if let Some(id) = v.get("item_id").and_then(|x| x.as_str()) { + if !id.trim().is_empty() { + out.insert(id.to_string()); + } + } + } + + Ok(()) +} + +/// Public: scan the downloads root and return set of downloaded Jellyfin item ids. +pub fn scan_downloaded_ids() -> Result, String> { + let root = downloads_root()?; + let mut out = HashSet::new(); + scan_dir_for_markers(&root, &mut out)?; + Ok(out) +} + + +pub fn downloads_root() -> Result { + let dir = paths::app_config_dir().ok_or_else(|| "no config dir".to_string())?; + Ok(dir.join("downloads")) +} + +pub fn temp_root() -> Result { + let dir = paths::app_config_dir().ok_or_else(|| "no config dir".to_string())?; + Ok(dir.join("temp")) +} + +pub fn ensure_dirs() -> Result<(), String> { + fs::create_dir_all(downloads_root().map_err(|e| e.to_string())?).map_err(|e| e.to_string())?; + fs::create_dir_all(temp_root().map_err(|e| e.to_string())?).map_err(|e| e.to_string())?; + Ok(()) +} + +pub fn cleanup_temp_dir() -> Result<(), String> { + let tmp = temp_root()?; + if tmp.exists() { + fs::remove_dir_all(&tmp).map_err(|e| format!("remove temp: {e}"))?; + } + fs::create_dir_all(&tmp).map_err(|e| format!("create temp: {e}"))?; + fs::create_dir_all(tmp.join("jobs")).map_err(|e| format!("create temp/jobs: {e}"))?; + Ok(()) +} + +// ---------- queue building (expansion) ---------- + +pub fn expand_for_enqueue( + jf: &JellyfinClient, + user_id: &str, + item: &JfItem, +) -> Result, String> { + let t = item.item_type.as_deref().unwrap_or(""); + match t { + "Movie" => Ok(vec![make_download_item_movie(item)?]), + "Episode" => Ok(vec![make_download_item_episode(item)?]), + "Audio" => Ok(vec![make_download_item_track(item)?]), + + // Containers: + "Season" => { + // episodes under Season (non-recursive) + list_all_items(jf, user_id, LibraryQuery::all().with_parent(item.id.clone()).with_item_types("Episode"), false)? + .into_iter() + .map(|ep| make_download_item_episode(&ep)) + .collect() + } + "Series" => { + // episodes under Series (recursive) + let mut q = LibraryQuery::all() + .with_parent(item.id.clone()) + .with_item_types("Episode"); + q.recursive = true; + list_all_items(jf, user_id, q, true)? + .into_iter() + .map(|ep| make_download_item_episode(&ep)) + .collect() + } + "MusicAlbum" => { + // tracks under album + list_all_items(jf, user_id, LibraryQuery::all().with_parent(item.id.clone()).with_item_types("Audio"), false)? + .into_iter() + .map(|tr| make_download_item_track(&tr)) + .collect() + } + "Playlist" => { + // playlist items via special endpoint + list_all_playlist_items(jf, user_id, &item.id)? + .into_iter() + .map(|it| { + let tt = it.item_type.as_deref().unwrap_or(""); + match tt { + "Audio" => make_download_item_track(&it), + "Episode" => make_download_item_episode(&it), + "Movie" => make_download_item_movie(&it), + _ => make_download_item_other(&it), + } + }) + .collect() + } + + _ => Ok(vec![make_download_item_other(item)?]), + } +} + +fn list_all_items( + jf: &JellyfinClient, + user_id: &str, + query: LibraryQuery, + _maybe_large: bool, +) -> Result, String> { + // user-driven action, so paging here is fine + const LIMIT: usize = 200; + let mut start = 0usize; + let mut out = Vec::new(); + loop { + let page = jf.list_items(user_id, query.clone(), start, LIMIT)?; + out.extend(page.items.into_iter()); + if out.len() >= page.total || page.total == 0 { + break; + } + start += LIMIT; + } + Ok(out) +} + +fn list_all_playlist_items(jf: &JellyfinClient, user_id: &str, playlist_id: &str) -> Result, String> { + const LIMIT: usize = 200; + let mut start = 0usize; + let mut out = Vec::new(); + loop { + let page = jf.playlist_items(user_id, playlist_id, start, LIMIT)?; + out.extend(page.items.into_iter()); + if out.len() >= page.total || page.total == 0 { + break; + } + start += LIMIT; + } + Ok(out) +} + +// ---------- naming ---------- + +fn playlist_link_abs_path( + downloads_root: &Path, + playlist_name: &str, + playlist_index: usize, + title: &str, + ext: &str, +) -> PathBuf { + let plist = sanitize_component(playlist_name); + let t = sanitize_component(title); + + let idx = format!("{:03}", playlist_index + 1); + let file = if ext.is_empty() { + format!("{idx} {t}") + } else { + format!("{idx} {t}.{ext}") + }; + + downloads_root + .join("playlists") + .join(plist) + .join(file) +} + +fn ensure_playlist_link( + final_media_path: &Path, + playlist_name: &str, + playlist_index: usize, + title: &str, +) -> Result<(), String> { + let dl_root = downloads_root()?; + + let ext = final_media_path + .extension() + .and_then(|s| s.to_str()) + .unwrap_or(""); + + let link_abs = playlist_link_abs_path(&dl_root, playlist_name, playlist_index, title, ext); + + if let Some(parent) = link_abs.parent() { + fs::create_dir_all(parent).map_err(|e| format!("mk playlist dir: {e}"))?; + } + + // If exists, do nothing (idempotent) + if link_abs.exists() { + return Ok(()); + } + + // Prefer hardlink (fast, no duplication). If cross-device, fall back to copy. + match fs::hard_link(final_media_path, &link_abs) { + Ok(_) => Ok(()), + Err(e) => { + // EXDEV / permission / etc -> fallback copy + fs::copy(final_media_path, &link_abs) + .map(|_| ()) + .map_err(|e2| format!("playlist link failed: {e} / copy failed: {e2}")) + } + } +} + + +fn sanitize_component(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + // conservative sanitization for Linux/Windows compatibility + if matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' ) { + out.push('_'); + } else { + out.push(ch); + } + } + let out = out.trim().to_string(); + if out.is_empty() { "_".into() } else { out.chars().take(100).collect() } +} + +fn make_download_item_movie(item: &JfItem) -> Result { + let name = sanitize_component(&item.name); + let rel = PathBuf::from("movies").join(format!("{name}.watch")); + Ok(DownloadItem { + item_id: item.id.clone(), + title: item.name.clone(), + kind: DownloadKind::Movie, + final_rel_path: rel, + status: DownloadStatus::Queued, + added_at: Instant::now(), + bytes_downloaded: 0, + bytes_total: None, + download_speed: 0, + eta_secs: None, + gid: None, + playlist_links: Vec::new(), + }) +} + +fn make_download_item_episode(item: &JfItem) -> Result { + let series = item.series_name.clone().unwrap_or_else(|| "Unknown Show".into()); + let series_s = sanitize_component(&series); + let season_no = item.parent_index_number; + let ep_no = item.index_number; + + let season_folder = season_no + .map(|n| format!("Season-{}", n)) + .unwrap_or_else(|| "Season-0".into()); + + let title_s = sanitize_component(&item.name); + + let filename = match (season_no, ep_no) { + (Some(s), Some(e)) => format!("S{:02}E{:02} - {title_s}.watch", s, e), + _ => format!("{title_s}.watch"), + }; + + let rel = PathBuf::from("shows") + .join(series_s) + .join(season_folder) + .join(filename); + + Ok(DownloadItem { + item_id: item.id.clone(), + title: format!("{series} - {}", item.name), + kind: DownloadKind::Episode { + series, + season: season_no, + episode: ep_no, + }, + final_rel_path: rel, + status: DownloadStatus::Queued, + added_at: Instant::now(), + + bytes_downloaded: 0, + bytes_total: None, + download_speed: 0, + eta_secs: None, + + gid: None, + playlist_links: Vec::new(), + }) +} + +fn make_download_item_track(item: &JfItem) -> Result { + let artist = item + .album_artist + .clone() + .or_else(|| item.artists.get(0).cloned()); + + let album = item.album.clone(); + let track_no = item.index_number; + + let artist_s = sanitize_component(artist.as_deref().unwrap_or("Unknown Artist")); + let album_s = sanitize_component(album.as_deref().unwrap_or("Unknown Album")); + let title_s = sanitize_component(&item.name); + + let filename = match track_no { + Some(n) => format!("{:02} - {title_s}.listen", n), + None => format!("{title_s}.listen"), + }; + + let rel = PathBuf::from("music") + .join(artist_s) + .join(album_s) + .join(filename); + + Ok(DownloadItem { + item_id: item.id.clone(), + title: item.name.clone(), + kind: DownloadKind::Track { + artist, + album, + track_no, + }, + final_rel_path: rel, + status: DownloadStatus::Queued, + added_at: Instant::now(), + + bytes_downloaded: 0, + bytes_total: None, + download_speed: 0, + eta_secs: None, + + gid: None, + playlist_links: Vec::new(), + }) +} + +fn make_download_item_other(item: &JfItem) -> Result { + let name = sanitize_component(&item.name); + let rel = PathBuf::from("other").join(format!("{name}.watch")); + + Ok(DownloadItem { + item_id: item.id.clone(), + title: item.name.clone(), + kind: DownloadKind::Other, + final_rel_path: rel, + status: DownloadStatus::Queued, + added_at: Instant::now(), + + bytes_downloaded: 0, + bytes_total: None, + download_speed: 0, + eta_secs: None, + + gid: None, + playlist_links: Vec::new(), + }) +} + + +fn tmp_dir_of_item(it: &DownloadItem) -> Result { + temp_dir_for(it) +} + +// Find the actual downloaded media file in tmp dir. +// We ignore aria2 metadata (*.aria2) and partials (*.part). +fn find_real_downloaded_file(tmp_dir: &Path) -> Result { + let mut best: Option<(u64, PathBuf)> = None; + + let rd = fs::read_dir(tmp_dir).map_err(|e| format!("read temp dir: {e}"))?; + for ent in rd { + let p = ent.map_err(|e| format!("read dir entry: {e}"))?.path(); + if !p.is_file() { + continue; + } + let name = p.file_name().and_then(|s| s.to_str()).unwrap_or(""); + + if name.ends_with(".aria2") || name.ends_with(".part") { + continue; + } + + let sz = fs::metadata(&p).map_err(|e| format!("stat temp file: {e}"))?.len(); + match &best { + None => best = Some((sz, p)), + Some((best_sz, _)) => { + if sz > *best_sz { + best = Some((sz, p)); + } + } + } + } + + best.map(|(_, p)| p) + .ok_or_else(|| "no downloaded media file found in temp dir".to_string()) +} + + + +// ---------- worker loop ---------- + +fn worker_loop(cfg: Config, state: Arc>, rx: mpsc::Receiver) { + let _ = ensure_dirs(); + + let token = cfg.access_token.clone().unwrap_or_default(); + + let mut shutdown = false; + + let mut aria2_daemon = match start_aria2_daemon() { + Ok(c) => c, + Err(e) => { + if let Ok(mut st) = state.lock() { + st.last_message = Some(format!("downloads disabled: {e}")); + } + return; + } + }; + + while !shutdown { + // ------------------------- + // 1) Handle incoming commands + // ------------------------- + while let Ok(cmd) = rx.try_recv() { + match cmd { + DownloadCommand::Enqueue(mut items) => { + if let Ok(mut st) = state.lock() { + // dedupe by item_id + final_rel_path + for it in items.drain(..) { + let already = st.queue.iter().any(|q| { + q.item_id == it.item_id && q.final_rel_path == it.final_rel_path + }); + if !already { + st.queue.push_back(it); + } + } + st.last_message = Some("queued".into()); + } + } + + DownloadCommand::Cancel { index } => { + if index == 0 { + // cancel the active download (front) + let gid = state.lock().ok().and_then(|s| s.active_gid.clone()); + + if let Some(g) = gid { + let _ = aria2_rpc_call("aria2.forceRemove", json!([g.clone()])); + let _ = aria2_rpc_call("aria2.removeDownloadResult", json!([g])); + } + + // mark cancelled + delete temp + let active_it = state.lock().ok().and_then(|s| s.queue.front().cloned()); + if let Some(it) = active_it { + if let Ok(mut st) = state.lock() { + if let Some(front) = st.queue.front_mut() { + front.status = DownloadStatus::Cancelled; + } + st.last_message = Some("cancelled active download".into()); + st.active_gid = None; + } + let _ = delete_temp_for(&it); + } + + // remove from queue + if let Ok(mut st) = state.lock() { + let _ = st.queue.pop_front(); + } + } else { + // cancel queued item (not active) + if let Ok(mut st) = state.lock() { + if index < st.queue.len() { + let mut v: Vec<_> = st.queue.drain(..).collect(); + if index < v.len() { + let mut it = v.remove(index); + it.status = DownloadStatus::Cancelled; + let _ = delete_temp_for(&it); + } + st.queue = v.into(); + st.last_message = Some("removed from queue".into()); + } + } + } + } + + DownloadCommand::RemoveQueued { index } => { + if let Ok(mut st) = state.lock() { + if index > 0 && index < st.queue.len() { + let mut v: Vec<_> = st.queue.drain(..).collect(); + let mut it = v.remove(index); + it.status = DownloadStatus::Cancelled; + let _ = delete_temp_for(&it); + st.queue = v.into(); + st.last_message = Some("removed from queue".into()); + } + } + } + + DownloadCommand::EnqueuePlaylistItem { item, playlist_name, playlist_index } => { + // If already downloaded, just create the playlist link immediately. + if let Some(local) = local_media_path_for_item_id(&item.id) { + let _ = ensure_playlist_link(&local, &playlist_name, playlist_index, &item.name); + if let Ok(mut st) = state.lock() { + st.last_message = Some("playlist link created".into()); + } + continue; + } + + // Build a DownloadItem like normal, but attach a playlist link intent. + let tt = item.item_type.as_deref().unwrap_or(""); + let mut di = match tt { + "Audio" => make_download_item_track(&item), + "Episode" => make_download_item_episode(&item), + "Movie" => make_download_item_movie(&item), + _ => make_download_item_other(&item), + }; + + let mut di = match di { + Ok(v) => v, + Err(e) => { + if let Ok(mut st) = state.lock() { + st.last_message = Some(format!("enqueue playlist item failed: {e}")); + } + continue; + } + }; + + di.playlist_links.push(PlaylistLink { + playlist_name, + playlist_index, + title: item.name.clone(), + }); + + if let Ok(mut st) = state.lock() { + // If same media already queued, merge playlist link intents. + if let Some(existing) = st.queue.iter_mut().find(|q| q.item_id == di.item_id && q.final_rel_path == di.final_rel_path) { + for pl in di.playlist_links.drain(..) { + let already = existing.playlist_links.iter().any(|x| + x.playlist_name == pl.playlist_name && x.playlist_index == pl.playlist_index + ); + if !already { + existing.playlist_links.push(pl); + } + } + st.last_message = Some("queued (merged playlist link)".into()); + } else { + st.queue.push_back(di); + st.last_message = Some("queued (playlist)".into()); + } + } + } + + + DownloadCommand::Shutdown => { + shutdown = true; + } + } + } + + if shutdown { + break; + } + + // ------------------------- + // 2) If nothing active, start next + // ------------------------- + let (has_queue, has_active_gid, next_item) = { + let st = match state.lock() { + Ok(s) => s, + Err(_) => break, + }; + + let has_queue = !st.queue.is_empty(); + let has_active_gid = st.active_gid.is_some(); + let next_item = st.queue.front().cloned(); + + (has_queue, has_active_gid, next_item) + }; + + if !has_queue { + thread::sleep(Duration::from_millis(120)); + continue; + } + + if !has_active_gid { + if let Some(it) = next_item { + // mark downloading + if let Ok(mut st) = state.lock() { + if let Some(front) = st.queue.front_mut() { + front.status = DownloadStatus::Downloading; + } + } + + match aria2_add_download(&cfg.base_url, &token, &it) { + Ok(gid) => { + if let Ok(mut st) = state.lock() { + if let Some(front) = st.queue.front_mut() { + front.gid = Some(gid.clone()); + front.status = DownloadStatus::Downloading; + } + st.active_gid = Some(gid); + st.last_message = Some(format!("downloading {}", it.title)); + } + } + Err(e) => { + if let Ok(mut st) = state.lock() { + if let Some(front) = st.queue.front_mut() { + front.status = DownloadStatus::Failed(e.clone()); + } + st.last_message = Some(format!("download failed: {e}")); + let _ = st.queue.pop_front(); + st.active_gid = None; + } + } + } + } + + thread::sleep(Duration::from_millis(80)); + continue; + } + + // ------------------------- + // 3) Poll progress + handle completion + // ------------------------- + poll_progress_for_front(&state); + + match aria2_front_status(&state).as_deref() { + Some("complete") => { + // mark finalizing + if let Ok(mut st) = state.lock() { + if let Some(front) = st.queue.front_mut() { + front.status = DownloadStatus::Finalizing; + } + } + + // finalize + if let Err(e) = finalize_front(&state) { + if let Ok(mut st) = state.lock() { + if let Some(front) = st.queue.front_mut() { + front.status = DownloadStatus::Failed(e.clone()); + } + st.last_message = Some(e); + let _ = st.queue.pop_front(); + st.active_gid = None; + } + } else { + // remove aria2 result + let gid = state.lock().ok().and_then(|s| s.active_gid.clone()); + if let Some(g) = gid { + let _ = aria2_rpc_call("aria2.removeDownloadResult", json!([g])); + } + + if let Ok(mut st) = state.lock() { + let _ = st.queue.pop_front(); + st.active_gid = None; + st.last_message = Some("download complete".into()); + } + } + } + + Some("error") => { + if let Ok(mut st) = state.lock() { + if let Some(front) = st.queue.front_mut() { + front.status = DownloadStatus::Failed("aria2 status=error".into()); + } + st.last_message = Some("aria2 failed".into()); + let _ = st.queue.pop_front(); + st.active_gid = None; + } + } + + Some("active") | Some("waiting") => { + thread::sleep(Duration::from_millis(200)); + } + + _ => { + thread::sleep(Duration::from_millis(200)); + } + } + } + + // ------------------------- + // shutdown cleanup + // ------------------------- + let _ = aria2_rpc_call("aria2.shutdown", json!([])); + let _ = aria2_daemon.kill(); + let _ = aria2_daemon.wait(); + let _ = cleanup_temp_dir(); +} + + + +// Each download gets its own isolated temp folder so deletes can't affect others. +// temp/jobs// +fn temp_dir_for(it: &DownloadItem) -> Result { + let tmp = temp_root()?; + Ok(tmp.join("jobs").join(&it.item_id)) +} + +fn delete_temp_for(it: &DownloadItem) -> Result<(), String> { + let dir = temp_dir_for(it)?; + if dir.exists() { + fs::remove_dir_all(&dir).map_err(|e| format!("rm temp: {e}"))?; + } + Ok(()) +} + +fn aria2_add_download(base_url: &str, token: &str, it: &DownloadItem) -> Result { + let base = base_url.trim_end_matches('/'); + let url = format!("{}/Items/{}/Download", base, it.item_id); + + let tmp_dir = temp_dir_for(it)?; + if tmp_dir.exists() { + let _ = fs::remove_dir_all(&tmp_dir); + } + fs::create_dir_all(&tmp_dir).map_err(|e| format!("mk temp dir: {e}"))?; + + // aria2 options + let opts = json!({ + "dir": tmp_dir.to_string_lossy(), + "allow-overwrite": "true", + "auto-file-renaming": "false", + "continue": "true", + "check-certificate": "true", + "content-disposition": "true", + "header": format!("X-Emby-Token: {token}"), + }); + + // params: [ [uri], options ] + let result = aria2_rpc_call("aria2.addUri", json!([[url], opts]))?; + + result.as_str() + .map(|s| s.to_string()) + .ok_or_else(|| format!("aria2.addUri returned unexpected result: {result}")) +} + +fn poll_progress_for_front(state: &Arc>) { + let gid = { + let st = match state.lock() { + Ok(s) => s, + Err(_) => return, + }; + st.queue.front().and_then(|it| it.gid.clone()) + }; + + let gid = match gid { + Some(g) => g, + None => return, + }; + + let res = match aria2_rpc_call("aria2.tellStatus", json!([gid, ["status","completedLength","totalLength","downloadSpeed"]])) { + Ok(v) => v, + Err(_) => return, + }; + + let status = res.get("status").and_then(|v| v.as_str()).unwrap_or(""); + let completed = res.get("completedLength").and_then(|v| v.as_str()).and_then(|s| s.parse::().ok()).unwrap_or(0); + let total = res.get("totalLength").and_then(|v| v.as_str()).and_then(|s| s.parse::().ok()); + let speed = res.get("downloadSpeed").and_then(|v| v.as_str()).and_then(|s| s.parse::().ok()).unwrap_or(0); + + let eta = match (total, speed) { + (Some(t), s) if t > completed && s > 0 => Some((t - completed) / s), + _ => None, + }; + + if let Ok(mut st) = state.lock() { + if let Some(front) = st.queue.front_mut() { + front.bytes_downloaded = completed; + front.bytes_total = total; + front.download_speed = speed; + front.eta_secs = eta; + + // optional: if aria2 says error, reflect it + if status == "error" { + front.status = DownloadStatus::Failed("aria2 error".into()); + } + } + } +} + + +fn finalize_front(state: &Arc>) -> Result<(), String> { + let (it, tmp_dir, final_abs_watch) = { + let st = state.lock().map_err(|_| "lock poisoned".to_string())?; + let it = st.queue.front().ok_or_else(|| "no front".to_string())?.clone(); + let tmp_dir = tmp_dir_of_item(&it)?; + let dl_root = downloads_root()?; + let final_abs_watch = dl_root.join(&it.final_rel_path); + (it, tmp_dir, final_abs_watch) + }; + + // Ensure final dir exists + let final_parent = final_abs_watch + .parent() + .ok_or_else(|| "no final parent".to_string())?; + fs::create_dir_all(final_parent).map_err(|e| format!("mk final dir: {e}"))?; + + // Find real downloaded file (with real extension) inside tmp_dir + let real_file = find_real_downloaded_file(&tmp_dir)?; + + // Build final filename: + // final_rel_path ends in "...something.watch/.listen" + // Keep stem, replace extension with actual media extension + let real_ext = real_file + .extension() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + + let stem = final_abs_watch + .file_stem() + .and_then(|s| s.to_str()) + .ok_or_else(|| "bad final filename stem".to_string())? + .to_string(); + + let final_abs = if real_ext.is_empty() { + final_abs_watch.clone() + } else { + final_parent.join(format!("{stem}.{real_ext}")) + }; + + // Move file into place + fs::rename(&real_file, &final_abs).map_err(|e| format!("move to final failed: {e}"))?; + + // ✅ NEW: write marker so we can detect "downloaded" later + // (If marker fails, we still consider download successful.) + let _ = write_download_marker(&final_abs, &it.item_id); + + // NEW: create playlist links (best-effort) + for pl in &it.playlist_links { + let _ = ensure_playlist_link(&final_abs, &pl.playlist_name, pl.playlist_index, &pl.title); + } + + // Cleanup temp folder fully + let _ = fs::remove_dir_all(&tmp_dir); + + Ok(()) +} + + +const ARIA2_RPC_HOST: &str = "127.0.0.1"; +const ARIA2_RPC_PORT: u16 = 6800; + +fn aria2_http_post(body: &str) -> Result { + let mut stream = TcpStream::connect((ARIA2_RPC_HOST, ARIA2_RPC_PORT)) + .map_err(|e| format!("aria2 rpc connect failed: {e}"))?; + + let req = format!( + "POST /jsonrpc HTTP/1.1\r\nHost: {}:{}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + ARIA2_RPC_HOST, + ARIA2_RPC_PORT, + body.len(), + body + ); + + stream.write_all(req.as_bytes()).map_err(|e| format!("aria2 rpc write failed: {e}"))?; + + let mut resp = String::new(); + stream.read_to_string(&mut resp).map_err(|e| format!("aria2 rpc read failed: {e}"))?; + + // split headers/body + let (_hdrs, body) = resp.split_once("\r\n\r\n").ok_or("aria2 rpc bad http response")?; + Ok(body.to_string()) +} + +fn aria2_rpc_call(method: &str, params: Value) -> Result { + let payload = json!({ + "jsonrpc": "2.0", + "id": "cjtui", + "method": method, + "params": params, + }); + + let body = payload.to_string(); + let raw = aria2_http_post(&body)?; + let v: Value = serde_json::from_str(raw.trim()).map_err(|e| format!("aria2 rpc json parse failed: {e} ({raw})"))?; + + if let Some(err) = v.get("error") { + return Err(format!("aria2 rpc error: {err}")); + } + Ok(v.get("result").cloned().unwrap_or(Value::Null)) +} + +fn start_aria2_daemon() -> Result { + let mut cmd = Command::new("aria2c"); + cmd.arg("--enable-rpc=true") + .arg("--rpc-listen-all=false") + .arg(format!("--rpc-listen-port={}", ARIA2_RPC_PORT)) + .arg("--rpc-allow-origin-all=true") + .arg("--rpc-max-request-size=2M") + .arg("--check-certificate=true") + .arg("--continue=true") + .arg("--max-tries=0") + .arg("--retry-wait=5") + .arg("--timeout=60") + .arg("--connect-timeout=10") + .arg("--summary-interval=1") + .arg("--console-log-level=warn") + .arg("--file-allocation=none") + .arg("--content-disposition=true") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + let child = cmd.spawn().map_err(|e| format!("spawn aria2c rpc daemon failed: {e}"))?; + + // quick readiness check (try rpc a few times) + let start = Instant::now(); + while start.elapsed() < Duration::from_millis(800) { + if aria2_rpc_call("aria2.getVersion", json!([])).is_ok() { + return Ok(child); + } + thread::sleep(Duration::from_millis(40)); + } + + Err("aria2 rpc daemon did not become ready".into()) +} + +fn aria2_front_status(state: &Arc>) -> Option { + let gid = { + let st = state.lock().ok()?; + st.queue.front().and_then(|it| it.gid.clone()) + }?; + let res = aria2_rpc_call("aria2.tellStatus", json!([gid, ["status"]])).ok()?; + res.get("status").and_then(|v| v.as_str()).map(|s| s.to_string()) +} + + +/// If this item_id has a local download marker, return the local media file path. +/// Marker files are written alongside the media file as: +/// . +/// where DOWNLOAD_MARKER_EXT == "cjtui.json" +pub fn local_media_path_for_item_id(item_id: &str) -> Option { + fn walk(dir: &Path, item_id: &str) -> Option { + if !dir.exists() { + return None; + } + + let rd = fs::read_dir(dir).ok()?; + for ent in rd { + let p = ent.ok()?.path(); + + if p.is_dir() { + if let Some(found) = walk(&p, item_id) { + return Some(found); + } + continue; + } + + let name = p.file_name().and_then(|s| s.to_str()).unwrap_or(""); + if !name.ends_with(DOWNLOAD_MARKER_EXT) { + continue; + } + + let raw = fs::read_to_string(&p).ok()?; + let v: Value = serde_json::from_str(raw.trim()).ok()?; + let id = v.get("item_id").and_then(|x| x.as_str())?; + + if id == item_id { + // Marker file is "." + // So the media file path is the same path with that suffix stripped. + let suffix = format!(".{DOWNLOAD_MARKER_EXT}"); + if !name.ends_with(&suffix) { + continue; + } + let media_name = &name[..name.len() - suffix.len()]; + let media_path = p.with_file_name(media_name); + + if media_path.exists() { + return Some(media_path); + } + } + } + + None + } + + let root = downloads_root().ok()?; + walk(&root, item_id) +} + + +// Delete ALL local copies of an item by scanning *.cjtui.json markers recursively. +// Returns how many media files we attempted to remove (count of matched markers). +pub fn delete_local_by_item_id(item_id: &str) -> Result { + let root = downloads_root()?; + let mut deleted = 0usize; + delete_in_dir_by_item_id(&root, item_id, &mut deleted)?; + Ok(deleted) +} + +// Delete local content based on a concrete local file path. +// If there is a sibling marker, we read item_id and delete ALL copies (same as online behavior). +// If there is no marker, we delete just that file + sidecars. +pub fn delete_local_by_path(path: &Path) -> Result { + // If there is a sibling marker, prefer item_id-based deletion (removes hardlinks elsewhere too). + if let Some(item_id) = item_id_for_media_path(path)? { + return delete_local_by_item_id(&item_id); + } + + // No marker => delete just this file + any obvious sidecars. + delete_file_and_sidecars(path)?; + Ok(1) +} + +// -------------------- +// Internal helpers +// -------------------- + +fn delete_in_dir_by_item_id(dir: &Path, item_id: &str, deleted: &mut usize) -> Result<(), String> { + if !dir.exists() { + return Ok(()); + } + + for ent in fs::read_dir(dir).map_err(|e| format!("read_dir failed: {e}"))? { + let p = ent.map_err(|e| format!("dir entry failed: {e}"))?.path(); + + if p.is_dir() { + delete_in_dir_by_item_id(&p, item_id, deleted)?; + continue; + } + + // marker files only + let name = p.file_name().and_then(|s| s.to_str()).unwrap_or(""); + if !name.ends_with(DOWNLOAD_MARKER_EXT) { + continue; + } + + let raw = match fs::read_to_string(&p) { + Ok(s) => s, + Err(_) => continue, + }; + + let v: Value = match serde_json::from_str(raw.trim()) { + Ok(v) => v, + Err(_) => continue, + }; + + let mid = match v.get("item_id").and_then(|x| x.as_str()) { + Some(s) => s.trim(), + None => continue, + }; + + if mid != item_id { + continue; + } + + // If we matched the item_id, delete: + // - media file referenced by marker (if present) + // - marker itself + // - media sidecars (.aria2/.part) + if let Some(media_name) = v.get("media_file").and_then(|x| x.as_str()) { + let media_path = p + .parent() + .unwrap_or(dir) + .join(media_name); + + let _ = delete_file_and_sidecars(&media_path); + } + + let _ = fs::remove_file(&p); // remove marker + *deleted += 1; + } + + Ok(()) +} + +// Reads sibling marker ".cjtui.json" and returns item_id if present. +fn item_id_for_media_path(media_path: &Path) -> Result, String> { + let fname = match media_path.file_name().and_then(|s| s.to_str()) { + Some(s) => s, + None => return Ok(None), + }; + + let marker = media_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(format!("{fname}.{DOWNLOAD_MARKER_EXT}")); + + if !marker.exists() { + return Ok(None); + } + + let raw = fs::read_to_string(&marker).map_err(|e| format!("read marker failed: {e}"))?; + let v: Value = serde_json::from_str(raw.trim()).map_err(|e| format!("marker json parse failed: {e}"))?; + + Ok(v.get("item_id").and_then(|x| x.as_str()).map(|s| s.to_string())) +} + +fn delete_file_and_sidecars(path: &Path) -> Result<(), String> { + // delete main file + let _ = fs::remove_file(path); + + // delete sibling marker (if any) + if let Some(fname) = path.file_name().and_then(|s| s.to_str()) { + let marker = path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(format!("{fname}.{DOWNLOAD_MARKER_EXT}")); + let _ = fs::remove_file(marker); + } + + // delete obvious aria2 artifacts + if let Some(fname) = path.file_name().and_then(|s| s.to_str()) { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + + let aria2 = parent.join(format!("{fname}.aria2")); + let part = parent.join(format!("{fname}.part")); + + let _ = fs::remove_file(aria2); + let _ = fs::remove_file(part); + } + + Ok(()) +} diff --git a/src/core/jellyfin.rs b/src/core/jellyfin.rs index 77fda8d..8285003 100644 --- a/src/core/jellyfin.rs +++ b/src/core/jellyfin.rs @@ -1,675 +1,696 @@ -use serde::{Deserialize, Serialize}; - -use super::config::Config; - -use std::time::Duration; - -#[derive(Clone)] -pub struct JellyfinClient { - base_url: String, - client: reqwest::blocking::Client, - token: String, -} - -pub struct LoginResult { - pub access_token: String, - pub user_id: Option, -} - -#[derive(Serialize)] -struct AuthenticateUserByName<'a> { - #[serde(rename = "Username")] - username: &'a str, - #[serde(rename = "Pw")] - pw: &'a str, -} - -#[derive(Serialize)] -struct PlaybackStartInfo<'a> { - #[serde(rename = "ItemId")] - item_id: &'a str, - - #[serde(rename = "PositionTicks", skip_serializing_if = "Option::is_none")] - position_ticks: Option, - - #[serde(rename = "CanSeek")] - can_seek: bool, - - #[serde(rename = "IsPaused")] - is_paused: bool, - - #[serde(rename = "IsMuted")] - is_muted: bool, - - // Optional but harmless to include; some servers like seeing it present. - #[serde(rename = "MediaSourceId", skip_serializing_if = "Option::is_none")] - media_source_id: Option<&'a str>, -} - -#[derive(Serialize)] -struct PlaybackProgressInfo<'a> { - #[serde(rename = "ItemId")] - item_id: &'a str, - - #[serde(rename = "PositionTicks", skip_serializing_if = "Option::is_none")] - position_ticks: Option, - - #[serde(rename = "CanSeek")] - can_seek: bool, - - #[serde(rename = "IsPaused")] - is_paused: bool, - - #[serde(rename = "IsMuted")] - is_muted: bool, - - #[serde(rename = "MediaSourceId", skip_serializing_if = "Option::is_none")] - media_source_id: Option<&'a str>, -} - -#[derive(Serialize)] -struct PlaybackStopInfo<'a> { - #[serde(rename = "ItemId")] - item_id: &'a str, - - #[serde(rename = "PositionTicks", skip_serializing_if = "Option::is_none")] - position_ticks: Option, - - #[serde(rename = "MediaSourceId", skip_serializing_if = "Option::is_none")] - media_source_id: Option<&'a str>, - - #[serde(rename = "Failed")] - failed: bool, -} - - -#[derive(Deserialize)] -struct AuthenticationResult { - #[serde(rename = "AccessToken")] - access_token: Option, - #[serde(rename = "User")] - user: Option, -} - -#[derive(Deserialize)] -struct UserDto { - #[serde(rename = "Id")] - id: Option, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct ViewsResponse { - #[serde(rename = "Items", default)] - pub items: Vec, -} - -#[derive(Deserialize)] -struct PlaylistItemsResponse { - #[serde(rename = "Items", default)] - items: Vec, - #[serde(rename = "TotalRecordCount")] - total_record_count: Option, -} - - -impl JellyfinClient { - pub fn from_config(cfg: &Config) -> Result { - let token = cfg - .access_token - .clone() - .ok_or_else(|| "missing access_token (need login)".to_string())?; - - Ok(Self { - base_url: cfg.base_url.trim_end_matches('/').to_string(), - client: reqwest::blocking::Client::new(), - token, - }) - } - - pub fn unauthenticated(base_url: &str) -> Self { - Self { - base_url: base_url.trim_end_matches('/').to_string(), - client: reqwest::blocking::Client::new(), - token: String::new(), // unused for login - } - } - - pub fn login_by_name(&self, username: &str, password: &str) -> Result { - let url = format!("{}/Users/AuthenticateByName", self.base_url); - - let auth_header = format!( - "MediaBrowser Client=\"cjtui\", Device=\"kitty\", DeviceId=\"cjtui\", Version=\"{}\"", - env!("CARGO_PKG_VERSION"), - ); - - let body = AuthenticateUserByName { username, pw: password }; - - let resp = self - .client - .post(url) - .header("X-Emby-Authorization", auth_header) - .json(&body) - .send() - .map_err(|e| format!("request failed: {e}"))?; - - if !resp.status().is_success() { - let status = resp.status(); - let text = resp.text().unwrap_or_default(); - return Err(format!("login failed: {status} {text}")); - } - - let parsed: AuthenticationResult = resp - .json() - .map_err(|e| format!("bad response json: {e}"))?; - - let token = parsed - .access_token - .ok_or_else(|| "missing AccessToken in response".to_string())?; - - let user_id = parsed.user.and_then(|u| u.id); - - Ok(LoginResult { - access_token: token, - user_id, - }) - } - - pub fn ping_base_url(base_url: &str) -> bool { - // Fast, unauthenticated check. Latch result at startup. - // We try a couple endpoints because Jellyfin setups vary. - let base = base_url.trim_end_matches('/'); - - let client = match reqwest::blocking::Client::builder() - .timeout(Duration::from_millis(900)) - .build() - { - Ok(c) => c, - Err(_) => return false, - }; - - let candidates = [ - format!("{base}/System/Ping"), - format!("{base}/System/Info/Public"), - ]; - - for url in candidates { - if let Ok(resp) = client.get(url).send() { - if resp.status().is_success() { - return true; - } - } - } - - false - } - - pub fn mark_played(&self, user_id: &str, item_id: &str) -> Result<(), String> { - let base = self.base_url.trim_end_matches('/'); - let url = format!("{base}/Users/{user_id}/PlayedItems/{item_id}"); - - let resp = self - .client - .post(url) - .header("X-Emby-Token", &self.token) - .send() - .map_err(|e| format!("mark_played request failed: {e}"))?; - - if !resp.status().is_success() { - return Err(format!("mark_played failed: HTTP {}", resp.status())); - } - - Ok(()) - } - - pub fn mark_unplayed(&self, user_id: &str, item_id: &str) -> Result<(), String> { - let base = self.base_url.trim_end_matches('/'); - let url = format!("{base}/Users/{user_id}/PlayedItems/{item_id}"); - - let resp = self - .client - .delete(url) - .header("X-Emby-Token", &self.token) - .send() - .map_err(|e| format!("mark_unplayed request failed: {e}"))?; - - if !resp.status().is_success() { - return Err(format!("mark_unplayed failed: HTTP {}", resp.status())); - } - - Ok(()) - } - - pub fn report_playback_start(&self, item_id: &str, position_ticks: Option) -> Result<(), String> { - let base = self.base_url.trim_end_matches('/'); - let url = format!("{base}/Sessions/Playing"); - - let body = PlaybackStartInfo { - item_id, - position_ticks, - can_seek: true, - is_paused: false, - is_muted: false, - media_source_id: Some(item_id), - }; - - let resp = self - .client - .post(url) - .header("X-Emby-Token", &self.token) - .json(&body) - .send() - .map_err(|e| format!("report_playback_start request failed: {e}"))?; - - if !resp.status().is_success() { - return Err(format!("report_playback_start HTTP {}", resp.status())); - } - - Ok(()) -} - - pub fn report_playback_progress(&self, item_id: &str, position_ticks: Option, is_paused: bool) -> Result<(), String> { - let base = self.base_url.trim_end_matches('/'); - let url = format!("{base}/Sessions/Playing/Progress"); - - let body = PlaybackProgressInfo { - item_id, - position_ticks, - can_seek: true, - is_paused, - is_muted: false, - media_source_id: Some(item_id), - }; - - let resp = self - .client - .post(url) - .header("X-Emby-Token", &self.token) - .json(&body) - .send() - .map_err(|e| format!("report_playback_progress request failed: {e}"))?; - - if !resp.status().is_success() { - return Err(format!("report_playback_progress HTTP {}", resp.status())); - } - - Ok(()) - } - - pub fn report_playback_stopped(&self, item_id: &str, position_ticks: Option, failed: bool) -> Result<(), String> { - let base = self.base_url.trim_end_matches('/'); - let url = format!("{base}/Sessions/Playing/Stopped"); - - let body = PlaybackStopInfo { - item_id, - position_ticks, - media_source_id: Some(item_id), - failed, - }; - - let resp = self - .client - .post(url) - .header("X-Emby-Token", &self.token) - .json(&body) - .send() - .map_err(|e| format!("report_playback_stopped request failed: {e}"))?; - - if !resp.status().is_success() { - return Err(format!("report_playback_stopped HTTP {}", resp.status())); - } - - Ok(()) - } - - pub fn list_views(&self, user_id: &str) -> Result, String> { - let url = format!("{}/Users/{}/Views", self.base_url, user_id); - - let resp = self - .client - .get(url) - .header("X-Emby-Authorization", self.auth_header_value()) - .send() - .map_err(|e| format!("Views request failed: {e}"))?; - - if !resp.status().is_success() { - let status = resp.status(); - let text = resp.text().unwrap_or_default(); - return Err(format!("Views failed: {status} {text}")); - } - - let parsed: ViewsResponse = resp - .json() - .map_err(|e| format!("Views parse failed: {e}"))?; - - Ok(parsed.items) - } - - fn auth_header_value(&self) -> String { - // Identify as a client and include the token. - format!( - "MediaBrowser Client=\"cjtui\", Device=\"kitty\", DeviceId=\"cjtui\", Version=\"{}\", Token=\"{}\"", - env!("CARGO_PKG_VERSION"), - self.token - ) - } - - pub fn list_items( - &self, - user_id: &str, - query: LibraryQuery, - start_index: usize, - limit: usize, - ) -> Result { - let url = format!("{}/Users/{}/Items", self.base_url, user_id); - - let default_sort_by = if let Some(types) = query.include_item_types.as_deref() { - if types.contains("Episode") { - "ParentIndexNumber,IndexNumber,SortName".to_string() - } else { - "SortName".to_string() - } - } else { - "SortName".to_string() - }; - - let sort_by = query.sort_by.clone().unwrap_or(default_sort_by); - let sort_order = query - .sort_order - .clone() - .unwrap_or_else(|| "Ascending".to_string()); - - let recursive = if query.recursive { "true" } else { "false" }; - - let mut req = self - .client - .get(url) - .header("X-Emby-Authorization", self.auth_header_value()) - .query(&[ - ("Recursive", recursive), - ("StartIndex", &start_index.to_string()), - ("Limit", &limit.to_string()), - ("Fields", "PrimaryImageAspectRatio,SortName,ProductionYear,IndexNumber,ParentIndexNumber,RunTimeTicks,Genres,Studios,Overview,SeriesName,Album,AlbumArtist,Artists,UserData,ChildCount,RecursiveItemCount"), - ("SortBy", &sort_by), - ("SortOrder", &sort_order), - ]); - - if let Some(types) = query.include_item_types.as_deref() { - // Jellyfin expects IncludeItemTypes as repeated query params, not CSV - for t in types.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) { - req = req.query(&[("IncludeItemTypes", t)]); - } - } - if let Some(parent) = query.parent_id.as_deref() { - req = req.query(&[("ParentId", parent)]); - } - if let Some(term) = query.search_term.as_deref() { - req = req.query(&[("SearchTerm", term)]); - } - if let Some(filters) = query.filters.as_deref() { - req = req.query(&[("Filters", filters)]); - } - - let resp = req.send().map_err(|e| format!("request failed: {e}"))?; - - if !resp.status().is_success() { - let status = resp.status(); - let text = resp.text().unwrap_or_default(); - return Err(format!("list_items failed: {status} {text}")); - } - - let parsed: ItemsResponse = resp - .json() - .map_err(|e| format!("bad response json: {e}"))?; - - Ok(PagedItems { - items: parsed.items, - total: parsed.total_record_count.unwrap_or(0), - }) - } - -/// Returns true if there exists at least one resumable Episode under the given parent (Series or Season). -/// This is used to mark Series/Season rows as "in progress" even when Jellyfin doesn't populate -/// PlayedPercentage on those container types. -pub fn has_resumable_under( - &self, - user_id: &str, - parent_id: &str, -) -> Result { - // Series -> Season -> Episode, so this MUST be recursive for Series. - // Recursive=true is also fine for Season. - let mut q = LibraryQuery::all() - .with_parent(parent_id.to_string()) - .with_item_types("Episode") - .with_filters("IsResumable"); - - q.recursive = true; - - let page = self.list_items(user_id, q, 0, 1)?; - Ok(page.total > 0) -} - -pub fn playlist_items( - &self, - user_id: &str, - playlist_id: &str, - start_index: usize, - limit: usize, - ) -> Result { - let url = format!("{}/Playlists/{}/Items", self.base_url, playlist_id); - - let resp = self - .client - .get(url) - .header("X-Emby-Authorization", self.auth_header_value()) - .query(&[ - ("UserId", user_id), - ("StartIndex", &start_index.to_string()), - ("Limit", &limit.to_string()), - ("Fields", "PrimaryImageAspectRatio,SortName,ProductionYear,IndexNumber,ParentIndexNumber,RunTimeTicks,Genres,Studios,Overview,SeriesName,Album,AlbumArtist,Artists,UserData,ChildCount,RecursiveItemCount"), - ]) - .send() - .map_err(|e| format!("request failed: {e}"))?; - - if !resp.status().is_success() { - let status = resp.status(); - let text = resp.text().unwrap_or_default(); - return Err(format!("playlist_items failed: {status} {text}")); - } - - let parsed: PlaylistItemsResponse = resp - .json() - .map_err(|e| format!("bad response json: {e}"))?; - - Ok(PagedItems { - items: parsed.items, - total: parsed.total_record_count.unwrap_or(0), - }) - } -} - -/// What kind of library list we want. -#[derive(Clone)] -pub struct LibraryQuery { - pub include_item_types: Option, // e.g. Some("Movie".into()) - pub parent_id: Option, // e.g. Some(series_id) - pub search_term: Option, // e.g. Some("matrix".into()) - pub recursive: bool, // true for big lists, false for drilldown - pub filters: Option, // e.g. Some("IsResumable".into()) - - // optional overrides - pub sort_by: Option, // e.g. Some("DateCreated".into()) - pub sort_order: Option, // "Ascending" or "Descending" -} - - -impl LibraryQuery { - pub fn movies() -> Self { - Self { - include_item_types: Some("Movie".into()), - parent_id: None, - search_term: None, - recursive: true, - filters: None, - sort_by: None, - sort_order: None, - } - } - pub fn shows() -> Self { - Self { - include_item_types: Some("Series".into()), - parent_id: None, - search_term: None, - recursive: true, - filters: None, - sort_by: None, - sort_order: None, - } - } - pub fn music() -> Self { - Self { - include_item_types: Some("Audio".into()), - parent_id: None, - search_term: None, - recursive: true, - filters: None, - sort_by: None, - sort_order: None, - } - } - - // true "All" (no filter) - pub fn all() -> Self { - Self { - include_item_types: None, - parent_id: None, - search_term: None, - recursive: true, - filters: None, - sort_by: None, - sort_order: None, - } - } - - pub fn with_parent(mut self, parent_id: impl Into) -> Self { - self.parent_id = Some(parent_id.into()); - self.recursive = false; // drilldown is usually direct children - self - } - - pub fn with_search(mut self, term: impl Into) -> Self { - self.search_term = Some(term.into()); - self - } - - pub fn with_item_types(mut self, types: impl Into) -> Self { - self.include_item_types = Some(types.into()); - self - } - - pub fn with_filters(mut self, filters: impl Into) -> Self { - self.filters = Some(filters.into()); - self - } - - pub fn with_sort_by(mut self, sort_by: impl Into) -> Self { - self.sort_by = Some(sort_by.into()); - self - } - - pub fn with_sort_order(mut self, sort_order: impl Into) -> Self { - self.sort_order = Some(sort_order.into()); - self - } -} - - -pub struct PagedItems { - pub items: Vec, - pub total: usize, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct JfStudio { - #[serde(rename = "Name")] - pub name: String, -} - -#[derive(Deserialize, Clone, Debug, Default)] -pub struct JfUserData { - #[serde(rename = "Played")] - pub played: Option, - - #[serde(rename = "PlayedPercentage")] - pub played_percentage: Option, - - #[serde(rename = "PlaybackPositionTicks")] - pub playback_position_ticks: Option, - - #[serde(rename = "UnplayedItemCount")] - pub unplayed_item_count: Option, -} - - -#[derive(Debug, Clone, Deserialize)] -pub struct JfItem { - #[serde(rename = "Id")] - pub id: String, - #[serde(rename = "Name")] - pub name: String, - #[serde(rename = "Type")] - pub item_type: Option, - #[serde(rename = "ProductionYear")] - pub production_year: Option, - - #[serde(rename = "ChildCount")] - pub child_count: Option, - #[serde(rename = "RecursiveItemCount")] - pub recursive_item_count: Option, - - #[serde(rename = "IndexNumber")] - pub index_number: Option, - #[serde(rename = "ParentIndexNumber")] - pub parent_index_number: Option, - - // ✅ add these - #[serde(rename = "RunTimeTicks")] - pub run_time_ticks: Option, - - #[serde(rename = "Genres", default)] - pub genres: Vec, - - #[serde(rename = "Studios", default)] - pub studios: Vec, - - #[serde(rename = "Overview")] - pub overview: Option, - - #[serde(rename = "SeriesName")] - pub series_name: Option, - - #[serde(rename = "Album")] - pub album: Option, - - #[serde(rename = "AlbumArtist")] - pub album_artist: Option, - - #[serde(rename = "Artists", default)] - pub artists: Vec, - - #[serde(rename = "UserData")] - pub user_data: Option, -} - -#[derive(Deserialize)] -struct ItemsResponse { - #[serde(rename = "Items")] - items: Vec, - #[serde(rename = "TotalRecordCount")] - total_record_count: Option, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct StudioDto { - #[serde(rename = "Name")] - pub name: Option, +use serde::{Deserialize, Serialize}; + +use super::config::Config; + +use std::time::Duration; + +#[derive(Clone)] +pub struct JellyfinClient { + base_url: String, + client: reqwest::blocking::Client, + token: String, + device_id: String, +} + +/// Human-readable device label shown in Jellyfin's dashboard. Cosmetic only — +/// DeviceId is what the server keys sessions on. +fn device_name() -> String { + if let Ok(h) = std::fs::read_to_string("/etc/hostname") { + let h = h.trim(); + if !h.is_empty() { + return h.to_string(); + } + } + + std::env::var("HOSTNAME").unwrap_or_else(|_| "cjtui".to_string()) +} + +pub struct LoginResult { + pub access_token: String, + pub user_id: Option, +} + +#[derive(Serialize)] +struct AuthenticateUserByName<'a> { + #[serde(rename = "Username")] + username: &'a str, + #[serde(rename = "Pw")] + pw: &'a str, +} + +#[derive(Serialize)] +struct PlaybackStartInfo<'a> { + #[serde(rename = "ItemId")] + item_id: &'a str, + + #[serde(rename = "PositionTicks", skip_serializing_if = "Option::is_none")] + position_ticks: Option, + + #[serde(rename = "CanSeek")] + can_seek: bool, + + #[serde(rename = "IsPaused")] + is_paused: bool, + + #[serde(rename = "IsMuted")] + is_muted: bool, + + // Optional but harmless to include; some servers like seeing it present. + #[serde(rename = "MediaSourceId", skip_serializing_if = "Option::is_none")] + media_source_id: Option<&'a str>, +} + +#[derive(Serialize)] +struct PlaybackProgressInfo<'a> { + #[serde(rename = "ItemId")] + item_id: &'a str, + + #[serde(rename = "PositionTicks", skip_serializing_if = "Option::is_none")] + position_ticks: Option, + + #[serde(rename = "CanSeek")] + can_seek: bool, + + #[serde(rename = "IsPaused")] + is_paused: bool, + + #[serde(rename = "IsMuted")] + is_muted: bool, + + #[serde(rename = "MediaSourceId", skip_serializing_if = "Option::is_none")] + media_source_id: Option<&'a str>, +} + +#[derive(Serialize)] +struct PlaybackStopInfo<'a> { + #[serde(rename = "ItemId")] + item_id: &'a str, + + #[serde(rename = "PositionTicks", skip_serializing_if = "Option::is_none")] + position_ticks: Option, + + #[serde(rename = "MediaSourceId", skip_serializing_if = "Option::is_none")] + media_source_id: Option<&'a str>, + + #[serde(rename = "Failed")] + failed: bool, +} + + +#[derive(Deserialize)] +struct AuthenticationResult { + #[serde(rename = "AccessToken")] + access_token: Option, + #[serde(rename = "User")] + user: Option, +} + +#[derive(Deserialize)] +struct UserDto { + #[serde(rename = "Id")] + id: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ViewsResponse { + #[serde(rename = "Items", default)] + pub items: Vec, +} + +#[derive(Deserialize)] +struct PlaylistItemsResponse { + #[serde(rename = "Items", default)] + items: Vec, + #[serde(rename = "TotalRecordCount")] + total_record_count: Option, +} + + +impl JellyfinClient { + pub fn from_config(cfg: &Config) -> Result { + let token = cfg + .access_token + .clone() + .ok_or_else(|| "missing access_token (need login)".to_string())?; + + Ok(Self { + base_url: cfg.base_url.trim_end_matches('/').to_string(), + client: reqwest::blocking::Client::new(), + token, + device_id: cfg.device_id.clone().unwrap_or_default(), + }) + } + + pub fn unauthenticated(base_url: &str, device_id: &str) -> Self { + Self { + base_url: base_url.trim_end_matches('/').to_string(), + client: reqwest::blocking::Client::new(), + token: String::new(), // unused for login + device_id: device_id.to_string(), + } + } + + pub fn login_by_name(&self, username: &str, password: &str) -> Result { + let url = format!("{}/Users/AuthenticateByName", self.base_url); + + let auth_header = format!( + "MediaBrowser Client=\"cjtui\", Device=\"{}\", DeviceId=\"{}\", Version=\"{}\"", + device_name(), + self.device_id, + env!("CARGO_PKG_VERSION"), + ); + + let body = AuthenticateUserByName { username, pw: password }; + + let resp = self + .client + .post(url) + .header("X-Emby-Authorization", auth_header) + .json(&body) + .send() + .map_err(|e| format!("request failed: {e}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().unwrap_or_default(); + return Err(format!("login failed: {status} {text}")); + } + + let parsed: AuthenticationResult = resp + .json() + .map_err(|e| format!("bad response json: {e}"))?; + + let token = parsed + .access_token + .ok_or_else(|| "missing AccessToken in response".to_string())?; + + let user_id = parsed.user.and_then(|u| u.id); + + Ok(LoginResult { + access_token: token, + user_id, + }) + } + + pub fn ping_base_url(base_url: &str) -> bool { + // Fast, unauthenticated check. Latch result at startup. + // We try a couple endpoints because Jellyfin setups vary. + let base = base_url.trim_end_matches('/'); + + let client = match reqwest::blocking::Client::builder() + .timeout(Duration::from_millis(900)) + .build() + { + Ok(c) => c, + Err(_) => return false, + }; + + let candidates = [ + format!("{base}/System/Ping"), + format!("{base}/System/Info/Public"), + ]; + + for url in candidates { + if let Ok(resp) = client.get(url).send() { + if resp.status().is_success() { + return true; + } + } + } + + false + } + + pub fn mark_played(&self, user_id: &str, item_id: &str) -> Result<(), String> { + let base = self.base_url.trim_end_matches('/'); + let url = format!("{base}/Users/{user_id}/PlayedItems/{item_id}"); + + let resp = self + .client + .post(url) + .header("X-Emby-Token", &self.token) + .send() + .map_err(|e| format!("mark_played request failed: {e}"))?; + + if !resp.status().is_success() { + return Err(format!("mark_played failed: HTTP {}", resp.status())); + } + + Ok(()) + } + + pub fn mark_unplayed(&self, user_id: &str, item_id: &str) -> Result<(), String> { + let base = self.base_url.trim_end_matches('/'); + let url = format!("{base}/Users/{user_id}/PlayedItems/{item_id}"); + + let resp = self + .client + .delete(url) + .header("X-Emby-Token", &self.token) + .send() + .map_err(|e| format!("mark_unplayed request failed: {e}"))?; + + if !resp.status().is_success() { + return Err(format!("mark_unplayed failed: HTTP {}", resp.status())); + } + + Ok(()) + } + + pub fn report_playback_start(&self, item_id: &str, position_ticks: Option) -> Result<(), String> { + let base = self.base_url.trim_end_matches('/'); + let url = format!("{base}/Sessions/Playing"); + + let body = PlaybackStartInfo { + item_id, + position_ticks, + can_seek: true, + is_paused: false, + is_muted: false, + media_source_id: Some(item_id), + }; + + let resp = self + .client + .post(url) + .header("X-Emby-Token", &self.token) + .json(&body) + .send() + .map_err(|e| format!("report_playback_start request failed: {e}"))?; + + if !resp.status().is_success() { + return Err(format!("report_playback_start HTTP {}", resp.status())); + } + + Ok(()) +} + + pub fn report_playback_progress(&self, item_id: &str, position_ticks: Option, is_paused: bool) -> Result<(), String> { + let base = self.base_url.trim_end_matches('/'); + let url = format!("{base}/Sessions/Playing/Progress"); + + let body = PlaybackProgressInfo { + item_id, + position_ticks, + can_seek: true, + is_paused, + is_muted: false, + media_source_id: Some(item_id), + }; + + let resp = self + .client + .post(url) + .header("X-Emby-Token", &self.token) + .json(&body) + .send() + .map_err(|e| format!("report_playback_progress request failed: {e}"))?; + + if !resp.status().is_success() { + return Err(format!("report_playback_progress HTTP {}", resp.status())); + } + + Ok(()) + } + + pub fn report_playback_stopped(&self, item_id: &str, position_ticks: Option, failed: bool) -> Result<(), String> { + let base = self.base_url.trim_end_matches('/'); + let url = format!("{base}/Sessions/Playing/Stopped"); + + let body = PlaybackStopInfo { + item_id, + position_ticks, + media_source_id: Some(item_id), + failed, + }; + + let resp = self + .client + .post(url) + .header("X-Emby-Token", &self.token) + .json(&body) + .send() + .map_err(|e| format!("report_playback_stopped request failed: {e}"))?; + + if !resp.status().is_success() { + return Err(format!("report_playback_stopped HTTP {}", resp.status())); + } + + Ok(()) + } + + pub fn list_views(&self, user_id: &str) -> Result, String> { + let url = format!("{}/Users/{}/Views", self.base_url, user_id); + + let resp = self + .client + .get(url) + .header("X-Emby-Authorization", self.auth_header_value()) + .send() + .map_err(|e| format!("Views request failed: {e}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().unwrap_or_default(); + return Err(format!("Views failed: {status} {text}")); + } + + let parsed: ViewsResponse = resp + .json() + .map_err(|e| format!("Views parse failed: {e}"))?; + + Ok(parsed.items) + } + + fn auth_header_value(&self) -> String { + // Identify as a client and include the token. DeviceId is per-install so + // that two machines on the same account hold independent sessions. + format!( + "MediaBrowser Client=\"cjtui\", Device=\"{}\", DeviceId=\"{}\", Version=\"{}\", Token=\"{}\"", + device_name(), + self.device_id, + env!("CARGO_PKG_VERSION"), + self.token + ) + } + + pub fn list_items( + &self, + user_id: &str, + query: LibraryQuery, + start_index: usize, + limit: usize, + ) -> Result { + let url = format!("{}/Users/{}/Items", self.base_url, user_id); + + let default_sort_by = if let Some(types) = query.include_item_types.as_deref() { + if types.contains("Episode") { + "ParentIndexNumber,IndexNumber,SortName".to_string() + } else { + "SortName".to_string() + } + } else { + "SortName".to_string() + }; + + let sort_by = query.sort_by.clone().unwrap_or(default_sort_by); + let sort_order = query + .sort_order + .clone() + .unwrap_or_else(|| "Ascending".to_string()); + + let recursive = if query.recursive { "true" } else { "false" }; + + let mut req = self + .client + .get(url) + .header("X-Emby-Authorization", self.auth_header_value()) + .query(&[ + ("Recursive", recursive), + ("StartIndex", &start_index.to_string()), + ("Limit", &limit.to_string()), + ("Fields", "PrimaryImageAspectRatio,SortName,ProductionYear,IndexNumber,ParentIndexNumber,RunTimeTicks,Genres,Studios,Overview,SeriesName,Album,AlbumArtist,Artists,UserData,ChildCount,RecursiveItemCount"), + ("SortBy", &sort_by), + ("SortOrder", &sort_order), + ]); + + if let Some(types) = query.include_item_types.as_deref() { + // Jellyfin expects IncludeItemTypes as repeated query params, not CSV + for t in types.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) { + req = req.query(&[("IncludeItemTypes", t)]); + } + } + if let Some(parent) = query.parent_id.as_deref() { + req = req.query(&[("ParentId", parent)]); + } + if let Some(term) = query.search_term.as_deref() { + req = req.query(&[("SearchTerm", term)]); + } + if let Some(filters) = query.filters.as_deref() { + req = req.query(&[("Filters", filters)]); + } + + let resp = req.send().map_err(|e| format!("request failed: {e}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().unwrap_or_default(); + return Err(format!("list_items failed: {status} {text}")); + } + + let parsed: ItemsResponse = resp + .json() + .map_err(|e| format!("bad response json: {e}"))?; + + Ok(PagedItems { + items: parsed.items, + total: parsed.total_record_count.unwrap_or(0), + }) + } + +/// Returns true if there exists at least one resumable Episode under the given parent (Series or Season). +/// This is used to mark Series/Season rows as "in progress" even when Jellyfin doesn't populate +/// PlayedPercentage on those container types. +pub fn has_resumable_under( + &self, + user_id: &str, + parent_id: &str, +) -> Result { + // Series -> Season -> Episode, so this MUST be recursive for Series. + // Recursive=true is also fine for Season. + let mut q = LibraryQuery::all() + .with_parent(parent_id.to_string()) + .with_item_types("Episode") + .with_filters("IsResumable"); + + q.recursive = true; + + let page = self.list_items(user_id, q, 0, 1)?; + Ok(page.total > 0) +} + +pub fn playlist_items( + &self, + user_id: &str, + playlist_id: &str, + start_index: usize, + limit: usize, + ) -> Result { + let url = format!("{}/Playlists/{}/Items", self.base_url, playlist_id); + + let resp = self + .client + .get(url) + .header("X-Emby-Authorization", self.auth_header_value()) + .query(&[ + ("UserId", user_id), + ("StartIndex", &start_index.to_string()), + ("Limit", &limit.to_string()), + ("Fields", "PrimaryImageAspectRatio,SortName,ProductionYear,IndexNumber,ParentIndexNumber,RunTimeTicks,Genres,Studios,Overview,SeriesName,Album,AlbumArtist,Artists,UserData,ChildCount,RecursiveItemCount"), + ]) + .send() + .map_err(|e| format!("request failed: {e}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().unwrap_or_default(); + return Err(format!("playlist_items failed: {status} {text}")); + } + + let parsed: PlaylistItemsResponse = resp + .json() + .map_err(|e| format!("bad response json: {e}"))?; + + Ok(PagedItems { + items: parsed.items, + total: parsed.total_record_count.unwrap_or(0), + }) + } +} + +/// What kind of library list we want. +#[derive(Clone)] +pub struct LibraryQuery { + pub include_item_types: Option, // e.g. Some("Movie".into()) + pub parent_id: Option, // e.g. Some(series_id) + pub search_term: Option, // e.g. Some("matrix".into()) + pub recursive: bool, // true for big lists, false for drilldown + pub filters: Option, // e.g. Some("IsResumable".into()) + + // optional overrides + pub sort_by: Option, // e.g. Some("DateCreated".into()) + pub sort_order: Option, // "Ascending" or "Descending" +} + + +impl LibraryQuery { + pub fn movies() -> Self { + Self { + include_item_types: Some("Movie".into()), + parent_id: None, + search_term: None, + recursive: true, + filters: None, + sort_by: None, + sort_order: None, + } + } + pub fn shows() -> Self { + Self { + include_item_types: Some("Series".into()), + parent_id: None, + search_term: None, + recursive: true, + filters: None, + sort_by: None, + sort_order: None, + } + } + pub fn music() -> Self { + Self { + include_item_types: Some("Audio".into()), + parent_id: None, + search_term: None, + recursive: true, + filters: None, + sort_by: None, + sort_order: None, + } + } + + // true "All" (no filter) + pub fn all() -> Self { + Self { + include_item_types: None, + parent_id: None, + search_term: None, + recursive: true, + filters: None, + sort_by: None, + sort_order: None, + } + } + + pub fn with_parent(mut self, parent_id: impl Into) -> Self { + self.parent_id = Some(parent_id.into()); + self.recursive = false; // drilldown is usually direct children + self + } + + pub fn with_search(mut self, term: impl Into) -> Self { + self.search_term = Some(term.into()); + self + } + + pub fn with_item_types(mut self, types: impl Into) -> Self { + self.include_item_types = Some(types.into()); + self + } + + pub fn with_filters(mut self, filters: impl Into) -> Self { + self.filters = Some(filters.into()); + self + } + + pub fn with_sort_by(mut self, sort_by: impl Into) -> Self { + self.sort_by = Some(sort_by.into()); + self + } + + pub fn with_sort_order(mut self, sort_order: impl Into) -> Self { + self.sort_order = Some(sort_order.into()); + self + } +} + + +pub struct PagedItems { + pub items: Vec, + pub total: usize, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct JfStudio { + #[serde(rename = "Name")] + pub name: String, +} + +#[derive(Deserialize, Clone, Debug, Default)] +pub struct JfUserData { + #[serde(rename = "Played")] + pub played: Option, + + #[serde(rename = "PlayedPercentage")] + pub played_percentage: Option, + + #[serde(rename = "PlaybackPositionTicks")] + pub playback_position_ticks: Option, + + #[serde(rename = "UnplayedItemCount")] + pub unplayed_item_count: Option, +} + + +#[derive(Debug, Clone, Deserialize)] +pub struct JfItem { + #[serde(rename = "Id")] + pub id: String, + #[serde(rename = "Name")] + pub name: String, + #[serde(rename = "Type")] + pub item_type: Option, + #[serde(rename = "ProductionYear")] + pub production_year: Option, + + #[serde(rename = "ChildCount")] + pub child_count: Option, + #[serde(rename = "RecursiveItemCount")] + pub recursive_item_count: Option, + + #[serde(rename = "IndexNumber")] + pub index_number: Option, + #[serde(rename = "ParentIndexNumber")] + pub parent_index_number: Option, + + // ✅ add these + #[serde(rename = "RunTimeTicks")] + pub run_time_ticks: Option, + + #[serde(rename = "Genres", default)] + pub genres: Vec, + + #[serde(rename = "Studios", default)] + pub studios: Vec, + + #[serde(rename = "Overview")] + pub overview: Option, + + #[serde(rename = "SeriesName")] + pub series_name: Option, + + #[serde(rename = "Album")] + pub album: Option, + + #[serde(rename = "AlbumArtist")] + pub album_artist: Option, + + #[serde(rename = "Artists", default)] + pub artists: Vec, + + #[serde(rename = "UserData")] + pub user_data: Option, +} + +#[derive(Deserialize)] +struct ItemsResponse { + #[serde(rename = "Items")] + items: Vec, + #[serde(rename = "TotalRecordCount")] + total_record_count: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct StudioDto { + #[serde(rename = "Name")] + pub name: Option, } \ No newline at end of file diff --git a/src/core/mod.rs b/src/core/mod.rs index cd21547..76a8ff7 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,4 +1,4 @@ -pub mod config; -pub mod jellyfin; -pub mod paths; +pub mod config; +pub mod jellyfin; +pub mod paths; pub mod downloads; \ No newline at end of file diff --git a/src/core/paths.rs b/src/core/paths.rs index 5233221..fe6b22f 100644 --- a/src/core/paths.rs +++ b/src/core/paths.rs @@ -1,44 +1,44 @@ -use std::{env, path::PathBuf}; - -pub fn home_dir() -> Option { - env::var_os("HOME").map(PathBuf::from) -} - -pub fn config_dir() -> Option { - // XDG first, fallback to ~/.config - if let Some(p) = env::var_os("XDG_CONFIG_HOME").map(PathBuf::from) { - Some(p) - } else { - home_dir().map(|h| h.join(".config")) - } -} - -pub fn cache_dir() -> Option { - // XDG first, fallback to ~/.cache - if let Some(p) = env::var_os("XDG_CACHE_HOME").map(PathBuf::from) { - Some(p) - } else { - home_dir().map(|h| h.join(".cache")) - } -} - -pub fn app_config_dir() -> Option { - config_dir().map(|d| d.join("cjtui")) -} - -pub fn app_cache_dir() -> Option { - cache_dir().map(|d| d.join("cjtui")) -} - -pub fn posters_cache_dir() -> Option { - app_cache_dir().map(|d| d.join("posters")) -} - -pub fn downloads_dir() -> Option { - app_config_dir().map(|d| d.join("downloads")) -} - -pub fn temp_downloads_dir() -> Option { - app_config_dir().map(|d| d.join("temp")) -} - +use std::{env, path::PathBuf}; + +pub fn home_dir() -> Option { + env::var_os("HOME").map(PathBuf::from) +} + +pub fn config_dir() -> Option { + // XDG first, fallback to ~/.config + if let Some(p) = env::var_os("XDG_CONFIG_HOME").map(PathBuf::from) { + Some(p) + } else { + home_dir().map(|h| h.join(".config")) + } +} + +pub fn cache_dir() -> Option { + // XDG first, fallback to ~/.cache + if let Some(p) = env::var_os("XDG_CACHE_HOME").map(PathBuf::from) { + Some(p) + } else { + home_dir().map(|h| h.join(".cache")) + } +} + +pub fn app_config_dir() -> Option { + config_dir().map(|d| d.join("cjtui")) +} + +pub fn app_cache_dir() -> Option { + cache_dir().map(|d| d.join("cjtui")) +} + +pub fn posters_cache_dir() -> Option { + app_cache_dir().map(|d| d.join("posters")) +} + +pub fn downloads_dir() -> Option { + app_config_dir().map(|d| d.join("downloads")) +} + +pub fn temp_downloads_dir() -> Option { + app_config_dir().map(|d| d.join("temp")) +} + diff --git a/src/main.rs b/src/main.rs index f37598d..7ae5afb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3973 +1,3981 @@ -mod core; - -const PAGE_SIZE: usize = 50; - -use std::{ - io::{self, Write, Read, BufRead, BufReader}, - path::PathBuf, - process::{Child, Command, Stdio}, - time::{Duration, Instant}, - collections::HashSet, - thread, -}; - -use std::sync::{Arc, Mutex, mpsc}; - -use std::os::unix::net::UnixStream; - -use serde_json::Value; - -use crate::core::downloads; -use crate::core::downloads::{DownloadCommand, DownloadManager, DownloadStatus}; - -use crossterm::{ - event::{self, Event, KeyCode, KeyEventKind}, - execute, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, -}; -use ratatui::{ - layout::{Constraint, Layout, Rect, Spacing}, - symbols::merge::MergeStrategy, - widgets::{Block, List, ListItem, ListState, Paragraph, Wrap, Gauge}, - style::{Modifier, Style}, - text::Line, - Frame, Terminal, -}; -use ratatui::backend::CrosstermBackend; - -#[derive(Clone)] -enum View { - Categories, - Library { - title: String, - query: core::jellyfin::LibraryQuery, - items: Vec, - selected: usize, - start_index: usize, - total: usize, - page_size: usize, - loading: bool, - playlist_ctx: Option, - }, - OfflineLibrary { - title: String, - dir: std::path::PathBuf, - entries: Vec, - selected: usize, - }, - Login, -} - -#[derive(Clone)] -struct PlaylistCtx { - name: String, -} - -#[derive(Clone, Debug, Default)] -struct MpvState { - eof: bool, - idle: bool, - paused: bool, - time_pos: Option, - duration: Option, - percent: Option, - last_err: Option, - - // new: - media_title: Option, - audio: Option, - subs: Option, -} - -#[derive(Clone, Copy)] -enum LoginFocus { - User, - Pass, -} - -#[derive(Clone, Copy, Debug)] -enum SearchMode { - All, // no filter (true "all") - ShowsMovies, // Movie + Series - Shows, // Series - Movies, // Movie - Episodes, // Episode - Music, // Audio - Albums, // MusicAlbum - Artists, // MusicArtist -} - -impl SearchMode { - fn next(self) -> Self { - use SearchMode::*; - match self { - All => ShowsMovies, - ShowsMovies => Shows, - Shows => Movies, - Movies => Episodes, - Episodes => Music, - Music => Albums, - Albums => Artists, - Artists => All, - } - } - - fn prev(self) -> Self { - use SearchMode::*; - match self { - All => Artists, - ShowsMovies => All, - Shows => ShowsMovies, - Movies => Shows, - Episodes => Movies, - Music => Episodes, - Albums => Music, - Artists => Albums, - } - } - - fn label(self) -> &'static str { - use SearchMode::*; - match self { - All => "All", - ShowsMovies => "Shows+Movies", - Shows => "Shows", - Movies => "Movies", - Episodes => "Episodes", - Music => "Music", - Albums => "Albums", - Artists => "Artists", - } - } - - fn include_item_types(self) -> Option<&'static str> { - use SearchMode::*; - match self { - All => None, // IMPORTANT: truly unfiltered - ShowsMovies => Some("Movie,Series"), - Shows => Some("Series"), - Movies => Some("Movie"), - Episodes => Some("Episode"), - Music => Some("Audio"), - Albums => Some("MusicAlbum"), - Artists => Some("MusicArtist"), - } - } -} - -#[derive(Clone)] -enum SearchScope { - All, - Library { - title: String, - query: core::jellyfin::LibraryQuery, - }, -} - -struct SearchState { - mode: SearchMode, - scope: SearchScope, - input: String, -} - -struct App { - should_exit: bool, - status: String, - config_status: String, - - view_stack: Vec, - - items: Vec, - selected: usize, - - config: Option, - login_user: String, - login_pass: String, - login_focus: LoginFocus, - login_error: Option, - offline: bool, - - search: Option, - mpv: Option, - mpv_poll_last: Instant, - mpv_state: MpvState, - mpv_state_rx: Option>, - debug_mpv: bool, - mpv_now_playing: Option, - mpv_now_playing_item_id: Option, - mpv_now_playing_is_stream: bool, - last_progress_report_at: Instant, - last_reported_position_ticks: Option, - marked_watched_this_session: bool, - downloads: Option, - downloads_focus: bool, - downloads_selected: usize, - downloaded_ids: HashSet, - last_download_message: Option, - - autoplay: bool, - autoplay_view_depth: usize, - autoplay_next_index: Option, - autoplay_eof_latched: bool, - autoplay_prev_time_pos: Option, -} - -#[derive(Debug)] -struct MpvSession { - socket_path: PathBuf, - child: Child, -} - -impl Default for App { - fn default() -> Self { - let loaded = core::config::load_config().ok(); - - let (view_stack, config_status, offline) = match &loaded { - Some(cfg) if cfg.access_token.is_some() => { - let online = core::jellyfin::JellyfinClient::ping_base_url(&cfg.base_url); - if online { - (vec![View::Categories], format!("✅ Online: ({})", cfg.base_url), false) - } else { - (vec![View::Categories], format!("📴 Offline: ({})", cfg.base_url), true) - } - } - Some(cfg) => { - // no token: treat as "not connected", but still allow offline browsing - let online = core::jellyfin::JellyfinClient::ping_base_url(&cfg.base_url); - if online { - (vec![View::Login], format!("Not Connected: ({})", cfg.base_url), false) - } else { - (vec![View::Categories], format!("📴 Offline (no token): ({})", cfg.base_url), true) - } - } - None => (vec![View::Login], "Missing Configuration".to_string(), false), - }; - - Self { - should_exit: false, - status: String::new(), - config_status, - config: loaded, - - view_stack, - offline, - - items: categories_for_mode(offline), - - selected: 0, - - login_user: String::new(), - login_pass: String::new(), - login_focus: LoginFocus::User, - login_error: None, - search: None, - mpv: None, - mpv_poll_last: Instant::now(), - mpv_state: MpvState::default(), - mpv_state_rx: None, - debug_mpv: false, - mpv_now_playing: None, - mpv_now_playing_item_id: None, - mpv_now_playing_is_stream: false, - last_progress_report_at: Instant::now(), - last_reported_position_ticks: None, - marked_watched_this_session: false, - autoplay: false, - autoplay_view_depth: 0, - autoplay_next_index: None, - autoplay_eof_latched: false, - autoplay_prev_time_pos: None, - downloads: None, - downloads_focus: false, - downloads_selected: 0, - downloaded_ids: downloads::scan_downloaded_ids().unwrap_or_default(), - last_download_message: None, - - } - } -} - -impl App { - fn current_view(&self) -> &View { - self.view_stack.last().unwrap() - } -} - -fn main() -> io::Result<()> { - if let Err(e) = ensure_config_before_tui() { - eprintln!("{e}"); - return Ok(()); // or: return Err(io::Error::new(io::ErrorKind::Other, e)); - } - - // Terminal setup - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - terminal.clear()?; - - let res = run_app(&mut terminal); - - // Terminal restore (always) - disable_raw_mode()?; - execute!(io::stdout(), LeaveAlternateScreen)?; - terminal.show_cursor()?; - - res -} - -fn run_app(terminal: &mut Terminal>) -> io::Result<()> { - let tick_rate = Duration::from_millis(16); // ~60fps - let mut last_tick = Instant::now(); - let mut app = App::default(); - - while !app.should_exit { - terminal.draw(|f| ui(f, &app))?; - - let timeout = tick_rate.saturating_sub(last_tick.elapsed()); - if event::poll(timeout)? { - if let Event::Key(key) = event::read()? { - if key.kind == KeyEventKind::Press { - handle_key(&mut app, key.code); - } - } - } - - if let Some(rx) = &app.mpv_state_rx { - while let Ok(st) = rx.try_recv() { - app.mpv_state = st; - } - } - - if app.debug_mpv { - app.status = fmt_mpv_state(&app.mpv_state); - } - - playback_reporting_tick(&mut app); - - // --- mpv lifecycle (no borrow issues) --- - let mpv_exited = match app.mpv.as_mut() { - Some(mpv) => match mpv.child.try_wait() { - Ok(Some(status)) => Some(status), - _ => None, - }, - None => None, - }; - - if let Some(status) = mpv_exited { - jellyfin_report_stopped_best_effort(&mut app); - - // take ownership so we can clean up - if let Some(mpv) = app.mpv.take() { - let _ = std::fs::remove_file(&mpv.socket_path); - app.status = format!("mpv exited: {status}"); - } - - // Your requirement: window closing disables autoplay - app.autoplay = false; - app.autoplay_next_index = None; - app.mpv_now_playing_item_id = None; - app.mpv_now_playing_is_stream = false; - app.last_reported_position_ticks = None; - app.marked_watched_this_session = false; - app.mpv_now_playing = None; - app.mpv_state_rx = None; - app.mpv_state = MpvState::default(); - } - - // Refresh local-download index when downloads complete - if let Some(dm) = &app.downloads { - if let Ok(st) = dm.state.lock() { - let msg = st.last_message.clone(); - - if msg != app.last_download_message { - app.last_download_message = msg.clone(); - - if matches!(msg.as_deref(), Some("download complete")) { - app.downloaded_ids = downloads::scan_downloaded_ids().unwrap_or_default(); - } - } - } - } - - // Option A: always tick autoplay by polling eof-reached (window stays open) - autoplay_tick(&mut app); - - - if last_tick.elapsed() >= tick_rate { - // tick-based updates go here later (smooth nav, download polling, etc.) - last_tick = Instant::now(); - } - } - - if let Some(dm) = &app.downloads { - dm.send(downloads::DownloadCommand::Shutdown); - } - - Ok(()) -} - -fn handle_key(app: &mut App, code: KeyCode) { - // Special input handling in Login view - if matches!(app.current_view(), View::Login) { - match code { - KeyCode::Char('q') => { app.should_exit = true; return; } - KeyCode::Esc => { app.should_exit = true; return; } - - KeyCode::Tab => { - app.login_focus = match app.login_focus { - LoginFocus::User => LoginFocus::Pass, - LoginFocus::Pass => LoginFocus::User, - }; - return; - } - - KeyCode::Backspace => { - match app.login_focus { - LoginFocus::User => { app.login_user.pop(); } - LoginFocus::Pass => { app.login_pass.pop(); } - } - return; - } - - KeyCode::Enter => { - let cfg = match &app.config { - Some(c) => c.clone(), - None => { - app.login_error = Some("No config loaded. Create config.toml with base_url.".to_string()); - return; - } - }; - - let client = core::jellyfin::JellyfinClient::unauthenticated(&cfg.base_url); - match client.login_by_name(&app.login_user, &app.login_pass) { - Ok(res) => { - let mut new_cfg = cfg; - new_cfg.access_token = Some(res.access_token); - new_cfg.user_id = res.user_id; - - if let Err(e) = core::config::save_config(&new_cfg) { - app.login_error = Some(format!("login ok but failed to write config: {e:?}")); - return; - } - - app.config_status = format!("auth: token present ({})", new_cfg.base_url); - app.config = Some(new_cfg); - app.login_error = None; - app.login_pass.clear(); // don't keep password around - app.view_stack = vec![View::Categories]; - app.status = "Logged in".to_string(); - } - Err(e) => { - app.login_error = Some(e); - } - } - return; - } - - KeyCode::Char(c) => { - match app.login_focus { - LoginFocus::User => app.login_user.push(c), - LoginFocus::Pass => app.login_pass.push(c), - } - return; - } - - _ => return, - } - } - // If search mode is active, it captures input first - if let Some(search) = &mut app.search { - match code { - KeyCode::Esc => { app.search = None; return; } - KeyCode::Backspace => { search.input.pop(); return; } - KeyCode::Enter => { - let term = search.input.trim().to_string(); - // Special command: reset config + exit - if term == r"\reset" { - if let Some(path) = crate::core::config::config_path() { - let _ = std::fs::remove_file(&path); - } - app.should_exit = true; - return; - } - - if term.is_empty() { - app.search = None; - return; - } - - let mode = search.mode; - let types = mode.include_item_types().map(|s| s.to_string()); - - let (title, mut query) = match &search.scope { - SearchScope::All => ( - format!("Search: {}", term), - core::jellyfin::LibraryQuery::all().with_search(term.clone()), - ), - SearchScope::Library { title, query } => ( - format!("{}: {}", title, term), - query.clone().with_search(term.clone()), - ), - }; - - // Override/force item types for the search mode - query.include_item_types = types; - - app.search = None; - - // run search (same as your library fetch) - let cfg = match &app.config { Some(c) => c.clone(), None => { app.status = "No config".into(); return; } }; - let user_id = match cfg.user_id.as_deref() { Some(u) => u, None => { app.status = "Missing user_id".into(); return; } }; - let client = match core::jellyfin::JellyfinClient::from_config(&cfg) { Ok(c) => c, Err(e) => { app.status = e; return; } }; - - app.status = format!("Searching… {}", term); - - match client.list_items(user_id, query.clone(), 0, PAGE_SIZE) { - Ok(page) => { - app.view_stack.push(View::Library { - title, - query, - items: page.items, - selected: 0, - start_index: 0, - total: page.total, - page_size: PAGE_SIZE, - loading: false, - playlist_ctx: None, - }); - app.status = "Search loaded".into(); - } - Err(e) => app.status = format!("Search failed: {e}"), - } - - return; - } - KeyCode::Char('/') => { - search.mode = search.mode.next(); - return; - } - KeyCode::Char('?') => { - search.mode = search.mode.prev(); - return; - } - KeyCode::Char(c) => { search.input.push(c); return; } - _ => return, - } - } - - // Normalize arrow navigation - let code = match code { - KeyCode::Right => KeyCode::Enter, - KeyCode::Left => KeyCode::Esc, - KeyCode::Char('l') => KeyCode::Enter, - KeyCode::Char('h') => KeyCode::Esc, - other => other, - }; - - // q behavior: if not home, go home; if home, quit - if matches!(code, KeyCode::Char('q')) { - app.autoplay = false; - app.autoplay_next_index = None; - if !matches!(app.current_view(), View::Categories) { - app.view_stack = vec![View::Categories]; - app.selected = 0; - app.status = "$".into(); - return; - } else { - app.should_exit = true; - return; - } - } - - if matches!(code, KeyCode::Char('D')) { - app.downloads_focus = !app.downloads_focus; - app.downloads_selected = 0; - app.status = if app.downloads_focus { "downloads: focus".into() } else { "downloads: unfocus".into() }; - return; - } - // Toggle offline/online mode. - // - If currently online -> go offline immediately (latched until toggled back). - // - If currently offline -> ping server; only switch to online if ping succeeds. - if matches!(code, KeyCode::Char('o')) { - let base_url = app - .config - .as_ref() - .map(|c| c.base_url.clone()) - .unwrap_or_default(); - - if !app.offline { - // Online -> Offline (no ping needed) - app.offline = true; - app.items = categories_for_mode(true); - app.selected = 0; - app.view_stack = vec![View::Categories]; - - // stop autoplay if it was running (offline shouldn't try to advance via network) - app.autoplay = false; - app.autoplay_next_index = None; - app.autoplay_eof_latched = false; - - app.status = "📴 Offline mode enabled".into(); - return; - } - - // Offline -> Online (must confirm server reachable) - if base_url.is_empty() { - app.status = "Cannot go online: missing base_url in config".into(); - return; - } - - let ok = core::jellyfin::JellyfinClient::ping_base_url(&base_url); - if ok { - app.offline = false; - app.items = categories_for_mode(false); - app.selected = 0; - app.view_stack = vec![View::Categories]; - app.status = "✅ Online mode enabled".into(); - } else { - app.status = "Still offline: server not reachable".into(); - } - return; - } - - - // -------------------- - // Downloads Focus Modal - // -------------------- - if app.downloads_focus { - match code { - KeyCode::Esc => { - app.downloads_focus = false; - app.status = "downloads: unfocus".into(); - return; - } - - KeyCode::Up | KeyCode::Char('k') => { - if app.downloads_selected > 0 { - app.downloads_selected -= 1; - } - return; - } - - KeyCode::Down | KeyCode::Char('j') => { - let len = app - .downloads - .as_ref() - .and_then(|dm| dm.state.lock().ok()) - .map(|st| st.queue.len()) - .unwrap_or(0); - - if len > 0 && app.downloads_selected + 1 < len { - app.downloads_selected += 1; - } - return; - } - - KeyCode::Char('d') => { - if let Some(dm) = &app.downloads { - let idx = app.downloads_selected; - - if idx == 0 { - dm.send(downloads::DownloadCommand::Cancel { index: 0 }); - app.status = "download: cancel active".into(); - } else { - dm.send(downloads::DownloadCommand::RemoveQueued { index: idx }); - app.status = "download: removed".into(); - } - - // Clamp selection after removal - let len = dm - .state - .lock() - .ok() - .map(|st| st.queue.len()) - .unwrap_or(0); - - if len == 0 { - app.downloads_selected = 0; - } else if app.downloads_selected >= len { - app.downloads_selected = len - 1; - } - } else { - app.status = "downloads not available".into(); - } - return; - } - - // Swallow everything else while focused. - _ => return, - } - } - - // Capital X: delete local copy/copies for hovered item (works same online/offline) - if matches!(code, KeyCode::Char('X')) { - let result = match app.current_view() { - View::Library { items, selected, .. } if !items.is_empty() => { - let id = items[*selected].id.clone(); - crate::core::downloads::delete_local_by_item_id(&id) - } - View::OfflineLibrary { entries, selected, .. } if !entries.is_empty() => { - let e = &entries[*selected]; - if e.is_dir { - app.status = "X: select a file (not a folder)".into(); - return; - } - crate::core::downloads::delete_local_by_path(&e.path) - } - _ => { - app.status = "X: nothing deletable here".into(); - return; - } - }; - - match result { - Ok(n) => { - // refresh downloaded-id index so 💾 updates immediately - app.downloaded_ids = crate::core::downloads::scan_downloaded_ids().unwrap_or_default(); - - // if we’re in OfflineLibrary, refresh the directory listing so the file disappears - if let Some(View::OfflineLibrary { dir, entries, selected, .. }) = app.view_stack.last_mut() { - *entries = offline_list_dir(dir); - if !entries.is_empty() { - *selected = (*selected).min(entries.len() - 1); - } else { - *selected = 0; - } - } - - app.status = format!("Deleted local copies: {n}"); - } - Err(e) => { - app.status = format!("Delete failed: {e}"); - } - } - - return; - } - - - - // Mark watched (w) / Unmark watched (W) - if matches!(code, KeyCode::Char('w') | KeyCode::Char('W')) { - let unmark = matches!(code, KeyCode::Char('W')); - - let cfg = match &app.config { - Some(c) => c.clone(), - None => { app.status = "No config loaded".into(); return; } - }; - - let user_id = match cfg.user_id.as_deref() { - Some(u) => u.to_string(), - None => { app.status = "Missing user_id (login again)".into(); return; } - }; - - let (item_id, item_type) = match app.current_view() { - View::Library { items, selected, .. } if !items.is_empty() => { - let it = &items[*selected]; - (it.id.clone(), it.item_type.clone().unwrap_or_default()) - } - _ => { app.status = "Not in a library view".into(); return; } - }; - - // Only do this for playables (you can loosen this later if you want) - if item_type != "Episode" && item_type != "Movie" { - app.status = "Watched toggle only for Episodes/Movies (for now)".into(); - return; - } - - let client = match core::jellyfin::JellyfinClient::from_config(&cfg) { - Ok(c) => c, - Err(e) => { app.status = e; return; } - }; - - let r = if unmark { - client.mark_unplayed(&user_id, &item_id) - } else { - client.mark_played(&user_id, &item_id) - }; - - match r { - Ok(_) => { - // Update local UI state so ✅ disappears/appears immediately - if let Some(View::Library { items, selected, .. }) = app.view_stack.last_mut() { - if let Some(it) = items.get_mut(*selected) { - let ud = it.user_data.get_or_insert_with(core::jellyfin::JfUserData::default); - - if unmark { - ud.played = Some(false); - ud.played_percentage = Some(0.0); - ud.playback_position_ticks = Some(0); - } else { - ud.played = Some(true); - ud.played_percentage = Some(100.0); - ud.playback_position_ticks = Some(0); - } - } - } - - app.status = if unmark { "Marked unplayed".into() } else { "Marked played".into() }; - } - Err(e) => { - app.status = format!("Watched update failed: {e}"); - } - } - - return; - } - - - // mpv controls (only when not typing into search/login) - if app.search.is_none() && !matches!(app.current_view(), View::Login) { - match code { - KeyCode::Char('M') => { - app.debug_mpv = !app.debug_mpv; - app.status = format!("mpv debug: {}", if app.debug_mpv { "ON" } else { "OFF" }); - return; - } - KeyCode::Char('a') => { - if let Some(mpv) = &app.mpv { - if let Err(e) = mpv_cycle_audio(mpv) { - app.status = format!("mpv audio: {e}"); - } - } - return; - } - KeyCode::Char('s') => { - if let Some(mpv) = &app.mpv { - if let Err(e) = mpv_cycle_subtitles(mpv) { - app.status = format!("mpv subs: {e}"); - } - } - return; - } - KeyCode::Char('v') => { - if let Some(mpv) = &app.mpv { - if let Err(e) = mpv_toggle_subs(mpv) { - app.status = format!("mpv sub vis: {e}"); - } - } - return; - } - KeyCode::Char('x') => { - app.autoplay = false; - app.autoplay_next_index = None; - - jellyfin_report_stopped_best_effort(app); - - app.mpv_now_playing_item_id = None; - app.mpv_now_playing_is_stream = false; - app.last_reported_position_ticks = None; - app.marked_watched_this_session = false; - app.mpv_now_playing = None; - app.mpv_state_rx = None; - app.mpv_state = MpvState::default(); - - if let Some(mut mpv) = app.mpv.take() { - mpv_stop(&mut mpv); - app.status = "Stopped mpv".into(); - } - return; - } - KeyCode::Char('f') => { - if let Some(mpv) = &app.mpv { - if let Err(e) = mpv_toggle_fullscreen(mpv) { - app.status = format!("mpv fullscreen: {e}"); - } - } else { - app.status = "mpv not running (press 'p' to play)".into(); - } - return; - } - KeyCode::Char(' ') => { - if let Some(mpv) = &app.mpv { - if let Err(e) = mpv_cycle_pause(mpv) { - app.status = format!("mpv pause: {e}"); - } - } else { - app.status = "mpv not running (press 'p' to play)".into(); - } - return; - } - KeyCode::Char('p') => { - app.autoplay = false; - app.autoplay_next_index = None; - - // p = play selected item (spawn or replace) - let cfg = match &app.config { - Some(c) => c.clone(), - None => { app.status = "No config loaded".into(); return; } - }; - - // Determine what is “selected” depending on view - // - Online library: we have an item_id (and can prefer local by id) - // - Offline directory browser: we have a direct local file path - let (maybe_item_id, item_name, maybe_path) = match app.current_view() { - View::Library { items, selected, .. } if !items.is_empty() => { - let it = &items[*selected]; - let ty = it.item_type.as_deref().unwrap_or(""); - - if !is_playable_item_type(ty) { - app.status = "Not playable (use Enter to drill down)".into(); - return; - } - - (Some(it.id.clone()), it.name.clone(), None) - } - View::OfflineLibrary { entries, selected, .. } if !entries.is_empty() => { - let e = &entries[*selected]; - if e.is_dir { - app.status = "Select a file (or Enter to open folder)".into(); - return; - } - (None, e.name.clone(), Some(e.path.clone())) - } - _ => { app.status = "Nothing to play here".into(); return; } - }; - - app.mpv_now_playing = Some(item_name); - app.mpv_now_playing_item_id = maybe_item_id.clone(); - app.marked_watched_this_session = false; - app.last_reported_position_ticks = None; - app.last_progress_report_at = Instant::now(); - - // Decide URL/path: - // 1) If OfflineLibrary gave us a file path -> play it directly. - // 2) Else (online library): prefer local download by item_id, else stream. - let local = maybe_item_id - .as_deref() - .and_then(|id| crate::core::downloads::local_media_path_for_item_id(id)); - - if app.offline && maybe_path.is_none() && local.is_none() { - app.status = "Offline: item not downloaded".into(); - return; - } - - let url = if let Some(p) = maybe_path.or(local) { - p.to_string_lossy().to_string() - } else { - build_jellyfin_play_url(&cfg, maybe_item_id.as_deref().unwrap()) - }; - - // Only report watch progress when actually streaming (http(s) URL). - app.mpv_now_playing_is_stream = url.starts_with("http://") || url.starts_with("https://"); - - // Notify Jellyfin that playback started (best effort). - if app.mpv_now_playing_is_stream { - if let (Some(cfg), Some(item_id)) = (app.config.clone(), app.mpv_now_playing_item_id.clone()) { - if let Ok(client) = core::jellyfin::JellyfinClient::from_config(&cfg) { - let _ = client.report_playback_start(&item_id, None); - } - } - } - - // If mpv running -> replace, else spawn - if let Some(mpv) = &app.mpv { - match mpv_load_url(&cfg, mpv, &url) { - Ok(_) => app.status = "mpv: loaded".into(), - Err(e) => app.status = format!("mpv load failed: {e}"), - } - } else { - match spawn_mpv_url(&cfg, &url) { - Ok(mpv) => { - let rx = start_mpv_poller(mpv.socket_path.clone()); - app.mpv_state_rx = Some(rx); - app.mpv = Some(mpv); - app.status = "mpv: playing".into(); - } - Err(e) => app.status = format!("mpv spawn failed: {e}"), - } - } - return; - } - - KeyCode::Char('P') => { - let cfg = match &app.config { - Some(c) => c.clone(), - None => { app.status = "No config".into(); return; } - }; - - let view = app.current_view().clone(); - - match view { - View::Library { items, selected, .. } if !items.is_empty() => { - let ty = items[selected].item_type.as_deref().unwrap_or(""); - if !is_playable_item_type(ty) { - app.status = "Not playable (use Enter to drill down)".into(); - return; - } - - let item_id = items[selected].id.clone(); - let item_name = items[selected].name.clone(); - let len = items.len(); - - app.mpv_now_playing = Some(item_name); - - app.mpv_now_playing_item_id = Some(item_id.clone()); - app.marked_watched_this_session = false; - app.last_reported_position_ticks = None; - app.last_progress_report_at = Instant::now(); - - app.autoplay = true; - app.autoplay_prev_time_pos = None; - app.autoplay_view_depth = app.view_stack.len(); - app.autoplay_eof_latched = false; - app.autoplay_next_index = if selected + 1 < len { Some(selected + 1) } else { None }; - - let local = crate::core::downloads::local_media_path_for_item_id(&item_id); - if app.offline && local.is_none() { - app.autoplay = false; - app.autoplay_next_index = None; - app.status = "Offline: item not downloaded".into(); - return; - } - - let url = if let Some(p) = local { - p.to_string_lossy().to_string() - } else { - build_jellyfin_play_url(&cfg, &item_id) - }; - - // Only report watch progress when actually streaming (http(s) URL). - app.mpv_now_playing_is_stream = url.starts_with("http://") || url.starts_with("https://"); - - // Notify Jellyfin that playback started (best effort). - if app.mpv_now_playing_is_stream { - if let Ok(client) = core::jellyfin::JellyfinClient::from_config(&cfg) { - let _ = client.report_playback_start(&item_id, None); - } - } - - if let Some(mpv) = &app.mpv { - let r = if url.starts_with('/') { mpv_load_local(mpv, &url) } else { mpv_load_url(&cfg, mpv, &url) }; - if let Err(e) = r { - app.autoplay = false; - app.autoplay_next_index = None; - app.status = format!("mpv load failed: {e}"); - } else { - app.status = "mpv: playing (autoplay)".into(); - } - } else { - let r = if url.starts_with('/') { spawn_mpv_local(&url) } else { spawn_mpv_url(&cfg, &url) }; - match r { - Ok(mpv) => { - let rx = start_mpv_poller(mpv.socket_path.clone()); - app.mpv_state_rx = Some(rx); - app.mpv = Some(mpv); - app.status = "mpv: playing".into(); - } - Err(e) => { - app.autoplay = false; - app.autoplay_next_index = None; - app.status = format!("mpv spawn failed: {e}"); - } - } - } - return; - } - - View::OfflineLibrary { entries, selected, .. } if !entries.is_empty() => { - let e = &entries[selected]; - if e.is_dir { - app.status = "Select a file to autoplay".into(); - return; - } - - app.autoplay = true; - app.autoplay_prev_time_pos = None; - app.autoplay_view_depth = app.view_stack.len(); - app.autoplay_eof_latched = false; - app.autoplay_next_index = next_offline_playable(&entries, selected); - - app.mpv_now_playing = Some(e.name.clone()); - let url = e.path.to_string_lossy().to_string(); - - if let Some(mpv) = &app.mpv { - if let Err(e) = mpv_load_local(mpv, &url) { - app.autoplay = false; - app.autoplay_next_index = None; - app.status = format!("mpv load failed: {e}"); - } else { - app.status = "mpv: playing (offline autoplay)".into(); - } - } else { - match spawn_mpv_local(&url) { - Ok(mpv) => { - let rx = start_mpv_poller(mpv.socket_path.clone()); - app.mpv_state_rx = Some(rx); - app.mpv = Some(mpv); - app.status = "mpv: playing (offline autoplay)".into(); - } - Err(e) => { - app.autoplay = false; - app.autoplay_next_index = None; - app.status = format!("mpv spawn failed: {e}"); - } - } - } - return; - } - - _ => { - app.status = "Nothing to autoplay here".into(); - return; - } - } - } - KeyCode::Char('r') => { - app.autoplay = false; - app.autoplay_next_index = None; - - let cfg = match &app.config { - Some(c) => c.clone(), - None => { app.status = "No config loaded".into(); return; } - }; - - let (item_id, item_name, start_ticks) = match app.current_view() { - View::Library { items, selected, .. } if !items.is_empty() => { - let it = &items[*selected]; - let ty = it.item_type.as_deref().unwrap_or(""); - if ty != "Episode" && ty != "Movie" { - app.status = "Resume only works on Episodes/Movies (for now).".into(); - return; - } - (it.id.clone(), it.name.clone(), playback_position_ticks(it)) - } - _ => { app.status = "Nothing to resume here".into(); return; } - }; - - app.mpv_now_playing = Some(item_name); - - // --- progress reporting state for resume start --- - app.mpv_now_playing_item_id = Some(item_id.clone()); - app.marked_watched_this_session = false; - app.last_reported_position_ticks = None; - app.last_progress_report_at = Instant::now(); - - let url = build_jellyfin_play_url(&cfg, &item_id); - - // resume is always a stream URL here - app.mpv_now_playing_is_stream = url.starts_with("http://") || url.starts_with("https://"); - - // Notify Jellyfin that playback started (best effort). For resume, include the start ticks if present. - if app.mpv_now_playing_is_stream { - if let Ok(client) = core::jellyfin::JellyfinClient::from_config(&cfg) { - let start_i64 = start_ticks.map(|t| t as i64); - let _ = client.report_playback_start(&item_id, start_i64); - } - } - - if let Some(mpv) = &app.mpv { - match mpv_cmd(&mpv.socket_path, &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url)) { - Ok(_) => { - if let Some(t) = start_ticks { - let _ = mpv_seek_absolute(&mpv.socket_path, ticks_to_seconds(t)); - } - app.status = "mpv: resumed".into(); - } - Err(e) => app.status = format!("mpv resume failed: {e}"), - } - } else { - match spawn_mpv_url(&cfg, &url) { - Ok(mpv) => { - let rx = start_mpv_poller(mpv.socket_path.clone()); - app.mpv_state_rx = Some(rx); - let sock = mpv.socket_path.clone(); - app.mpv = Some(mpv); - if let Some(t) = start_ticks { - let _ = mpv_seek_absolute(&sock, ticks_to_seconds(t)); - } - app.status = "mpv: resumed".into(); - } - Err(e) => app.status = format!("mpv spawn failed: {e}"), - } - } - return; - } - KeyCode::Char('R') => { - // resume + autoplay - let cfg = match &app.config { Some(c) => c.clone(), None => { app.status="No config".into(); return; } }; - - let (item_id, item_name, selected, len, start_ticks) = match app.current_view() { - View::Library { items, selected, .. } if !items.is_empty() => { - let it = &items[*selected]; - let ty = it.item_type.as_deref().unwrap_or(""); - if ty != "Episode" && ty != "Movie" { - app.status = "Resume only works on Episodes/Movies (for now).".into(); - return; - } - (it.id.clone(), it.name.clone(), *selected, items.len(), playback_position_ticks(it)) - } - _ => { app.status = "Nothing to resume here".into(); return; } - }; - - app.mpv_now_playing = Some(item_name); - - // --- progress reporting state for resume+autoplay start --- - app.mpv_now_playing_item_id = Some(item_id.clone()); - app.marked_watched_this_session = false; - app.last_reported_position_ticks = None; - app.last_progress_report_at = Instant::now(); - - app.autoplay = true; - app.autoplay_prev_time_pos = None; - app.autoplay_view_depth = app.view_stack.len(); - app.autoplay_next_index = if selected + 1 < len { Some(selected + 1) } else { None }; - app.autoplay_eof_latched = false; - - let url = build_jellyfin_play_url(&cfg, &item_id); - - app.mpv_now_playing_is_stream = url.starts_with("http://") || url.starts_with("https://"); - - // Notify Jellyfin that playback started (best effort). Include resume ticks if present. - if app.mpv_now_playing_is_stream { - if let Ok(client) = core::jellyfin::JellyfinClient::from_config(&cfg) { - let start_i64 = start_ticks.map(|t| t as i64); - let _ = client.report_playback_start(&item_id, start_i64); - } - } - - if let Some(mpv) = &app.mpv { - match mpv_cmd(&mpv.socket_path, &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url)) { - Ok(_) => { - if let Some(t) = start_ticks { - let _ = mpv_seek_absolute(&mpv.socket_path, ticks_to_seconds(t)); - } - app.status = "mpv: resumed (autoplay)".into(); - } - Err(e) => app.status = format!("mpv resume failed: {e}"), - } - } else { - match spawn_mpv_url(&cfg, &url) { - Ok(mpv) => { - let rx = start_mpv_poller(mpv.socket_path.clone()); - app.mpv_state_rx = Some(rx); - let sock = mpv.socket_path.clone(); - app.mpv = Some(mpv); - if let Some(t) = start_ticks { - let _ = mpv_seek_absolute(&sock, ticks_to_seconds(t)); - } - app.status = "mpv: resumed (autoplay)".into(); - } - Err(e) => app.status = format!("mpv spawn failed: {e}"), - } - } - return; - } - _ => {} - } - } - - // downloads focus overrides normal navigation - // downloads focus overrides normal navigation - - match code { - KeyCode::Char('d') => { - let cfg = match &app.config { - Some(c) => c.clone(), - None => { app.status = "No config loaded".into(); return; } - }; - let user_id = match cfg.user_id.as_deref() { - Some(u) => u, - None => { app.status = "No user_id (login first)".into(); return; } - }; - - let (selected_item, playlist_ctx, selected_index) = match app.current_view() { - View::Library { items, selected, playlist_ctx, .. } if !items.is_empty() => { - (items[*selected].clone(), playlist_ctx.clone(), *selected) - } - _ => { app.status = "Nothing to download here".into(); return; } - }; - - // ensure DM - let dm = match &app.downloads { - Some(dm) => dm, - None => { - if cfg.access_token.is_some() { - app.downloads = Some(downloads::DownloadManager::start(cfg.clone())); - app.downloads.as_ref().unwrap() - } else { - app.status = "Not connected".into(); - return; - } - } - }; - - let client = match core::jellyfin::JellyfinClient::from_config(&cfg) { - Ok(c) => c, - Err(e) => { app.status = format!("downloads: {e}"); return; } - }; - - let ty = selected_item.item_type.as_deref().unwrap_or(""); - - // ------------------------- - // CASE 1: We are INSIDE a playlist view (already works) - // ------------------------- - if let Some(pctx) = playlist_ctx { - let playable = matches!(ty, "Movie" | "Episode" | "Audio"); - if !playable { - app.status = "Playlist downloads only support Movie/Episode/Audio".into(); - return; - } - - dm.send(downloads::DownloadCommand::EnqueuePlaylistItem { - item: selected_item, - playlist_name: pctx.name, - playlist_index: selected_index, - }); - app.status = "queued 1 (playlist)".into(); - return; - } - - // ------------------------- - // CASE 2: We are on the PLAYLIST LIST and selected item IS a playlist - // -> enqueue via playlist-aware path so we get hardlinks/order - // ------------------------- - if ty == "Playlist" { - app.status = format!("Fetching playlist… {}", selected_item.name); - - match client.playlist_items(user_id, &selected_item.id, 0, 200) { - Ok(page) => { - let pname = selected_item.name.clone(); - let mut n = 0usize; - - for (i, it) in page.items.into_iter().enumerate() { - let it_ty = it.item_type.as_deref().unwrap_or(""); - let playable = matches!(it_ty, "Movie" | "Episode" | "Audio"); - if !playable { continue; } - - dm.send(downloads::DownloadCommand::EnqueuePlaylistItem { - item: it, - playlist_name: pname.clone(), - playlist_index: i, - }); - n += 1; - } - - app.status = format!("queued {n} (playlist)"); - } - Err(e) => { - app.status = format!("Playlist load failed: {e}"); - } - } - - return; - } - - // ------------------------- - // CASE 3: Normal (non-playlist-container) download behavior - // ------------------------- - match downloads::expand_for_enqueue(&client, user_id, &selected_item) { - Ok(items) => { - let n = items.len(); - dm.send(downloads::DownloadCommand::Enqueue(items)); - app.status = format!("queued {n}"); - } - Err(e) => app.status = format!("queue failed: {e}"), - } - return; - } - - KeyCode::Char('q') => app.should_exit = true, - - //Enter code Block - KeyCode::Enter => { - let view = app.current_view().clone(); - - match view { - View::Categories => { - let cat = app.items[app.selected].clone(); - - // OFFLINE: local-only library - if app.offline { - if cat == "Downloaded" { - let root = match crate::core::paths::app_config_dir() { - Some(p) => p.join("downloads"), - None => { - app.status = "offline: cannot resolve config dir".into(); - return; - } - }; - - let entries = offline_list_dir(&root); - - app.view_stack.push(View::OfflineLibrary { - title: "Downloaded".into(), - dir: root, - entries, - selected: 0, - }); - app.status = "$".into(); - return; - } - } - - let cfg = match &app.config { - Some(c) => c.clone(), - None => { app.status = "No config loaded".into(); return; } - }; - - let user_id = match cfg.user_id.as_deref() { - Some(u) => u, - None => { app.status = "Missing user_id in config (login again)".into(); return; } - }; - - let client = match core::jellyfin::JellyfinClient::from_config(&cfg) { - Ok(c) => c, - Err(e) => { app.status = e; return; } - }; - - // Special case: Libraries uses /Views, not /Items - if cat == "Libraries" { - app.status = "Fetching Libraries...".into(); - match client.list_views(user_id) { - Ok(items) => { - let total = items.len(); - app.view_stack.push(View::Library { - title: "Libraries".into(), - query: core::jellyfin::LibraryQuery::all(), // placeholder - items, - selected: 0, - start_index: 0, - total, // cheap total for UI - page_size: PAGE_SIZE, - loading: false, - playlist_ctx: None, - }); - app.status = "Loaded".into(); - } - Err(e) => app.status = format!("Views failed: {e}"), - } - return; - } - - // Normal categories use /Items - let query = match cat.as_str() { - "Movies" => core::jellyfin::LibraryQuery::movies(), - "Shows" => core::jellyfin::LibraryQuery::shows(), - "Music" => core::jellyfin::LibraryQuery::music(), - "Playlists" => core::jellyfin::LibraryQuery::all().with_item_types("Playlist"), - - // NEW - "Collections" => core::jellyfin::LibraryQuery::all() - .with_item_types("BoxSet"), - - "Continue Watching" => core::jellyfin::LibraryQuery::all() - .with_item_types("Episode,Movie") - .with_filters("IsResumable") - .with_sort_by("DatePlayed") - .with_sort_order("Descending"), - - "Recently Added" => core::jellyfin::LibraryQuery::all() - .with_item_types("Movie,Series") - .with_sort_by("DateCreated,SortName") - .with_sort_order("Descending"), - - _ => { app.status = format!("Not wired yet: {}", cat); return; } - }; - - app.status = format!("Fetching {}...", cat); - - match client.list_items(user_id, query.clone(), 0, PAGE_SIZE) { - Ok(page) => { - app.view_stack.push(View::Library { - title: cat, - query, - items: page.items, - selected: 0, - start_index: 0, - total: page.total, - page_size: PAGE_SIZE, - loading: false, - playlist_ctx: None, - }); - app.status = "$".into(); - } - Err(e) => app.status = format!("Fetch failed: {e}"), - } - } - - View::Library { title, items, selected, .. } => { - if items.is_empty() { return; } - let it = &items[selected]; - - let ty = it.item_type.as_deref().unwrap_or(""); - - // Libraries (/Views) usually return CollectionFolder. Entering it should list its contents. - if ty == "CollectionFolder" { - let q = core::jellyfin::LibraryQuery::all().with_parent(it.id.clone()); - open_children(app, format!("{} > {}", title, it.name), q); - return; - } - // Shows -> Seasons -> Episodes - if ty == "Series" { - let q = core::jellyfin::LibraryQuery::all() - .with_parent(it.id.clone()) - .with_item_types("Season"); - open_children(app, format!("{} > {}", title, it.name), q); - return; - } - if ty == "Season" { - let q = core::jellyfin::LibraryQuery::all() - .with_parent(it.id.clone()) - .with_item_types("Episode"); - open_children(app, format!("{} > {}", title, it.name), q); - return; - } - // MusicArtist -> MusicAlbum -> Audio - if ty == "MusicArtist" { - let q = core::jellyfin::LibraryQuery::all() - .with_parent(it.id.clone()) - .with_item_types("MusicAlbum"); - open_children(app, format!("{} > {}", title, it.name), q); - return; - } - if ty == "MusicAlbum" { - let q = core::jellyfin::LibraryQuery::all() - .with_parent(it.id.clone()) - .with_item_types("Audio"); - open_children(app, format!("{} > {}", title, it.name), q); - return; - } - if ty == "BoxSet" { - let q = core::jellyfin::LibraryQuery::all().with_parent(it.id.clone()); - open_children(app, format!("{} > {}", title, it.name), q); - return; - } - if ty == "UserView" { - // Most UserViews are libraries like Shows/Movies/Music/Playlists. - // For Playlists view, drill into playlists. - let q = core::jellyfin::LibraryQuery::all() - .with_parent(it.id.clone()) - .with_item_types("Playlist"); - open_children(app, format!("{} > {}", title, it.name), q); - return; - } - if ty == "Playlist" { - let cfg = match &app.config { Some(c) => c.clone(), None => { app.status="No config".into(); return; } }; - let user_id = match cfg.user_id.as_deref() { Some(u) => u, None => { app.status="Missing user_id".into(); return; } }; - let client = match core::jellyfin::JellyfinClient::from_config(&cfg) { Ok(c)=>c, Err(e)=>{ app.status=e; return; } }; - - match client.playlist_items(user_id, &it.id, 0, PAGE_SIZE) { - Ok(page) => { - app.view_stack.push(View::Library { - title: format!("{} > {}", title, it.name), - // store a query placeholder; playlist paging could be done later if you want - query: core::jellyfin::LibraryQuery::all(), - items: page.items, - selected: 0, - start_index: 0, - total: page.total, - page_size: PAGE_SIZE, - loading: false, - playlist_ctx: Some(PlaylistCtx { name: it.name.clone() }), - }); - app.status = "$".into(); - } - Err(e) => app.status = format!("Playlist load failed: {e}"), - } - return; - } - // Playables -> mpv - // Playables: do nothing on Enter (use 'p' to play) - if ty == "Episode" || ty == "Audio" || ty == "Movie" { - app.status = "Press 'p' to play, or 'P' for autoplay.".into(); - return; - } - - app.status = format!("No drilldown for type: {}", ty); - } - - View::OfflineLibrary { title, dir: _, entries, selected } => { - if entries.is_empty() { return; } - let e = &entries[selected]; - - if e.is_dir { - let new_dir = e.path.clone(); - let new_entries = offline_list_dir(&new_dir); - - app.view_stack.push(View::OfflineLibrary { - title: format!("{title} > {}", e.name), - dir: new_dir, - entries: new_entries, - selected: 0, - }); - app.status = "$".into(); - } else { - app.status = "Press 'p' to play local file".into(); - } - return; - } - - _ => {} - } -} - - - KeyCode::Esc | KeyCode::Backspace => { - if app.view_stack.len() > 1 { - app.view_stack.pop(); - app.status = "$".to_string(); - app.selected = 0; - } - } - KeyCode::Up | KeyCode::Char('k') => { - match app.view_stack.last_mut().unwrap() { - View::Categories => { - if app.items.is_empty() { return; } - if app.selected == 0 { app.selected = app.items.len() - 1; } - else { app.selected -= 1; } - //app.status = format!("Selected: {}", app.items[app.selected]); - app.status = "$".into(); - } - View::Library { items, selected, total, .. } => { - if items.is_empty() { return; } - - if *selected == 0 { - // wrap-up wants the real end - if items.len() < *total { - ensure_loaded_to_end(app); - } - - if let Some(View::Library { items, selected, .. }) = app.view_stack.last_mut() { - if !items.is_empty() { - *selected = items.len() - 1; - } - } - } else { - *selected -= 1; - } - - maybe_prefetch(app); - } - View::OfflineLibrary { entries, selected, .. } => { - if entries.is_empty() { return; } - if *selected == 0 { *selected = entries.len() - 1; } - else { *selected -= 1; } - } - _ => {} - } - } - KeyCode::Down | KeyCode::Char('j') => { - match app.view_stack.last_mut().unwrap() { - View::Categories => { - if app.items.is_empty() { return; } - app.selected = (app.selected + 1) % app.items.len(); - //app.status = format!("Selected: {}", app.items[app.selected]); - app.status = "$".into(); - } - View::Library { items, selected, total, .. } => { - if items.is_empty() { return; } - - // If we're at the end of what we have loaded… - if *selected + 1 >= items.len() { - // …and there’s more available on the server, fetch next page. - if items.len() < *total { - load_next_library_page(app); - - // After loading more, move down if possible. - if let Some(View::Library { items, selected, .. }) = app.view_stack.last_mut() { - if !items.is_empty() && *selected + 1 < items.len() { - *selected += 1; - } - } - } else { - *selected = 0; - } - } else { - *selected += 1; - } - - maybe_prefetch(app); - } - View::OfflineLibrary { entries, selected, .. } => { - if entries.is_empty() { return; } - *selected = (*selected + 1) % entries.len(); - } - _ => {} - } - } - KeyCode::Char('/') => { - // Always behave like home-page search (global), regardless of current view. - app.search = Some(SearchState { - scope: SearchScope::All, - mode: SearchMode::All, - input: String::new(), - }); - } - _ => {} - } -} - -fn load_next_library_page(app: &mut App) { - // pull info + mark loading - let (query, next_start, total, page_size) = match app.view_stack.last_mut() { - Some(View::Library { query, items, total, start_index, loading, page_size, .. }) => { - if *loading { return; } - *loading = true; - let loaded = items.len(); - let next_start = *start_index + loaded; - (query.clone(), next_start, *total, *page_size) - } - _ => return, - }; - - let cfg = match &app.config { - Some(c) => c.clone(), - None => { app.status = "No config loaded".to_string(); finish_loading(app); return; } - }; - - let user_id = match cfg.user_id.as_deref() { - Some(u) => u, - None => { app.status = "Missing user_id in config (login again)".to_string(); finish_loading(app); return; } - }; - - let client = match core::jellyfin::JellyfinClient::from_config(&cfg) { - Ok(c) => c, - Err(e) => { app.status = e; finish_loading(app); return; } - }; - - let page = match client.list_items(user_id, query.clone(), next_start, page_size) { - Ok(p) => p, - Err(e) => { app.status = format!("Fetch failed: {e}"); finish_loading(app); return; } - }; - - if let Some(View::Library { items, total, .. }) = app.view_stack.last_mut() { - items.extend(page.items); - *total = page.total; - } - - finish_loading(app); -} - -fn finish_loading(app: &mut App) { - if let Some(View::Library { loading, .. }) = app.view_stack.last_mut() { - *loading = false; - } -} - -fn maybe_prefetch(app: &mut App) { - const PREFETCH_ROWS: usize = 0; - - let should = match app.view_stack.last() { - Some(View::Library { items, selected, total, loading, .. }) => { - if *loading { false } - else if items.len() >= *total { false } - else { - let remaining_loaded = items.len().saturating_sub(*selected); - remaining_loaded <= PREFETCH_ROWS - } - } - _ => false, - }; - - if should { - load_next_library_page(app); - } -} - -fn ensure_loaded_to_end(app: &mut App) { - loop { - let done = match app.view_stack.last() { - Some(View::Library { items, total, loading, .. }) => { - !*loading && items.len() >= *total - } - _ => true, - }; - if done { break; } - - load_next_library_page(app); - } -} - -fn ticks_to_runtime_str(ticks: Option) -> Option { - // Jellyfin ticks are 10,000,000 per second - let t = ticks?; - if t <= 0 { return None; } - - let total_seconds = (t as u64 / 10_000_000) as u64; - let minutes = total_seconds / 60; - let seconds = total_seconds % 60; - - if minutes >= 60 { - let h = minutes / 60; - let m = minutes % 60; - Some(format!("{h}h {m}m")) - } else { - Some(format!("{minutes}m {seconds:02}s")) - } -} - -fn join_artists(artists: &[String]) -> String { - let names: Vec = artists - .iter() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - - if names.is_empty() { "-".into() } else { names.join(", ") } -} - -fn first_nonempty(opt: &Option) -> Option { - opt.as_deref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) -} - -fn join_genres(genres: &[String]) -> String { - if genres.is_empty() { "-".into() } else { genres.join(", ") } -} - -fn join_studios(studios: &[core::jellyfin::JfStudio]) -> String { - let names: Vec = studios - .iter() - .map(|s| s.name.clone()) - .filter(|s| !s.trim().is_empty()) - .collect(); - - if names.is_empty() { "-".into() } else { names.join(", ") } -} - -fn year_str(y: Option) -> String { - y.map(|v| v.to_string()).unwrap_or_else(|| "-".into()) -} - -fn overview_str(o: &Option) -> String { - o.as_deref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .unwrap_or("-") - .to_string() -} - -fn format_details(it: &core::jellyfin::JfItem) -> String { - let ty = it.item_type.as_deref().unwrap_or(""); - - match ty { - "Playlist" | "UserView" | "CollectionFolder" | "BoxSet" => { - format!("Name: {}", it.name) - } - - "Series" => format!( - "Name: {}\nYear: {}\nGenres: {}\nStudio: {}\n\nOverview:\n{}", - it.name, - year_str(it.production_year), - join_genres(&it.genres), - join_studios(&it.studios), - overview_str(&it.overview), - ), - - "Movie" => { - let mut header = format!( - "Name: {}\nYear: {}\nRuntime: {}\nGenres: {}\nStudio: {}", - it.name, - year_str(it.production_year), - ticks_to_runtime_str(it.run_time_ticks).unwrap_or_else(|| "-".into()), - join_genres(&it.genres), - join_studios(&it.studios), - ); - - if is_in_progress_fast(it) { - let pos = playback_position_ticks(it).unwrap_or(0); - let pos_secs = (pos / 10_000_000) as f64; - header.push_str(&format!( - "\nIn progress ({}): press r/R to resume.", - fmt_hms(pos_secs) - )); - } - - format!( - "{}\n\nOverview:\n{}", - header, - overview_str(&it.overview), - ) - } - - "Episode" => { - let series = it.series_name.clone().unwrap_or_else(|| "-".into()); - let se = match (it.parent_index_number, it.index_number) { - (Some(s), Some(e)) => format!("{:02}x{:02}", s, e), - _ => "-".into(), - }; - - let mut header = format!( - "Series: {}\nEpisode: {} {}\nYear: {}", - series, - se, - it.name, - year_str(it.production_year), - ); - - if is_in_progress_fast(it) { - let pos = playback_position_ticks(it).unwrap_or(0); - let pos_secs = (pos / 10_000_000) as f64; - header.push_str(&format!( - "\nIn progress ({}): press r/R to resume.", - fmt_hms(pos_secs) - )); - } - - format!( - "{}\n\nOverview:\n{}", - header, - overview_str(&it.overview), - ) - } - - // --- Songs --- - "Audio" => { - let artist = first_nonempty(&it.album_artist) - .or_else(|| it.artists.get(0).cloned()) - .unwrap_or_else(|| "-".into()); - - let album = first_nonempty(&it.album).unwrap_or_else(|| "-".into()); - - format!( - "Name: {}\nRuntime: {}\nArtist: {}\nAlbum: {}\nYear: {}", - it.name, - ticks_to_runtime_str(it.run_time_ticks).unwrap_or_else(|| "-".into()), - artist, - album, - year_str(it.production_year), - ) - } - - // --- Albums --- - "MusicAlbum" => { - let artist = first_nonempty(&it.album_artist) - .or_else(|| it.artists.get(0).cloned()) - .unwrap_or_else(|| "-".into()); - - format!( - "Name: {}\nRuntime: {}\nArtist: {}\nYear: {}", - it.name, - ticks_to_runtime_str(it.run_time_ticks).unwrap_or_else(|| "-".into()), - artist, - year_str(it.production_year), - ) - } - - // --- Artists --- - "MusicArtist" => format!("Name: {}", it.name), - - _ => format!( - "Name: {}\nId: {}\nType: {:?}\nYear: {:?}", - it.name, it.id, it.item_type, it.production_year - ), - } -} - -fn ui(frame: &mut Frame, app: &App) { - let area = frame.area(); - let panes = layout(area); - - // Top - //frame.render_widget( - // Paragraph::new("cjtui").block(Block::bordered()), - // panes.top, - //); - - let top_block = Block::bordered(); - frame.render_widget(top_block.clone(), panes.top); - - let top_inner = top_block.inner(panes.top); - let [top_l, top_r] = Layout::horizontal([Constraint::Fill(1), Constraint::Length(40)]) - .areas(top_inner); - - frame.render_widget(Paragraph::new("cjtui"), top_l); - let server = app - .config - .as_ref() - .map(|c| c.base_url.clone()) - .unwrap_or_else(|| "-".into()); - - frame.render_widget( - Paragraph::new(server) - .alignment(ratatui::layout::Alignment::Right), - top_r, - ); - - // Main - - match app.current_view() { - View::OfflineLibrary { title, dir: _, entries, selected } => { - render_offline_library_dir(frame, title, entries, *selected, panes.left_main, panes.right_main); - } - View::Categories => { - render_categories(frame, app, panes.left_main, panes.right_main); - } - View::Library { title, query, items, selected, start_index, total, .. } => { - let is_audio = query - .include_item_types - .as_deref() - .map(|t| t.split(',').any(|x| x.trim() == "Audio")) - .unwrap_or(false); - - let show_audio_track_numbers = is_audio && query.parent_id.is_some(); - - render_library( - frame, - app, - title, - *start_index, - *total, - items, - *selected, - panes.left_main, - panes.right_main, - show_audio_track_numbers, - ); - } - View::Login => { - render_login(frame, app, panes.left_main, panes.right_main); - } - } - - // Bottom Left - // ---- Bottom Left: prompt / mpv ---- - // Bottom Left: Search (temporary) > MPV (persistent) > "$" - if let Some(s) = &app.search { - let scope = match &s.scope { - SearchScope::All => "All".to_string(), - SearchScope::Library { title, .. } => title.clone(), - }; - let mode = s.mode.label(); - let line = if s.input.is_empty() { - format!("/ ({scope}) [{mode}]") - } else { - format!("/ ({scope}) [{mode}] {}", s.input) - }; - - frame.render_widget( - Paragraph::new(line).block(Block::bordered().merge_borders(MergeStrategy::Exact)), - panes.left_output, - ); - } else if app.downloads_focus || app.downloads.as_ref().map(|d| d.state.lock().map(|s| !s.queue.is_empty()).unwrap_or(false)).unwrap_or(false) { - render_downloads_panel(frame, app, panes.left_output); - } else if app.mpv.is_some() { - // mpv panel takes over the whole bottom-left area - render_mpv_panel(frame, app, panes.left_output); - } else { - frame.render_widget( - Paragraph::new("$").block(Block::bordered().merge_borders(MergeStrategy::Exact)), - panes.left_output, - ); - } - - // ---- Bottom Right: always-on status/help ---- - let connected = if app.offline { - "📴 Offline" - } else if app.config.as_ref().and_then(|c| c.access_token.as_ref()).is_some() { - "✅ Connected" - } else { - "❌ Disconnected" - }; - - let server = app - .config - .as_ref() - .map(|c| c.base_url.as_str()) - .unwrap_or("-"); - - let right_text = format!( - "{connected}\np/P: play/autoplay -r/R: resume/autoplay -d: download / cancel -q: quit -/help: more help" - ); - - frame.render_widget( - Paragraph::new(right_text) - .wrap(Wrap { trim: false }) - .block(Block::bordered().merge_borders(MergeStrategy::Exact)), - panes.right_output, - ); -} - -fn compute_window(len: usize, selected: usize, viewport: usize) -> (usize, usize) { - if len == 0 || viewport == 0 { - return (0, 0); - } - - let viewport = viewport.min(len); - let half = viewport / 2; - - let mut start = selected.saturating_sub(half); - if start + viewport > len { - start = len - viewport; - } - - let end = start + viewport; - (start, end) -} - -fn render_categories(frame: &mut Frame, app: &App, left: Rect, right: Rect) { - // how many rows can we show inside the bordered block? - let viewport = left.height.saturating_sub(2) as usize; - - let len = app.items.len(); - let selected = app.selected.min(len.saturating_sub(1)); - - let (start, end) = compute_window(len, selected, viewport); - - let items: Vec = app.items[start..end] - .iter() - .map(|s| ListItem::new(s.as_str())) - .collect(); - - let list = List::new(items) - .block(Block::bordered().title("Categories").merge_borders(MergeStrategy::Exact)) - .highlight_symbol("> ") - .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); - - let mut state = ListState::default(); - state.select(Some(selected - start)); // selection relative to slice - - frame.render_stateful_widget(list, left, &mut state); - - // Right pane details (use the real selected item) - frame.render_widget( - Paragraph::new("") - .block(Block::bordered().merge_borders(MergeStrategy::Exact)), - right, - ); -} - -fn render_placeholder(frame: &mut Frame, name: &str, left: Rect, right: Rect) { - frame.render_widget( - Paragraph::new(format!("Inside {}\n\nPress Esc to go back", name)) - .block(Block::bordered().title("Library").merge_borders(MergeStrategy::Exact)), - left, - ); - - frame.render_widget( - Paragraph::new("Right pane reserved for item details / thumbnails") - .block(Block::bordered().title("Details").merge_borders(MergeStrategy::Exact)), - right, - ); -} - -fn render_library( - frame: &mut Frame, - app: &App, - title: &str, - start_index: usize, - total: usize, - items: &[core::jellyfin::JfItem], - selected: usize, - left: Rect, - right: Rect, - show_audio_track_numbers: bool, -) { - let viewport = left.height.saturating_sub(2) as usize; - - let len = items.len(); - let selected = selected.min(len.saturating_sub(1)); - - let (start, end) = compute_window(len, selected, viewport); - - fn show_year_for(item_type: Option<&str>) -> bool { - matches!(item_type, Some("Movie" | "Series" | "MusicAlbum")) - } - - fn left_number_prefix(it: &core::jellyfin::JfItem, show_audio_track_numbers: bool) -> String { - match it.item_type.as_deref() { - Some("Episode") => { - match (it.parent_index_number, it.index_number) { - (Some(season), Some(ep)) => format!("{:02}x{:02} ", season, ep), - (_, Some(ep)) => format!("{:02} ", ep), - _ => String::new(), - } - } - Some("Audio") => { - if !show_audio_track_numbers { - return String::new(); - } - match it.index_number { - Some(n) => format!("{:02}. ", n), - None => String::new(), - } - } - _ => String::new(), - } - } - - let list_items: Vec = items[start..end] - .iter() - .filter(|it| { - // Hide the redundant "collections" folder inside the Collections library - if title.ends_with("Collections") { - if it.item_type.as_deref() == Some("Folder") - && it.name.eq_ignore_ascii_case("collections") - { - return false; - } - } - true - }) - .map(|it| { - let emoji = emoji_prefix_for(app, it); - let prefix = left_number_prefix(it, show_audio_track_numbers); - let year = if show_year_for(it.item_type.as_deref()) { - it.production_year.map(|y| format!(" ({y})")).unwrap_or_default() - } else { - String::new() - }; - ListItem::new(format!("{}{}{}{}", emoji, prefix, it.name, year)) - }) - .collect(); - - let list = List::new(list_items) - .block( - Block::bordered() - .title(format!("{title} [{}/{}]", items.len(), total)) - .merge_borders(MergeStrategy::Exact), - ) - .highlight_symbol("> ") - .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); - - let mut state = ListState::default(); - state.select(if len > 0 { Some(selected - start) } else { None }); - - frame.render_stateful_widget(list, left, &mut state); - - let detail = if len > 0 { - let it = &items[selected]; - format_details(it) - } else { - "No items returned".to_string() - }; - - frame.render_widget( - Paragraph::new(detail) - .wrap(Wrap { trim: false }) - .block(Block::bordered().title("Details").merge_borders(MergeStrategy::Exact)), - right, - ); -} - -fn open_children(app: &mut App, title: String, query: core::jellyfin::LibraryQuery) { - let cfg = match &app.config { - Some(c) => c.clone(), - None => { app.status = "No config loaded".into(); return; } - }; - let user_id = match cfg.user_id.as_deref() { - Some(u) => u, - None => { app.status = "Missing user_id in config (login again)".into(); return; } - }; - let client = match core::jellyfin::JellyfinClient::from_config(&cfg) { - Ok(c) => c, - Err(e) => { app.status = e; return; } - }; - - app.status = format!("Fetching {}...", title); - - match client.list_items(user_id, query.clone(), 0, PAGE_SIZE) { - Ok(page) => { - app.view_stack.push(View::Library { - title, - query, - items: page.items, - selected: 0, - start_index: 0, - total: page.total, - page_size: PAGE_SIZE, - loading: false, - playlist_ctx: None, - }); - app.status = "$".into(); - } - Err(e) => app.status = format!("Fetch failed: {e}"), - } -} - -fn mpv_socket_path() -> PathBuf { - let mut p = std::env::temp_dir(); - let pid = std::process::id(); - p.push(format!("cjtui-mpv-{}.sock", pid)); - p -} - -fn mpv_request(socket: &PathBuf, json_line: &str) -> Result { - let mut stream = UnixStream::connect(socket) - .map_err(|e| format!("mpv ipc connect failed: {e}"))?; - - // Prevent UI hangs if mpv doesn't respond - let _ = stream.set_write_timeout(Some(Duration::from_millis(40))); - let _ = stream.set_read_timeout(Some(Duration::from_millis(40))); - - stream - .write_all(json_line.as_bytes()) - .map_err(|e| format!("mpv ipc write failed: {e}"))?; - - let mut reader = BufReader::new(stream); - let mut line = String::new(); - - match reader.read_line(&mut line) { - Ok(0) => Err("mpv ipc: EOF".to_string()), - Ok(_) => Ok(line), - Err(e) => { - // timeout is normal; just treat it as "no data yet" - if e.kind() == io::ErrorKind::WouldBlock || e.kind() == io::ErrorKind::TimedOut { - Err("mpv ipc timeout".to_string()) - } else { - Err(format!("mpv ipc read failed: {e}")) - } - } - } -} - -fn mpv_get_bool(mpv: &MpvSession, prop: &str) -> Result { - let resp = mpv_request( - &mpv.socket_path, - &format!(r#"{{"command":["get_property","{}"]}}\n"#, prop), - )?; - - let v: Value = serde_json::from_str(resp.trim()) - .map_err(|e| format!("mpv json parse failed: {e} ({})", resp.trim()))?; - - if v.get("error").and_then(|e| e.as_str()) != Some("success") { - return Err(format!("mpv get_property failed: {}", resp.trim())); - } - - Ok(v.get("data").and_then(|d| d.as_bool()).unwrap_or(false)) -} - -fn mpv_get_f64(mpv: &MpvSession, prop: &str) -> Result, String> { - let resp = mpv_request( - &mpv.socket_path, - &format!(r#"{{"command":["get_property","{}"]}}\n"#, prop), - )?; - - let v: Value = serde_json::from_str(resp.trim()) - .map_err(|e| format!("mpv json parse failed: {e} ({})", resp.trim()))?; - - if v.get("error").and_then(|e| e.as_str()) != Some("success") { - return Err(format!("mpv get_property failed: {}", resp.trim())); - } - - Ok(v.get("data").and_then(|d| d.as_f64())) -} - -fn mpv_send(socket: &PathBuf, json_line: &str) -> Result<(), String> { - let mut stream = UnixStream::connect(socket) - .map_err(|e| format!("mpv ipc connect failed: {e}"))?; - stream - .write_all(json_line.as_bytes()) - .map_err(|e| format!("mpv ipc write failed: {e}"))?; - Ok(()) -} - -fn mpv_cmd(socket: &PathBuf, cmd: &str) -> Result<(), String> { - // cmd should already be valid JSON without trailing newline - mpv_send(socket, &format!("{cmd}\n")) -} - -fn ticks_to_seconds(ticks: u64) -> f64 { - // Jellyfin ticks are 10,000,000 per second - (ticks as f64) / 10_000_000.0 -} - -fn seconds_to_ticks(seconds: f64) -> Option { - if !seconds.is_finite() || seconds < 0.0 { - return None; - } - Some((seconds * 10_000_000.0).round() as i64) -} - -fn mpv_seek_absolute(socket: &PathBuf, seconds: f64) -> Result<(), String> { - // Wait until mpv has loaded something real (duration becomes available), - // then issue a seek and REQUIRE a success reply. - let seek_cmd = format!(r#"{{"command":["seek",{}, "absolute"]}}"#, seconds); - - let start = Instant::now(); - while start.elapsed() < Duration::from_millis(2500) { - // duration being Some is a good signal that the file is actually ready - let dur = mpv_get_f64_jtui(socket, "duration", 1).ok().flatten(); - if dur.is_some() { - // seek, but only accept success - if mpv_cmd_reply(socket, &seek_cmd).is_ok() { - return Ok(()); - } - } - - thread::sleep(Duration::from_millis(50)); - } - - Err("mpv seek failed (mpv never became ready or rejected seek)".into()) -} - -fn spawn_mpv(cfg: &core::config::Config, first_item_id: &str) -> Result { - let token = cfg - .access_token - .as_deref() - .ok_or_else(|| "Missing access_token (login again)".to_string())?; - - let base = cfg.base_url.trim_end_matches('/'); - let url = format!("{}/Items/{}/Download", base, first_item_id); - - let header = format!("X-Emby-Token: {}", token); - let sock = mpv_socket_path(); - - // If a stale socket exists, remove it. - let _ = std::fs::remove_file(&sock); - - let mut child = Command::new("mpv") - .arg(url) - .arg("--force-window=yes") - .arg("--keep-open=yes") - .arg("--idle=yes") - .arg(format!("--http-header-fields={header}")) - .arg(format!("--input-ipc-server={}", sock.display())) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .map_err(|e| format!("Failed to spawn mpv: {e}"))?; - - // Wait briefly for IPC socket to appear - let start = Instant::now(); - while start.elapsed() < Duration::from_millis(800) { - if sock.exists() { - return Ok(MpvSession { - socket_path: sock, - child, - }); - } - thread::sleep(Duration::from_millis(10)); - } - - // If socket never appeared, mpv probably died - let _ = child.kill(); - Err("mpv IPC socket did not appear".to_string()) -} - -fn mpv_load_item(cfg: &core::config::Config, mpv: &MpvSession, item_id: &str) -> Result<(), String> { - let base = cfg.base_url.trim_end_matches('/'); - let url = format!("{}/Items/{}/Download", base, item_id); - - // loadfile replace - mpv_cmd( - &mpv.socket_path, - &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url), - ) -} - -fn mpv_cycle_audio(mpv: &MpvSession) -> Result<(), String> { - mpv_cmd(&mpv.socket_path, r#"{"command":["cycle","audio"]}"#) -} - -fn mpv_cycle_subtitles(mpv: &MpvSession) -> Result<(), String> { - mpv_cmd(&mpv.socket_path, r#"{"command":["cycle","sub"]}"#) -} - -fn mpv_toggle_subs(mpv: &MpvSession) -> Result<(), String> { - mpv_cmd(&mpv.socket_path, r#"{"command":["cycle","sub-visibility"]}"#) -} - -fn mpv_stop(mpv: &mut MpvSession) { - let _ = mpv_cmd(&mpv.socket_path, r#"{"command":["quit"]}"#); - let _ = mpv.child.kill(); - let _ = std::fs::remove_file(&mpv.socket_path); -} - -fn mpv_get_prop_jtui(socket: &PathBuf, prop: &str, request_id: u32) -> Result { - let mut stream = UnixStream::connect(socket) - .map_err(|e| format!("mpv ipc connect failed: {e}"))?; - - let _ = stream.set_write_timeout(Some(Duration::from_millis(250))); - let _ = stream.set_read_timeout(Some(Duration::from_millis(250))); - - let cmd = format!( - r#"{{"command":["get_property","{}"],"request_id":{}}}"#, - prop, request_id - ); - - stream - .write_all(format!("{cmd}\n").as_bytes()) - .map_err(|e| format!("mpv ipc write failed: {e}"))?; - - // IMPORTANT: read a whole JSON line (track-list can be >1024 bytes) - let mut reader = BufReader::new(stream); - let mut line = String::new(); - reader - .read_line(&mut line) - .map_err(|e| format!("mpv ipc read failed: {e}"))?; - - if line.trim().is_empty() { - return Err("mpv ipc: empty response".into()); - } - - let v: Value = serde_json::from_str(line.trim()) - .map_err(|e| format!("mpv json parse failed: {e} ({})", line.trim()))?; - - if v.get("error").and_then(|e| e.as_str()) != Some("success") { - return Err(format!("mpv get_property({prop}) failed: {v}")); - } - - Ok(v.get("data").cloned().unwrap_or(Value::Null)) -} - -fn selected_tracks_from_track_list(track_list: &Value) -> (Option, Option) { - let arr = match track_list.as_array() { - Some(a) => a, - None => return (None, None), - }; - - let mut audio: Option = None; - let mut subs: Option = None; - - for t in arr { - let ty = t.get("type").and_then(|x| x.as_str()).unwrap_or(""); - let selected = t.get("selected").and_then(|x| x.as_bool()).unwrap_or(false); - - if !selected { - continue; - } - - match ty { - "audio" => audio = Some(track_label_from_entry(t, "Audio")), - "sub" => subs = Some(track_label_from_entry(t, "Sub")), - _ => {} - } - } - - (audio, subs) -} - -fn mpv_get_f64_jtui(socket: &PathBuf, prop: &str, request_id: u32) -> Result, String> { - Ok(mpv_get_prop_jtui(socket, prop, request_id)?.as_f64()) -} - -fn mpv_get_bool_jtui(socket: &PathBuf, prop: &str, request_id: u32) -> Result { - Ok(mpv_get_prop_jtui(socket, prop, request_id)?.as_bool().unwrap_or(false)) -} - -fn mpv_toggle_fullscreen(mpv: &MpvSession) -> Result<(), String> { - // cycles fullscreen on/off - mpv_cmd(&mpv.socket_path, r#"{"command":["cycle","fullscreen"]}"#) - // alternatively: - // mpv_cmd(&mpv.socket_path, r#"{"command":["set_property","fullscreen","yes"]}"#) -} - -fn mpv_cycle_pause(mpv: &MpvSession) -> Result<(), String> { - mpv_cmd(&mpv.socket_path, r#"{"command":["cycle","pause"]}"#) -} - -fn autoplay_tick(app: &mut App) { - if !app.autoplay { - return; - } - - // If user navigated away from the view where autoplay started, cancel it. - if app.view_stack.len() != app.autoplay_view_depth { - app.autoplay = false; - app.autoplay_next_index = None; - app.autoplay_eof_latched = false; - app.autoplay_prev_time_pos = None; - return; - } - - // Snapshot latest mpv state from poller thread - let st = app.mpv_state.clone(); - - // Optional: live debug in status (toggle with 'D') - if app.debug_mpv { - app.status = fmt_mpv_state(&st); - } - - // Track previous time to detect "snapped back to 0" - if let Some(tp) = st.time_pos { - if tp > 0.25 { - app.autoplay_prev_time_pos = Some(tp); - } - } - - let near_end = match (st.time_pos, st.duration) { - (Some(tp), Some(d)) if d.is_finite() && d > 1.0 => (d - tp) <= 0.50, - _ => false, - }; - - let near_100 = match st.percent { - Some(p) if p.is_finite() => p >= 99.8, - _ => false, - }; - - let snapped_to_start_idle = match (st.idle, st.time_pos, app.autoplay_prev_time_pos) { - (true, Some(tp), Some(prev)) if prev > 1.0 && tp < 0.10 => true, - _ => false, - }; - - let finished = st.eof || near_end || near_100 || snapped_to_start_idle; - - // If not finished, clear latch so we can trigger when it *does* finish later. - if !finished { - app.autoplay_eof_latched = false; - return; - } - - // Only trigger once per finish event - if app.autoplay_eof_latched { - return; - } - app.autoplay_eof_latched = true; - - // Need mpv running to advance - let mpv = match &app.mpv { - Some(m) => m, - None => return, - }; - - let next_i = match app.autoplay_next_index { - Some(i) => i, - None => { - app.autoplay = false; - app.status = "$".into(); - return; - } - }; - - // Choose next target based on current view - enum NextTarget { - Online { id: String, name: String, len: usize }, - Offline { path: PathBuf, name: String, len: usize }, - } - - let next = match app.current_view() { - View::Library { items, .. } if next_i < items.len() => { - NextTarget::Online { - id: items[next_i].id.clone(), - name: items[next_i].name.clone(), - len: items.len(), - } - } - View::OfflineLibrary { entries, .. } if next_i < entries.len() => { - // skip dirs: if next_i lands on a dir, find the next file - let mut j = next_i; - while j < entries.len() && entries[j].is_dir { - j += 1; - } - if j >= entries.len() { - // no more playable files - app.autoplay = false; - app.autoplay_next_index = None; - if !app.debug_mpv { app.status = "$".into(); } - return; - } - - NextTarget::Offline { - path: entries[j].path.clone(), - name: entries[j].name.clone(), - len: entries.len(), - } - } - _ => { - app.autoplay = false; - app.autoplay_next_index = None; - if !app.debug_mpv { app.status = "$".into(); } - return; - } - }; - - match &next { - NextTarget::Online { name, .. } => app.mpv_now_playing = Some(name.clone()), - NextTarget::Offline { name, .. } => app.mpv_now_playing = Some(name.clone()), - } - - // Move selection highlight in UI + compute following index - match app.view_stack.last_mut() { - Some(View::Library { selected, .. }) => { - *selected = next_i; - } - Some(View::OfflineLibrary { selected, entries, .. }) => { - // if we skipped dirs, move selection to actual file index - let mut j = next_i; - while j < entries.len() && entries[j].is_dir { j += 1; } - *selected = j; - } - _ => {} - } - - // compute next index (offline should skip dirs too later) - let len = match &next { - NextTarget::Online { len, .. } => *len, - NextTarget::Offline { len, .. } => *len, - }; - app.autoplay_next_index = if next_i + 1 < len { Some(next_i + 1) } else { None }; - - // Load next - match next { - NextTarget::Online { id: next_id, .. } => { - let cfg = match app.config.clone() { - Some(c) => c, - None => { - app.autoplay = false; - app.autoplay_next_index = None; - app.status = "No config".into(); - return; - } - }; - - // If offline, MUST be local - let local = crate::core::downloads::local_media_path_for_item_id(&next_id); - if app.offline && local.is_none() { - app.autoplay = false; - app.autoplay_next_index = None; - app.status = "Offline: next item not downloaded".into(); - return; - } - - let url = if let Some(p) = local { - p.to_string_lossy().to_string() - } else { - build_jellyfin_play_url(&cfg, &next_id) - }; - - // --- progress reporting state for autoplay transitions --- - app.mpv_now_playing_item_id = Some(next_id.clone()); - app.marked_watched_this_session = false; - app.last_reported_position_ticks = None; - app.last_progress_report_at = Instant::now(); - - app.mpv_now_playing_is_stream = url.starts_with("http://") || url.starts_with("https://"); - - if app.mpv_now_playing_is_stream { - if let Ok(client) = core::jellyfin::JellyfinClient::from_config(&cfg) { - let _ = client.report_playback_start(&next_id, None); - } - } - - if let Err(e) = mpv_load_url(&cfg, mpv, &url) { - app.autoplay = false; - app.autoplay_next_index = None; - app.status = format!("autoplay load failed: {e}"); - return; - } - - } - - NextTarget::Offline { path, .. } => { - let url = path.to_string_lossy().to_string(); - app.mpv_now_playing_item_id = None; - app.mpv_now_playing_is_stream = false; - app.marked_watched_this_session = false; - app.last_reported_position_ticks = None; - app.last_progress_report_at = Instant::now(); - if let Err(e) = mpv_load_local(mpv, &url) { - app.autoplay = false; - app.autoplay_next_index = None; - app.status = format!("offline autoplay load failed: {e}"); - return; - } - } - } - - let _ = mpv_set_pause(mpv, false); - app.autoplay_prev_time_pos = None; - if !app.debug_mpv { - app.status = "$".into(); - } -} - -fn playback_reporting_tick(app: &mut App) { - // Only report for streaming items with a known Jellyfin item id - if !app.mpv_now_playing_is_stream { - return; - } - let item_id = match app.mpv_now_playing_item_id.clone() { - Some(id) => id, - None => return, - }; - - // Need config + user id - let cfg = match app.config.clone() { - Some(c) => c, - None => return, - }; - let user_id = match cfg.user_id.clone() { - Some(u) => u, - None => return, - }; - - // Need current playback time - let secs = match app.mpv_state.time_pos { - Some(s) if s.is_finite() && s >= 0.0 => s, - _ => return, - }; - let pos_ticks = match seconds_to_ticks(secs) { - Some(t) => t, - None => return, - }; - - // Throttle (every 10s) and avoid duplicate sends - let now = Instant::now(); - if now.duration_since(app.last_progress_report_at) < Duration::from_secs(10) { - return; - } - if let Some(last) = app.last_reported_position_ticks { - // if time didn't meaningfully move, don't spam - if (pos_ticks - last).abs() < 5 * 10_000_000 { - app.last_progress_report_at = now; - return; - } - } - - // Send progress (best effort) - if let Ok(client) = core::jellyfin::JellyfinClient::from_config(&cfg) { - // NOTE: adjust this call name/signature to whatever you implemented in core. - // Common pattern: report_playback_progress(item_id, position_ticks) - let _ = client.report_playback_progress(&item_id, Some(pos_ticks), app.mpv_state.paused); - - app.last_reported_position_ticks = Some(pos_ticks); - app.last_progress_report_at = now; - - // Mark watched once when >=95% - let pct = app.mpv_state.percent.unwrap_or(0.0); - if !app.marked_watched_this_session && pct.is_finite() && pct >= 95.0 { - let _ = client.mark_played(&user_id, &item_id); - app.marked_watched_this_session = true; - - // Optional: update current library row immediately if visible - if let Some(View::Library { items, .. }) = app.view_stack.last_mut() { - if let Some(it) = items.iter_mut().find(|it| it.id == item_id) { - let ud = it.user_data.get_or_insert_with(core::jellyfin::JfUserData::default); - ud.played = Some(true); - ud.played_percentage = Some(100.0); - ud.playback_position_ticks = Some(0); - } - } - } - } -} - -fn mpv_set_pause(mpv: &MpvSession, paused: bool) -> Result<(), String> { - // pause is a boolean property in mpv JSON IPC - let v = if paused { "true" } else { "false" }; - mpv_cmd( - &mpv.socket_path, - &format!(r#"{{"command":["set_property","pause",{}]}}"#, v), - ) -} - -fn fmt_mpv_state(st: &MpvState) -> String { - let pct = match st.percent { - Some(p) if p.is_finite() => p, - _ => match (st.time_pos, st.duration) { - (Some(t), Some(d)) if t.is_finite() && d.is_finite() && d > 0.0 => (t / d) * 100.0, - _ => 0.0, - }, - }; - - format!("{pct:5.1}%") -} - -fn start_mpv_poller(socket_path: PathBuf) -> mpsc::Receiver { - let (tx, rx) = mpsc::channel::(); - - std::thread::spawn(move || { - let mut request_id: u32 = 1; - - loop { - let mut st = MpvState::default(); - - // jtui pattern: connect->write->read(1024)->parse->close (per property call) - let tp = mpv_get_f64_jtui(&socket_path, "time-pos", request_id).ok().flatten(); - request_id = request_id.wrapping_add(1); - - let dur = mpv_get_f64_jtui(&socket_path, "duration", request_id).ok().flatten(); - request_id = request_id.wrapping_add(1); - - let percent_pos = mpv_get_f64_jtui(&socket_path, "percent-pos", request_id).ok().flatten(); - request_id = request_id.wrapping_add(1); - - let eof = mpv_get_bool_jtui(&socket_path, "eof-reached", request_id).unwrap_or(false); - request_id = request_id.wrapping_add(1); - - let idle = mpv_get_bool_jtui(&socket_path, "idle-active", request_id).unwrap_or(false); - request_id = request_id.wrapping_add(1); - - let aid = mpv_get_prop_jtui(&socket_path, "aid", request_id).ok().and_then(|v| v.as_i64()); - request_id = request_id.wrapping_add(1); - - let sid = mpv_get_prop_jtui(&socket_path, "sid", request_id).ok().and_then(|v| v.as_i64()); - request_id = request_id.wrapping_add(1); - - let track_list = mpv_get_prop_jtui(&socket_path, "track-list", request_id).ok(); - request_id = request_id.wrapping_add(1); - - let paused = mpv_get_bool_jtui(&socket_path, "pause", request_id).unwrap_or(false); - request_id = request_id.wrapping_add(1); - - let (audio, subs) = match track_list.as_ref() { - Some(v) => selected_tracks_from_track_list(v), - None => (None, None), - }; - - let media_title = mpv_get_prop_jtui(&socket_path, "media-title", request_id) - .ok() - .and_then(|v| v.as_str().map(|s| s.to_string())); - request_id = request_id.wrapping_add(1); - - st.time_pos = tp; - st.duration = dur; - st.eof = eof; - st.idle = idle; - st.media_title = media_title; - st.audio = audio; - st.subs = subs; - st.paused = paused; - - // compute percent yourself (jtui style) - st.percent = percent_pos - .filter(|p| p.is_finite()) - .or_else(|| match (tp, dur) { - (Some(t), Some(d)) if t.is_finite() && d.is_finite() && d > 0.0 => Some((t / d) * 100.0), - _ => None, - }); - - - // Optional: populate last_err if we got nothing useful - if st.time_pos.is_none() && st.duration.is_none() { - st.last_err = Some("mpv poll: no time-pos/duration (check socket / mpv running)".into()); - } - - if tx.send(st).is_err() { - break; - } - - std::thread::sleep(Duration::from_millis(200)); - } - }); - - rx -} - -fn spawn_mpv_url(cfg: &core::config::Config, url: &str) -> Result { - let token = cfg - .access_token - .as_deref() - .ok_or_else(|| "Missing access_token (login again)".to_string())?; - - let header = format!("X-Emby-Token: {}", token); - let sock = mpv_socket_path(); - - let _ = std::fs::remove_file(&sock); - - let mut child = Command::new("mpv") - .arg(url) - .arg("--force-window=yes") - .arg("--keep-open=yes") - .arg("--idle=yes") - .arg(format!("--http-header-fields={header}")) - .arg(format!("--input-ipc-server={}", sock.display())) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .map_err(|e| format!("Failed to spawn mpv: {e}"))?; - - let start = Instant::now(); - while start.elapsed() < Duration::from_millis(800) { - if sock.exists() { - return Ok(MpvSession { socket_path: sock, child }); - } - thread::sleep(Duration::from_millis(10)); - } - - let _ = child.kill(); - Err("mpv IPC socket did not appear".to_string()) -} - -fn mpv_load_url(cfg: &core::config::Config, mpv: &MpvSession, url: &str) -> Result<(), String> { - // mpv already has the http header from spawn flags - let _ = cfg; // keep signature symmetric; might be useful later - mpv_cmd( - &mpv.socket_path, - &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url), - ) -} - -fn track_label(title: Option<&str>, lang: Option<&str>, id: Option, kind: &str) -> String { - let mut parts: Vec = Vec::new(); - - if let Some(t) = title.map(|s| s.trim()).filter(|s| !s.is_empty()) { - parts.push(t.to_string()); - } - if let Some(l) = lang.map(|s| s.trim()).filter(|s| !s.is_empty()) { - parts.push(l.to_string()); - } - - if parts.is_empty() { - if let Some(i) = id { - return format!("{kind} {i}"); - } - return "-".into(); - } - - parts.join(" • ") -} - -fn track_label_from_entry(t: &serde_json::Value, kind: &str) -> String { - let title = t.get("title").and_then(|x| x.as_str()).map(str::trim).filter(|s| !s.is_empty()); - let lang = t.get("lang").and_then(|x| x.as_str()).map(str::trim).filter(|s| !s.is_empty()); - - // audio extras are often useful (codec/channels) - let codec = t.get("codec").and_then(|x| x.as_str()).map(str::trim).filter(|s| !s.is_empty()); - let ch = t.get("channels").and_then(|x| x.as_str()).map(str::trim).filter(|s| !s.is_empty()); - - let mut parts: Vec = Vec::new(); - if let Some(v) = title { parts.push(v.to_string()); } - if let Some(v) = lang { parts.push(v.to_string()); } - - if kind == "Audio" { - if let Some(v) = codec { parts.push(v.to_string()); } - if let Some(v) = ch { parts.push(v.to_string()); } - } - - if parts.is_empty() { "-".into() } else { parts.join(" • ") } -} - -fn tracks_from_ids(track_list: &serde_json::Value, aid: Option, sid: Option) -> (Option, Option) { - let arr = match track_list.as_array() { - Some(a) => a, - None => return (None, None), - }; - let mut audio: Option = None; - let mut subs: Option = None; - - for t in arr { - let ty = t.get("type").and_then(|x| x.as_str()).unwrap_or(""); - let id = t.get("id").and_then(|x| x.as_i64()); - - match (ty, id) { - ("audio", Some(id)) if Some(id) == aid => { - audio = Some(track_label_from_entry(t, "Audio")); - } - ("sub", Some(id)) if Some(id) == sid => { - subs = Some(track_label_from_entry(t, "Sub")); - } - _ => {} - } - } - - (audio, subs) -} - -fn fmt_hms(t: f64) -> String { - if !t.is_finite() || t < 0.0 { return "--:--".into(); } - let total = t as u64; - let h = total / 3600; - let m = (total % 3600) / 60; - let s = total % 60; - if h > 0 { format!("{h}:{m:02}:{s:02}") } else { format!("{m}:{s:02}") } -} - -fn mpv_progress(st: &MpvState) -> (u16, String) { - let pct = st.percent.unwrap_or(0.0).clamp(0.0, 100.0); - let p_u16 = pct.round() as u16; - - let cur = st.time_pos.map(fmt_hms).unwrap_or("--:--".into()); - let dur = st.duration.map(fmt_hms).unwrap_or("--:--".into()); - (p_u16, format!("{cur} / {dur} ({pct:0.1}%)")) -} - -fn render_downloads_panel(frame: &mut Frame, app: &App, area: Rect) { - let title = if app.downloads_focus { - "Downloads (focus)" - } else { - "Downloads" - }; - - // rows available inside the border - let viewport = area.height.saturating_sub(2) as usize; - - let mut rows: Vec = Vec::new(); - let mut state = ListState::default(); - - // Helper: "basic" scrolling window (no centering) - // Ensures selected is always visible within [start, start+viewport) - fn basic_window(len: usize, selected: usize, viewport: usize) -> (usize, usize) { - if len == 0 || viewport == 0 { - return (0, 0); - } - let viewport = viewport.min(len); - let mut start = 0usize; - - // If selected is below the visible window, scroll down just enough - if selected >= start + viewport { - start = selected + 1 - viewport; - } - // If selected is above the visible window, scroll up just enough - if selected < start { - start = selected; - } - - // Clamp - if start + viewport > len { - start = len - viewport; - } - - (start, start + viewport) - } - - // Empty states - if app.downloads.is_none() { - rows.push(ListItem::new("(downloads disabled)")); - state.select(None); - - let list = List::new(rows) - .block(Block::bordered().title(title).merge_borders(MergeStrategy::Exact)) - .highlight_symbol(">> ") - .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); - - frame.render_stateful_widget(list, area, &mut state); - return; - } - - let dm = app.downloads.as_ref().unwrap(); - - match dm.state.lock() { - Err(_) => { - rows.push(ListItem::new("(downloads lock poisoned)")); - state.select(None); - } - Ok(st) => { - let len = st.queue.len(); - - if len == 0 { - rows.push(ListItem::new("(empty)")); - state.select(None); - } else { - // Clamp selection - let selected = app.downloads_selected.min(len.saturating_sub(1)); - - // Basic scrolling window - let (start, end) = basic_window(len, selected, viewport); - - for i in start..end { - let it = match st.queue.get(i) { - Some(v) => v, - None => continue, - }; - - let status = match &it.status { - DownloadStatus::Queued => "queued", - DownloadStatus::Downloading => "downloading", - DownloadStatus::Finalizing => "finalizing", - DownloadStatus::Completed => "done", - DownloadStatus::Cancelled => "cancelled", - DownloadStatus::Failed(_) => "failed", - }; - - let active_prefix = if i == 0 { "▶ " } else { " " }; - - // Prefer aria2-derived info if you have it; otherwise it will show --% (--:--) - let prog_prefix = if matches!(it.status, DownloadStatus::Downloading) { - // uses the fields you now populate from aria2 RPC - // (bytes_downloaded, bytes_total, eta_secs) - let pct_str = match it.bytes_total { - Some(t) if t > 0 => { - let pct = (it.bytes_downloaded as f64 / t as f64) * 100.0; - format!("{:0.0}%", pct.clamp(0.0, 100.0)) - } - _ => "--%".to_string(), - }; - - let eta_str = match it.eta_secs { - Some(s) => fmt_hms(s as f64), - None => "--:--".to_string(), - }; - - format!("{pct_str} ({eta_str}) ") - } else { - String::new() - }; - - rows.push(ListItem::new(format!( - "{active_prefix}{status}: {prog_prefix}{}", - it.title - ))); - } - - // selection must be relative to the window slice - state.select(Some(selected - start)); - } - } - } - - let list = List::new(rows) - .block(Block::bordered().title(title).merge_borders(MergeStrategy::Exact)) - .highlight_symbol(">> ") - .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); - - frame.render_stateful_widget(list, area, &mut state); -} - -fn next_offline_playable(entries: &[OfflineEntry], from_index: usize) -> Option { - if entries.is_empty() { - return None; - } - let mut i = from_index + 1; - while i < entries.len() { - if !entries[i].is_dir { - return Some(i); - } - i += 1; - } - None -} - -fn spawn_mpv_local(url: &str) -> Result { - let sock = mpv_socket_path(); - let _ = std::fs::remove_file(&sock); - - let mut child = Command::new("mpv") - .arg(url) - .arg("--force-window=yes") - .arg("--keep-open=yes") - .arg("--idle=yes") - .arg(format!("--input-ipc-server={}", sock.display())) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .map_err(|e| format!("Failed to spawn mpv: {e}"))?; - - let start = Instant::now(); - while start.elapsed() < Duration::from_millis(800) { - if sock.exists() { - return Ok(MpvSession { socket_path: sock, child }); - } - thread::sleep(Duration::from_millis(10)); - } - - let _ = child.kill(); - Err("mpv IPC socket did not appear".to_string()) -} - -fn mpv_load_local(mpv: &MpvSession, url: &str) -> Result<(), String> { - mpv_cmd( - &mpv.socket_path, - &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url), - ) -} - - -// Returns e.g. "56% (4:32) " -// If total is unknown, returns "--% (--:--) " -fn format_download_pct_eta(downloaded: u64, total: Option, added_at: Instant) -> String { - // Percent - let pct_str = match total { - Some(t) if t > 0 => { - let pct = (downloaded as f64 / t as f64) * 100.0; - format!("{:0.0}%", pct.clamp(0.0, 100.0)) - } - _ => "--%".to_string(), - }; - - // ETA from average speed since added_at (good enough for UI) - let elapsed = added_at.elapsed().as_secs_f64(); - let speed = if elapsed > 0.0 { - downloaded as f64 / elapsed - } else { - 0.0 - }; - - let eta_str = match (total, speed) { - (Some(t), s) if t > downloaded && s > 1.0 => { - let remaining = (t - downloaded) as f64; - let eta_secs = remaining / s; - fmt_hms(eta_secs) - } - _ => "--:--".to_string(), - }; - - format!("{pct_str} ({eta_str}) ") -} - - -fn render_mpv_panel(frame: &mut Frame, app: &App, area: Rect) { - // Make the bar exactly 1 row and don't depend on bordered blocks. - let [c0, c1, c2, c3, c4, c5] = Layout::vertical([ - Constraint::Length(1), // title (no border) - Constraint::Length(1), // audio - Constraint::Length(1), // subs - Constraint::Length(1), // BAR (raw) - Constraint::Length(1), // time - Constraint::Min(1), // controls - ]) - .areas(area); - - let st = &app.mpv_state; - - let title = app - .mpv_now_playing - .clone() - .or_else(|| st.media_title.clone()) - .unwrap_or_else(|| "-".into()); - - let audio = st.audio.clone().unwrap_or_else(|| "-".into()); - let subs = st.subs.clone().unwrap_or_else(|| "None".into()); - - frame.render_widget(Paragraph::new(format!("▶ {title}")), c0); - frame.render_widget(Paragraph::new(format!("Audio: {audio}")), c1); - frame.render_widget(Paragraph::new(format!("Subs: {subs}")), c2); - - let ratio = (st.percent.unwrap_or(0.0) / 100.0).clamp(0.0, 1.0); - let pct = st.percent.unwrap_or(0.0); - - // reserve 1 col so we don't draw into the overlap w/ right pane border - let safe_w = c3.width.saturating_sub(1); - - let bar_line = mpv_bar_line(safe_w, ratio, pct); - frame.render_widget(Paragraph::new(bar_line), c3); - - let cur_secs = st.time_pos.unwrap_or(0.0); - let dur_secs = st.duration.unwrap_or(0.0); - - let cur = st.time_pos.map(fmt_hms).unwrap_or("--:--".into()); - let dur = st.duration.map(fmt_hms).unwrap_or("--:--".into()); - - let remaining = if dur_secs > 0.0 && cur_secs <= dur_secs { - fmt_hms(dur_secs - cur_secs) - } else { - "--:--".into() - }; - - frame.render_widget( - Paragraph::new(format!("{cur} / {dur} ({remaining} remaining)")), - c4, - ); - - let controls = - "Space: pause x: stop f: fullscreen\na: audio s: subs v: sub vis"; - frame.render_widget(Paragraph::new(controls).wrap(Wrap { trim: true }), c5); -} - -fn mpv_bar_line(width: u16, ratio: f64, pct: f64) -> String { - let w = width as usize; - if w == 0 { - return String::new(); - } - - let prefix = "mpv "; - let pct_text = format!("{:>5.1}%", pct.clamp(0.0, 100.0)); // " 43.3%" - let pct_area_min = pct_text.len() + 2; // breathing room around percent - - // If too narrow, just show prefix+percent - if w <= prefix.len() + pct_text.len() { - return format!("{prefix}{pct_text}"); - } - - let avail = w - prefix.len(); - - // Give pct a fixed-ish area if we can, but keep at least 2 cols for the bar: "[]" - let pct_area = pct_area_min.min(avail.saturating_sub(2)); - let bar_w = (avail - pct_area).max(2); - - let bar = unicode_progress_bar(bar_w, ratio); - - // IMPORTANT: compute remaining space using bar_w (columns), not bar.len() (bytes) - let pct_area_actual = avail.saturating_sub(bar_w); - let mut pct_buf = vec![' '; pct_area_actual]; - - if pct_area_actual >= pct_text.len() { - let start = (pct_area_actual - pct_text.len()) / 2; - for (i, ch) in pct_text.chars().enumerate() { - pct_buf[start + i] = ch; - } - } - - let pct_field: String = pct_buf.into_iter().collect(); - format!("{prefix}{bar}{pct_field}") -} - -fn unicode_progress_bar(width: usize, ratio: f64) -> String { - // width includes the brackets: [ .... ] - if width < 2 { - return String::new(); - } - - let inner = width - 2; - let ratio = ratio.clamp(0.0, 1.0); - - // 8-level partial blocks - // 0..=7 maps to: (none), ▏▎▍▌▋▊▉ - const PARTS: [char; 8] = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉']; - - let total_units = inner * 8; - let filled_units = (ratio * total_units as f64).round() as usize; - - let full = filled_units / 8; - let rem = filled_units % 8; - - let mut s = String::with_capacity(width); - s.push('['); - - // full blocks - let full = full.min(inner); - s.push_str(&"█".repeat(full)); - - // partial block (if any room left) - if full < inner { - if rem > 0 { - s.push(PARTS[rem]); - } else { - s.push(' '); - } - - // rest empty - let used = full + 1; - if used < inner { - s.push_str(&" ".repeat(inner - used)); - } - } - - s.push(']'); - s -} - -fn is_music_item(it: &core::jellyfin::JfItem) -> bool { - matches!(it.item_type.as_deref(), Some("Audio" | "MusicAlbum" | "MusicArtist")) -} - -fn is_downloaded(app: &App, it: &core::jellyfin::JfItem) -> bool { - // Basic: only exact item-id matches. - // (No container inference for Series/Season/Album right now.) - app.downloaded_ids.contains(&it.id) -} - -fn is_played(it: &core::jellyfin::JfItem) -> bool { - if it.user_data.as_ref().and_then(|u| u.played) == Some(true) { - return true; - } - - // Jellyfin often provides this for Season/Series - if matches!(it.item_type.as_deref(), Some("Season" | "Series")) { - if it.user_data.as_ref().and_then(|u| u.unplayed_item_count) == Some(0) { - return true; - } - } - - if let Some(p) = it.user_data.as_ref().and_then(|u| u.played_percentage) { - return p >= 99.9; - } - - false -} - -fn playback_position_ticks(it: &core::jellyfin::JfItem) -> Option { - it.user_data - .as_ref() - .and_then(|u| u.playback_position_ticks) - .and_then(|v| if v > 0 { Some(v as u64) } else { None }) -} - -fn is_in_progress_fast(it: &core::jellyfin::JfItem) -> bool { - // Already fully played? Never in-progress. - if is_played(it) { - return false; - } - - match it.item_type.as_deref() { - // Movies / Episodes → resume ticks mean in-progress - Some("Movie" | "Episode") => { - playback_position_ticks(it).is_some() - } - - // Seasons → jtui-style fast logic - Some("Season") => { - // 1) Prefer server percentage if present - if let Some(p) = it.user_data - .as_ref() - .and_then(|u| u.played_percentage) - { - if p > 0.1 && p < 99.9 { - return true; - } - } - - // 2) Fallback to count-based detection: - // 0 < UnplayedItemCount < ChildCount - let total = it.child_count - .and_then(|v| if v > 0 { Some(v as u64) } else { None }); - - let unplayed = it.user_data - .as_ref() - .and_then(|u| u.unplayed_item_count) - .and_then(|v| if v >= 0 { Some(v as u64) } else { None }); - - match (total, unplayed) { - (Some(t), Some(u)) if t > 0 && u > 0 && u < t => true, - _ => false, - } - } - - // Series → complete or nothing (as you requested) - Some("Series") => false, - - _ => false, - } -} - -fn emoji_prefix_for(app: &App, it: &core::jellyfin::JfItem) -> String { - // Music: show only the local-download indicator (💾) and never show played/in-progress. - // Everything else keeps the existing behavior (💾 + ✅/🟠). - let downloaded = is_downloaded(app, it); - - if is_music_item(it) { - return if downloaded { "💾 ".to_string() } else { String::new() }; - } - - let played = is_played(it); - let inprog = is_in_progress_fast(it); - - let mut s = String::new(); - - if downloaded { - s.push_str("💾 "); - } - - if played { - s.push_str("✅ "); - } else if inprog { - s.push_str("🟠 "); - } - - s -} - -/* -fn is_in_progress(it: &core::jellyfin::JfItem) -> bool { - if is_played(it) { - return false; - } - - match it.item_type.as_deref() { - // Movies/Episodes: only if resume ticks exist - Some("Movie" | "Episode") => playback_position_ticks(it).is_some(), - - // Seasons: percent-based - Some("Season") => it.user_data - .as_ref() - .and_then(|u| u.played_percentage) - .is_some_and(|p| p > 0.1 && p < 99.9), - - // Series: no in-progress marker (your requirement) - Some("Series") => false, - - _ => false, - } -} -*/ - -// --- transcoding + resume URL builder (Approach B) --- -fn clamp_mbps_to_bps(mbps: u32) -> u32 { - mbps.saturating_mul(1_000_000) // Mb/s -> bits/s -} - -fn build_jellyfin_play_url(cfg: &core::config::Config, item_id: &str) -> String { - let base = cfg.base_url.trim_end_matches('/'); - format!("{}/Items/{}/Download", base, item_id) -} - -fn mpv_cmd_reply(socket: &PathBuf, cmd: &str) -> Result { - let mut stream = UnixStream::connect(socket) - .map_err(|e| format!("mpv ipc connect failed: {e}"))?; - - let _ = stream.set_write_timeout(Some(Duration::from_millis(500))); - let _ = stream.set_read_timeout(Some(Duration::from_millis(500))); - - stream - .write_all(format!("{cmd}\n").as_bytes()) - .map_err(|e| format!("mpv ipc write failed: {e}"))?; - - let mut reader = BufReader::new(stream); - let mut line = String::new(); - reader - .read_line(&mut line) - .map_err(|e| format!("mpv ipc read failed: {e}"))?; - - if line.trim().is_empty() { - return Err("mpv ipc: empty response".into()); - } - - let v: Value = serde_json::from_str(line.trim()) - .map_err(|e| format!("mpv json parse failed: {e} ({})", line.trim()))?; - - if v.get("error").and_then(|e| e.as_str()) != Some("success") { - return Err(format!("mpv command failed: {}", line.trim())); - } - - Ok(v) -} - -struct Panes { - top: Rect, - left_main: Rect, - right_main: Rect, - left_output: Rect, - right_output: Rect, -} - -fn layout(area: Rect) -> Panes { - use Constraint::Fill; - - let [top, bottom] = - Layout::vertical([Fill(1), Fill(12)]).areas(area); - - let [main_area, output_area] = - Layout::vertical([Fill(9), Fill(3)]) - .spacing(Spacing::Overlap(1)) - .areas(bottom); - - let [left_main, right_main] = - Layout::horizontal([Fill(1), Fill(1)]) - .spacing(Spacing::Overlap(1)) - .areas(main_area); - - let [left_output, right_output] = - Layout::horizontal([Fill(7), Fill(2)]) - .spacing(Spacing::Overlap(1)) - .areas(output_area); - - Panes { - top, - left_main, - right_main, - left_output, - right_output, - } -} - -fn render_login(frame: &mut Frame, app: &App, left: Rect, right: Rect) { - let focus = match app.login_focus { - LoginFocus::User => "username", - LoginFocus::Pass => "password", - }; - - let masked = "*".repeat(app.login_pass.len()); - - let left_text = format!( - "Login (Tab switch, Enter submit)\n\nusername: {}\npassword: {}\n\nfocus: {}\n", - app.login_user, masked, focus - ); - - frame.render_widget( - Paragraph::new(left_text) - .block(Block::bordered().title("Login").merge_borders(MergeStrategy::Exact)), - left, - ); - - let right_text = match &app.login_error { - Some(e) => format!("Error:\n{e}"), - None => "Enter Jellyfin credentials.\nToken will be stored in config.toml.".to_string(), - }; - - frame.render_widget( - Paragraph::new(right_text) - .block(Block::bordered().title("Info").merge_borders(MergeStrategy::Exact)), - right, - ); -} - -fn render_offline_library( - frame: &mut Frame, - title: &str, - items: &[core::downloads::LocalEntry], - selected: usize, - left: Rect, - right: Rect, -) { - let viewport = left.height.saturating_sub(2) as usize; - let len = items.len(); - let selected = selected.min(len.saturating_sub(1)); - - let (start, end) = compute_window(len, selected, viewport); - - let list_items: Vec = items[start..end] - .iter() - .map(|it| ListItem::new(format!("💾 {}", it.title))) - .collect(); - - let list = List::new(list_items) - .block( - Block::bordered() - .title(format!("{title} [{}/{}]", items.len(), items.len())) - .merge_borders(MergeStrategy::Exact), - ) - .highlight_symbol("> ") - .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); - - let mut state = ListState::default(); - state.select(if len > 0 { Some(selected - start) } else { None }); - - frame.render_stateful_widget(list, left, &mut state); - - let detail = if len > 0 { - let it = &items[selected]; - format!("Title: {}\nItemId: {}\nPath:\n{}", it.title, it.item_id, it.path.display()) - } else { - "No downloaded items found".to_string() - }; - - frame.render_widget( - Paragraph::new(detail) - .wrap(Wrap { trim: false }) - .block(Block::bordered().title("Details").merge_borders(MergeStrategy::Exact)), - right, - ); -} - -fn categories_for_mode(offline: bool) -> Vec { - if offline { - vec![ - "Downloaded".into(), - ] - } else { - vec![ - "Shows".into(), - "Movies".into(), - "Music".into(), - "Playlists".into(), - "Collections".into(), - "Libraries".into(), - "Continue Watching".into(), - "Recently Added".into(), - ] - } -} - -#[derive(Clone, Debug)] -struct OfflineEntry { - name: String, - path: std::path::PathBuf, - is_dir: bool, -} - -fn offline_list_dir(dir: &std::path::Path) -> Vec { - let mut out: Vec = Vec::new(); - - let rd = match std::fs::read_dir(dir) { - Ok(r) => r, - Err(_) => return out, - }; - - for ent in rd.flatten() { - let path = ent.path(); - let name = match path.file_name().and_then(|s| s.to_str()) { - Some(s) => s.to_string(), - None => continue, - }; - - // Hide sidecar files - if !path.is_dir() && is_hidden_sidecar(&name) { - continue; - } - - let is_dir = path.is_dir(); - - // ✅ Hide "empty" folders (only sidecars / empty / no real files under them) - if is_dir && !dir_has_visible_content(&path) { - continue; - } - - out.push(OfflineEntry { name, path, is_dir }); - } - - // dirs first, then files; alpha within groups - out.sort_by(|a, b| { - match (a.is_dir, b.is_dir) { - (true, false) => std::cmp::Ordering::Less, - (false, true) => std::cmp::Ordering::Greater, - _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()), - } - }); - - out -} - - -fn is_hidden_sidecar(name: &str) -> bool { - name.ends_with(".cjtui.json") || name.ends_with(".aria2") || name.ends_with(".part") -} - -// Returns true if this directory contains any "visible" file or any subdir that does. -fn dir_has_visible_content(dir: &std::path::Path) -> bool { - let rd = match std::fs::read_dir(dir) { - Ok(r) => r, - Err(_) => return false, - }; - - for ent in rd.flatten() { - let path = ent.path(); - let name = match path.file_name().and_then(|s| s.to_str()) { - Some(s) => s, - None => continue, - }; - - if path.is_dir() { - if dir_has_visible_content(&path) { - return true; - } - continue; - } - - // file - if is_hidden_sidecar(name) { - continue; - } - - return true; // any other file means "visible content" - } - - false -} - - -fn render_offline_library_dir( - frame: &mut Frame, - title: &str, - entries: &[OfflineEntry], - selected: usize, - left: Rect, - right: Rect, -) { - let viewport = left.height.saturating_sub(2) as usize; - - let len = entries.len(); - let selected = selected.min(len.saturating_sub(1)); - - let (start, end) = compute_window(len, selected, viewport); - - let list_items: Vec = entries[start..end] - .iter() - .map(|e| { - if e.is_dir { - ListItem::new(format!("📁 {}", e.name)) - } else { - ListItem::new(format!("💾 {}", e.name)) - } - }) - .collect(); - - let list = List::new(list_items) - .block(Block::bordered().title(title).merge_borders(MergeStrategy::Exact)) - .highlight_symbol("> ") - .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); - - let mut state = ListState::default(); - state.select(if len > 0 { Some(selected - start) } else { None }); - - frame.render_stateful_widget(list, left, &mut state); - - let detail = if len > 0 { - let e = &entries[selected]; - format!( - "Name: {}\nType: {}\nPath:\n{}", - e.name, - if e.is_dir { "dir" } else { "file" }, - e.path.display() - ) - } else { - "Empty directory".to_string() - }; - - frame.render_widget( - Paragraph::new(detail) - .wrap(Wrap { trim: false }) - .block(Block::bordered().title("Details").merge_borders(MergeStrategy::Exact)), - right, - ); -} - -fn ensure_config_before_tui() -> Result<(), String> { - use std::io::{self, Write}; - - use crate::core::config::{self, Config, ConfigError}; - - match config::load_config() { - Ok(_) => return Ok(()), - Err(ConfigError::NotFound(_)) => { - // first run, we'll prompt below - } - Err(e) => { - // config exists but is broken; still offer to recreate - eprintln!("Config load error: {e:?}"); - eprintln!("We can recreate it now."); - } - } - - let path = config::config_path().ok_or_else(|| "Cannot resolve config path (HOME/XDG?)".to_string())?; - - println!("No usable config found."); - println!("We will create: {}", path.display()); - println!(); - - let mut url = String::new(); - loop { - print!("Jellyfin base URL (example: http://localhost:8096): "); - io::stdout().flush().map_err(|e| e.to_string())?; - - url.clear(); - io::stdin() - .read_line(&mut url) - .map_err(|e| e.to_string())?; - - let u = url.trim().trim_end_matches('/').to_string(); - - if u.is_empty() { - println!("URL cannot be empty.\n"); - continue; - } - if !(u.starts_with("http://") || u.starts_with("https://")) { - println!("Please include http:// or https://\n"); - continue; - } - - let cfg = Config { - base_url: u, - access_token: None, - user_id: None, - }; - - config::save_config(&cfg).map_err(|e| format!("save_config failed: {e:?}"))?; - println!("\nWrote config: {}\n", path.display()); - break; - } - - Ok(()) -} - -fn jellyfin_report_stopped_best_effort(app: &mut App) { - // Only report for streaming items with a known Jellyfin item id - if !app.mpv_now_playing_is_stream { - return; - } - let item_id = match app.mpv_now_playing_item_id.clone() { - Some(id) => id, - None => return, - }; - - let cfg = match app.config.clone() { - Some(c) => c, - None => return, - }; - - // position ticks (best effort) - let secs = app.mpv_state.time_pos.unwrap_or(0.0); - let pos_ticks = seconds_to_ticks(secs).unwrap_or(0); - - // IMPORTANT: - // Jellyfin's "Failed" means playback error, not "not completed". - // Completion is handled by your >=95% mark_played logic elsewhere. - let failed = false; - - if let Ok(client) = core::jellyfin::JellyfinClient::from_config(&cfg) { - let _ = client.report_playback_stopped(&item_id, Some(pos_ticks), failed); - } -} - -fn is_playable_item_type(ty: &str) -> bool { - matches!(ty, "Episode" | "Movie" | "Audio") -} +mod core; + +const PAGE_SIZE: usize = 50; + +use std::{ + io::{self, Write, Read, BufRead, BufReader}, + path::PathBuf, + process::{Child, Command, Stdio}, + time::{Duration, Instant}, + collections::HashSet, + thread, +}; + +use std::sync::{Arc, Mutex, mpsc}; + +use std::os::unix::net::UnixStream; + +use serde_json::Value; + +use crate::core::downloads; +use crate::core::downloads::{DownloadCommand, DownloadManager, DownloadStatus}; + +use crossterm::{ + event::{self, Event, KeyCode, KeyEventKind}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + layout::{Constraint, Layout, Rect, Spacing}, + symbols::merge::MergeStrategy, + widgets::{Block, List, ListItem, ListState, Paragraph, Wrap, Gauge}, + style::{Modifier, Style}, + text::Line, + Frame, Terminal, +}; +use ratatui::backend::CrosstermBackend; + +#[derive(Clone)] +enum View { + Categories, + Library { + title: String, + query: core::jellyfin::LibraryQuery, + items: Vec, + selected: usize, + start_index: usize, + total: usize, + page_size: usize, + loading: bool, + playlist_ctx: Option, + }, + OfflineLibrary { + title: String, + dir: std::path::PathBuf, + entries: Vec, + selected: usize, + }, + Login, +} + +#[derive(Clone)] +struct PlaylistCtx { + name: String, +} + +#[derive(Clone, Debug, Default)] +struct MpvState { + eof: bool, + idle: bool, + paused: bool, + time_pos: Option, + duration: Option, + percent: Option, + last_err: Option, + + // new: + media_title: Option, + audio: Option, + subs: Option, +} + +#[derive(Clone, Copy)] +enum LoginFocus { + User, + Pass, +} + +#[derive(Clone, Copy, Debug)] +enum SearchMode { + All, // no filter (true "all") + ShowsMovies, // Movie + Series + Shows, // Series + Movies, // Movie + Episodes, // Episode + Music, // Audio + Albums, // MusicAlbum + Artists, // MusicArtist +} + +impl SearchMode { + fn next(self) -> Self { + use SearchMode::*; + match self { + All => ShowsMovies, + ShowsMovies => Shows, + Shows => Movies, + Movies => Episodes, + Episodes => Music, + Music => Albums, + Albums => Artists, + Artists => All, + } + } + + fn prev(self) -> Self { + use SearchMode::*; + match self { + All => Artists, + ShowsMovies => All, + Shows => ShowsMovies, + Movies => Shows, + Episodes => Movies, + Music => Episodes, + Albums => Music, + Artists => Albums, + } + } + + fn label(self) -> &'static str { + use SearchMode::*; + match self { + All => "All", + ShowsMovies => "Shows+Movies", + Shows => "Shows", + Movies => "Movies", + Episodes => "Episodes", + Music => "Music", + Albums => "Albums", + Artists => "Artists", + } + } + + fn include_item_types(self) -> Option<&'static str> { + use SearchMode::*; + match self { + All => None, // IMPORTANT: truly unfiltered + ShowsMovies => Some("Movie,Series"), + Shows => Some("Series"), + Movies => Some("Movie"), + Episodes => Some("Episode"), + Music => Some("Audio"), + Albums => Some("MusicAlbum"), + Artists => Some("MusicArtist"), + } + } +} + +#[derive(Clone)] +enum SearchScope { + All, + Library { + title: String, + query: core::jellyfin::LibraryQuery, + }, +} + +struct SearchState { + mode: SearchMode, + scope: SearchScope, + input: String, +} + +struct App { + should_exit: bool, + status: String, + config_status: String, + + view_stack: Vec, + + items: Vec, + selected: usize, + + config: Option, + login_user: String, + login_pass: String, + login_focus: LoginFocus, + login_error: Option, + offline: bool, + + search: Option, + mpv: Option, + mpv_poll_last: Instant, + mpv_state: MpvState, + mpv_state_rx: Option>, + debug_mpv: bool, + mpv_now_playing: Option, + mpv_now_playing_item_id: Option, + mpv_now_playing_is_stream: bool, + last_progress_report_at: Instant, + last_reported_position_ticks: Option, + marked_watched_this_session: bool, + downloads: Option, + downloads_focus: bool, + downloads_selected: usize, + downloaded_ids: HashSet, + last_download_message: Option, + + autoplay: bool, + autoplay_view_depth: usize, + autoplay_next_index: Option, + autoplay_eof_latched: bool, + autoplay_prev_time_pos: Option, +} + +#[derive(Debug)] +struct MpvSession { + socket_path: PathBuf, + child: Child, +} + +impl Default for App { + fn default() -> Self { + let loaded = core::config::load_config().ok().map(|mut cfg| { + // Migrates configs written before device_id existed, so an upgraded + // install stops sharing one DeviceId with every other install. + core::config::ensure_device_id(&mut cfg); + cfg + }); + + let (view_stack, config_status, offline) = match &loaded { + Some(cfg) if cfg.access_token.is_some() => { + let online = core::jellyfin::JellyfinClient::ping_base_url(&cfg.base_url); + if online { + (vec![View::Categories], format!("✅ Online: ({})", cfg.base_url), false) + } else { + (vec![View::Categories], format!("📴 Offline: ({})", cfg.base_url), true) + } + } + Some(cfg) => { + // no token: treat as "not connected", but still allow offline browsing + let online = core::jellyfin::JellyfinClient::ping_base_url(&cfg.base_url); + if online { + (vec![View::Login], format!("Not Connected: ({})", cfg.base_url), false) + } else { + (vec![View::Categories], format!("📴 Offline (no token): ({})", cfg.base_url), true) + } + } + None => (vec![View::Login], "Missing Configuration".to_string(), false), + }; + + Self { + should_exit: false, + status: String::new(), + config_status, + config: loaded, + + view_stack, + offline, + + items: categories_for_mode(offline), + + selected: 0, + + login_user: String::new(), + login_pass: String::new(), + login_focus: LoginFocus::User, + login_error: None, + search: None, + mpv: None, + mpv_poll_last: Instant::now(), + mpv_state: MpvState::default(), + mpv_state_rx: None, + debug_mpv: false, + mpv_now_playing: None, + mpv_now_playing_item_id: None, + mpv_now_playing_is_stream: false, + last_progress_report_at: Instant::now(), + last_reported_position_ticks: None, + marked_watched_this_session: false, + autoplay: false, + autoplay_view_depth: 0, + autoplay_next_index: None, + autoplay_eof_latched: false, + autoplay_prev_time_pos: None, + downloads: None, + downloads_focus: false, + downloads_selected: 0, + downloaded_ids: downloads::scan_downloaded_ids().unwrap_or_default(), + last_download_message: None, + + } + } +} + +impl App { + fn current_view(&self) -> &View { + self.view_stack.last().unwrap() + } +} + +fn main() -> io::Result<()> { + if let Err(e) = ensure_config_before_tui() { + eprintln!("{e}"); + return Ok(()); // or: return Err(io::Error::new(io::ErrorKind::Other, e)); + } + + // Terminal setup + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + terminal.clear()?; + + let res = run_app(&mut terminal); + + // Terminal restore (always) + disable_raw_mode()?; + execute!(io::stdout(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + + res +} + +fn run_app(terminal: &mut Terminal>) -> io::Result<()> { + let tick_rate = Duration::from_millis(16); // ~60fps + let mut last_tick = Instant::now(); + let mut app = App::default(); + + while !app.should_exit { + terminal.draw(|f| ui(f, &app))?; + + let timeout = tick_rate.saturating_sub(last_tick.elapsed()); + if event::poll(timeout)? { + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + handle_key(&mut app, key.code); + } + } + } + + if let Some(rx) = &app.mpv_state_rx { + while let Ok(st) = rx.try_recv() { + app.mpv_state = st; + } + } + + if app.debug_mpv { + app.status = fmt_mpv_state(&app.mpv_state); + } + + playback_reporting_tick(&mut app); + + // --- mpv lifecycle (no borrow issues) --- + let mpv_exited = match app.mpv.as_mut() { + Some(mpv) => match mpv.child.try_wait() { + Ok(Some(status)) => Some(status), + _ => None, + }, + None => None, + }; + + if let Some(status) = mpv_exited { + jellyfin_report_stopped_best_effort(&mut app); + + // take ownership so we can clean up + if let Some(mpv) = app.mpv.take() { + let _ = std::fs::remove_file(&mpv.socket_path); + app.status = format!("mpv exited: {status}"); + } + + // Your requirement: window closing disables autoplay + app.autoplay = false; + app.autoplay_next_index = None; + app.mpv_now_playing_item_id = None; + app.mpv_now_playing_is_stream = false; + app.last_reported_position_ticks = None; + app.marked_watched_this_session = false; + app.mpv_now_playing = None; + app.mpv_state_rx = None; + app.mpv_state = MpvState::default(); + } + + // Refresh local-download index when downloads complete + if let Some(dm) = &app.downloads { + if let Ok(st) = dm.state.lock() { + let msg = st.last_message.clone(); + + if msg != app.last_download_message { + app.last_download_message = msg.clone(); + + if matches!(msg.as_deref(), Some("download complete")) { + app.downloaded_ids = downloads::scan_downloaded_ids().unwrap_or_default(); + } + } + } + } + + // Option A: always tick autoplay by polling eof-reached (window stays open) + autoplay_tick(&mut app); + + + if last_tick.elapsed() >= tick_rate { + // tick-based updates go here later (smooth nav, download polling, etc.) + last_tick = Instant::now(); + } + } + + if let Some(dm) = &app.downloads { + dm.send(downloads::DownloadCommand::Shutdown); + } + + Ok(()) +} + +fn handle_key(app: &mut App, code: KeyCode) { + // Special input handling in Login view + if matches!(app.current_view(), View::Login) { + match code { + KeyCode::Char('q') => { app.should_exit = true; return; } + KeyCode::Esc => { app.should_exit = true; return; } + + KeyCode::Tab => { + app.login_focus = match app.login_focus { + LoginFocus::User => LoginFocus::Pass, + LoginFocus::Pass => LoginFocus::User, + }; + return; + } + + KeyCode::Backspace => { + match app.login_focus { + LoginFocus::User => { app.login_user.pop(); } + LoginFocus::Pass => { app.login_pass.pop(); } + } + return; + } + + KeyCode::Enter => { + let mut cfg = match &app.config { + Some(c) => c.clone(), + None => { + app.login_error = Some("No config loaded. Create config.toml with base_url.".to_string()); + return; + } + }; + + let device_id = core::config::ensure_device_id(&mut cfg); + + let client = core::jellyfin::JellyfinClient::unauthenticated(&cfg.base_url, &device_id); + match client.login_by_name(&app.login_user, &app.login_pass) { + Ok(res) => { + let mut new_cfg = cfg; + new_cfg.access_token = Some(res.access_token); + new_cfg.user_id = res.user_id; + + if let Err(e) = core::config::save_config(&new_cfg) { + app.login_error = Some(format!("login ok but failed to write config: {e:?}")); + return; + } + + app.config_status = format!("auth: token present ({})", new_cfg.base_url); + app.config = Some(new_cfg); + app.login_error = None; + app.login_pass.clear(); // don't keep password around + app.view_stack = vec![View::Categories]; + app.status = "Logged in".to_string(); + } + Err(e) => { + app.login_error = Some(e); + } + } + return; + } + + KeyCode::Char(c) => { + match app.login_focus { + LoginFocus::User => app.login_user.push(c), + LoginFocus::Pass => app.login_pass.push(c), + } + return; + } + + _ => return, + } + } + // If search mode is active, it captures input first + if let Some(search) = &mut app.search { + match code { + KeyCode::Esc => { app.search = None; return; } + KeyCode::Backspace => { search.input.pop(); return; } + KeyCode::Enter => { + let term = search.input.trim().to_string(); + // Special command: reset config + exit + if term == r"\reset" { + if let Some(path) = crate::core::config::config_path() { + let _ = std::fs::remove_file(&path); + } + app.should_exit = true; + return; + } + + if term.is_empty() { + app.search = None; + return; + } + + let mode = search.mode; + let types = mode.include_item_types().map(|s| s.to_string()); + + let (title, mut query) = match &search.scope { + SearchScope::All => ( + format!("Search: {}", term), + core::jellyfin::LibraryQuery::all().with_search(term.clone()), + ), + SearchScope::Library { title, query } => ( + format!("{}: {}", title, term), + query.clone().with_search(term.clone()), + ), + }; + + // Override/force item types for the search mode + query.include_item_types = types; + + app.search = None; + + // run search (same as your library fetch) + let cfg = match &app.config { Some(c) => c.clone(), None => { app.status = "No config".into(); return; } }; + let user_id = match cfg.user_id.as_deref() { Some(u) => u, None => { app.status = "Missing user_id".into(); return; } }; + let client = match core::jellyfin::JellyfinClient::from_config(&cfg) { Ok(c) => c, Err(e) => { app.status = e; return; } }; + + app.status = format!("Searching… {}", term); + + match client.list_items(user_id, query.clone(), 0, PAGE_SIZE) { + Ok(page) => { + app.view_stack.push(View::Library { + title, + query, + items: page.items, + selected: 0, + start_index: 0, + total: page.total, + page_size: PAGE_SIZE, + loading: false, + playlist_ctx: None, + }); + app.status = "Search loaded".into(); + } + Err(e) => app.status = format!("Search failed: {e}"), + } + + return; + } + KeyCode::Char('/') => { + search.mode = search.mode.next(); + return; + } + KeyCode::Char('?') => { + search.mode = search.mode.prev(); + return; + } + KeyCode::Char(c) => { search.input.push(c); return; } + _ => return, + } + } + + // Normalize arrow navigation + let code = match code { + KeyCode::Right => KeyCode::Enter, + KeyCode::Left => KeyCode::Esc, + KeyCode::Char('l') => KeyCode::Enter, + KeyCode::Char('h') => KeyCode::Esc, + other => other, + }; + + // q behavior: if not home, go home; if home, quit + if matches!(code, KeyCode::Char('q')) { + app.autoplay = false; + app.autoplay_next_index = None; + if !matches!(app.current_view(), View::Categories) { + app.view_stack = vec![View::Categories]; + app.selected = 0; + app.status = "$".into(); + return; + } else { + app.should_exit = true; + return; + } + } + + if matches!(code, KeyCode::Char('D')) { + app.downloads_focus = !app.downloads_focus; + app.downloads_selected = 0; + app.status = if app.downloads_focus { "downloads: focus".into() } else { "downloads: unfocus".into() }; + return; + } + // Toggle offline/online mode. + // - If currently online -> go offline immediately (latched until toggled back). + // - If currently offline -> ping server; only switch to online if ping succeeds. + if matches!(code, KeyCode::Char('o')) { + let base_url = app + .config + .as_ref() + .map(|c| c.base_url.clone()) + .unwrap_or_default(); + + if !app.offline { + // Online -> Offline (no ping needed) + app.offline = true; + app.items = categories_for_mode(true); + app.selected = 0; + app.view_stack = vec![View::Categories]; + + // stop autoplay if it was running (offline shouldn't try to advance via network) + app.autoplay = false; + app.autoplay_next_index = None; + app.autoplay_eof_latched = false; + + app.status = "📴 Offline mode enabled".into(); + return; + } + + // Offline -> Online (must confirm server reachable) + if base_url.is_empty() { + app.status = "Cannot go online: missing base_url in config".into(); + return; + } + + let ok = core::jellyfin::JellyfinClient::ping_base_url(&base_url); + if ok { + app.offline = false; + app.items = categories_for_mode(false); + app.selected = 0; + app.view_stack = vec![View::Categories]; + app.status = "✅ Online mode enabled".into(); + } else { + app.status = "Still offline: server not reachable".into(); + } + return; + } + + + // -------------------- + // Downloads Focus Modal + // -------------------- + if app.downloads_focus { + match code { + KeyCode::Esc => { + app.downloads_focus = false; + app.status = "downloads: unfocus".into(); + return; + } + + KeyCode::Up | KeyCode::Char('k') => { + if app.downloads_selected > 0 { + app.downloads_selected -= 1; + } + return; + } + + KeyCode::Down | KeyCode::Char('j') => { + let len = app + .downloads + .as_ref() + .and_then(|dm| dm.state.lock().ok()) + .map(|st| st.queue.len()) + .unwrap_or(0); + + if len > 0 && app.downloads_selected + 1 < len { + app.downloads_selected += 1; + } + return; + } + + KeyCode::Char('d') => { + if let Some(dm) = &app.downloads { + let idx = app.downloads_selected; + + if idx == 0 { + dm.send(downloads::DownloadCommand::Cancel { index: 0 }); + app.status = "download: cancel active".into(); + } else { + dm.send(downloads::DownloadCommand::RemoveQueued { index: idx }); + app.status = "download: removed".into(); + } + + // Clamp selection after removal + let len = dm + .state + .lock() + .ok() + .map(|st| st.queue.len()) + .unwrap_or(0); + + if len == 0 { + app.downloads_selected = 0; + } else if app.downloads_selected >= len { + app.downloads_selected = len - 1; + } + } else { + app.status = "downloads not available".into(); + } + return; + } + + // Swallow everything else while focused. + _ => return, + } + } + + // Capital X: delete local copy/copies for hovered item (works same online/offline) + if matches!(code, KeyCode::Char('X')) { + let result = match app.current_view() { + View::Library { items, selected, .. } if !items.is_empty() => { + let id = items[*selected].id.clone(); + crate::core::downloads::delete_local_by_item_id(&id) + } + View::OfflineLibrary { entries, selected, .. } if !entries.is_empty() => { + let e = &entries[*selected]; + if e.is_dir { + app.status = "X: select a file (not a folder)".into(); + return; + } + crate::core::downloads::delete_local_by_path(&e.path) + } + _ => { + app.status = "X: nothing deletable here".into(); + return; + } + }; + + match result { + Ok(n) => { + // refresh downloaded-id index so 💾 updates immediately + app.downloaded_ids = crate::core::downloads::scan_downloaded_ids().unwrap_or_default(); + + // if we’re in OfflineLibrary, refresh the directory listing so the file disappears + if let Some(View::OfflineLibrary { dir, entries, selected, .. }) = app.view_stack.last_mut() { + *entries = offline_list_dir(dir); + if !entries.is_empty() { + *selected = (*selected).min(entries.len() - 1); + } else { + *selected = 0; + } + } + + app.status = format!("Deleted local copies: {n}"); + } + Err(e) => { + app.status = format!("Delete failed: {e}"); + } + } + + return; + } + + + + // Mark watched (w) / Unmark watched (W) + if matches!(code, KeyCode::Char('w') | KeyCode::Char('W')) { + let unmark = matches!(code, KeyCode::Char('W')); + + let cfg = match &app.config { + Some(c) => c.clone(), + None => { app.status = "No config loaded".into(); return; } + }; + + let user_id = match cfg.user_id.as_deref() { + Some(u) => u.to_string(), + None => { app.status = "Missing user_id (login again)".into(); return; } + }; + + let (item_id, item_type) = match app.current_view() { + View::Library { items, selected, .. } if !items.is_empty() => { + let it = &items[*selected]; + (it.id.clone(), it.item_type.clone().unwrap_or_default()) + } + _ => { app.status = "Not in a library view".into(); return; } + }; + + // Only do this for playables (you can loosen this later if you want) + if item_type != "Episode" && item_type != "Movie" { + app.status = "Watched toggle only for Episodes/Movies (for now)".into(); + return; + } + + let client = match core::jellyfin::JellyfinClient::from_config(&cfg) { + Ok(c) => c, + Err(e) => { app.status = e; return; } + }; + + let r = if unmark { + client.mark_unplayed(&user_id, &item_id) + } else { + client.mark_played(&user_id, &item_id) + }; + + match r { + Ok(_) => { + // Update local UI state so ✅ disappears/appears immediately + if let Some(View::Library { items, selected, .. }) = app.view_stack.last_mut() { + if let Some(it) = items.get_mut(*selected) { + let ud = it.user_data.get_or_insert_with(core::jellyfin::JfUserData::default); + + if unmark { + ud.played = Some(false); + ud.played_percentage = Some(0.0); + ud.playback_position_ticks = Some(0); + } else { + ud.played = Some(true); + ud.played_percentage = Some(100.0); + ud.playback_position_ticks = Some(0); + } + } + } + + app.status = if unmark { "Marked unplayed".into() } else { "Marked played".into() }; + } + Err(e) => { + app.status = format!("Watched update failed: {e}"); + } + } + + return; + } + + + // mpv controls (only when not typing into search/login) + if app.search.is_none() && !matches!(app.current_view(), View::Login) { + match code { + KeyCode::Char('M') => { + app.debug_mpv = !app.debug_mpv; + app.status = format!("mpv debug: {}", if app.debug_mpv { "ON" } else { "OFF" }); + return; + } + KeyCode::Char('a') => { + if let Some(mpv) = &app.mpv { + if let Err(e) = mpv_cycle_audio(mpv) { + app.status = format!("mpv audio: {e}"); + } + } + return; + } + KeyCode::Char('s') => { + if let Some(mpv) = &app.mpv { + if let Err(e) = mpv_cycle_subtitles(mpv) { + app.status = format!("mpv subs: {e}"); + } + } + return; + } + KeyCode::Char('v') => { + if let Some(mpv) = &app.mpv { + if let Err(e) = mpv_toggle_subs(mpv) { + app.status = format!("mpv sub vis: {e}"); + } + } + return; + } + KeyCode::Char('x') => { + app.autoplay = false; + app.autoplay_next_index = None; + + jellyfin_report_stopped_best_effort(app); + + app.mpv_now_playing_item_id = None; + app.mpv_now_playing_is_stream = false; + app.last_reported_position_ticks = None; + app.marked_watched_this_session = false; + app.mpv_now_playing = None; + app.mpv_state_rx = None; + app.mpv_state = MpvState::default(); + + if let Some(mut mpv) = app.mpv.take() { + mpv_stop(&mut mpv); + app.status = "Stopped mpv".into(); + } + return; + } + KeyCode::Char('f') => { + if let Some(mpv) = &app.mpv { + if let Err(e) = mpv_toggle_fullscreen(mpv) { + app.status = format!("mpv fullscreen: {e}"); + } + } else { + app.status = "mpv not running (press 'p' to play)".into(); + } + return; + } + KeyCode::Char(' ') => { + if let Some(mpv) = &app.mpv { + if let Err(e) = mpv_cycle_pause(mpv) { + app.status = format!("mpv pause: {e}"); + } + } else { + app.status = "mpv not running (press 'p' to play)".into(); + } + return; + } + KeyCode::Char('p') => { + app.autoplay = false; + app.autoplay_next_index = None; + + // p = play selected item (spawn or replace) + let cfg = match &app.config { + Some(c) => c.clone(), + None => { app.status = "No config loaded".into(); return; } + }; + + // Determine what is “selected” depending on view + // - Online library: we have an item_id (and can prefer local by id) + // - Offline directory browser: we have a direct local file path + let (maybe_item_id, item_name, maybe_path) = match app.current_view() { + View::Library { items, selected, .. } if !items.is_empty() => { + let it = &items[*selected]; + let ty = it.item_type.as_deref().unwrap_or(""); + + if !is_playable_item_type(ty) { + app.status = "Not playable (use Enter to drill down)".into(); + return; + } + + (Some(it.id.clone()), it.name.clone(), None) + } + View::OfflineLibrary { entries, selected, .. } if !entries.is_empty() => { + let e = &entries[*selected]; + if e.is_dir { + app.status = "Select a file (or Enter to open folder)".into(); + return; + } + (None, e.name.clone(), Some(e.path.clone())) + } + _ => { app.status = "Nothing to play here".into(); return; } + }; + + app.mpv_now_playing = Some(item_name); + app.mpv_now_playing_item_id = maybe_item_id.clone(); + app.marked_watched_this_session = false; + app.last_reported_position_ticks = None; + app.last_progress_report_at = Instant::now(); + + // Decide URL/path: + // 1) If OfflineLibrary gave us a file path -> play it directly. + // 2) Else (online library): prefer local download by item_id, else stream. + let local = maybe_item_id + .as_deref() + .and_then(|id| crate::core::downloads::local_media_path_for_item_id(id)); + + if app.offline && maybe_path.is_none() && local.is_none() { + app.status = "Offline: item not downloaded".into(); + return; + } + + let url = if let Some(p) = maybe_path.or(local) { + p.to_string_lossy().to_string() + } else { + build_jellyfin_play_url(&cfg, maybe_item_id.as_deref().unwrap()) + }; + + // Only report watch progress when actually streaming (http(s) URL). + app.mpv_now_playing_is_stream = url.starts_with("http://") || url.starts_with("https://"); + + // Notify Jellyfin that playback started (best effort). + if app.mpv_now_playing_is_stream { + if let (Some(cfg), Some(item_id)) = (app.config.clone(), app.mpv_now_playing_item_id.clone()) { + if let Ok(client) = core::jellyfin::JellyfinClient::from_config(&cfg) { + let _ = client.report_playback_start(&item_id, None); + } + } + } + + // If mpv running -> replace, else spawn + if let Some(mpv) = &app.mpv { + match mpv_load_url(&cfg, mpv, &url) { + Ok(_) => app.status = "mpv: loaded".into(), + Err(e) => app.status = format!("mpv load failed: {e}"), + } + } else { + match spawn_mpv_url(&cfg, &url) { + Ok(mpv) => { + let rx = start_mpv_poller(mpv.socket_path.clone()); + app.mpv_state_rx = Some(rx); + app.mpv = Some(mpv); + app.status = "mpv: playing".into(); + } + Err(e) => app.status = format!("mpv spawn failed: {e}"), + } + } + return; + } + + KeyCode::Char('P') => { + let cfg = match &app.config { + Some(c) => c.clone(), + None => { app.status = "No config".into(); return; } + }; + + let view = app.current_view().clone(); + + match view { + View::Library { items, selected, .. } if !items.is_empty() => { + let ty = items[selected].item_type.as_deref().unwrap_or(""); + if !is_playable_item_type(ty) { + app.status = "Not playable (use Enter to drill down)".into(); + return; + } + + let item_id = items[selected].id.clone(); + let item_name = items[selected].name.clone(); + let len = items.len(); + + app.mpv_now_playing = Some(item_name); + + app.mpv_now_playing_item_id = Some(item_id.clone()); + app.marked_watched_this_session = false; + app.last_reported_position_ticks = None; + app.last_progress_report_at = Instant::now(); + + app.autoplay = true; + app.autoplay_prev_time_pos = None; + app.autoplay_view_depth = app.view_stack.len(); + app.autoplay_eof_latched = false; + app.autoplay_next_index = if selected + 1 < len { Some(selected + 1) } else { None }; + + let local = crate::core::downloads::local_media_path_for_item_id(&item_id); + if app.offline && local.is_none() { + app.autoplay = false; + app.autoplay_next_index = None; + app.status = "Offline: item not downloaded".into(); + return; + } + + let url = if let Some(p) = local { + p.to_string_lossy().to_string() + } else { + build_jellyfin_play_url(&cfg, &item_id) + }; + + // Only report watch progress when actually streaming (http(s) URL). + app.mpv_now_playing_is_stream = url.starts_with("http://") || url.starts_with("https://"); + + // Notify Jellyfin that playback started (best effort). + if app.mpv_now_playing_is_stream { + if let Ok(client) = core::jellyfin::JellyfinClient::from_config(&cfg) { + let _ = client.report_playback_start(&item_id, None); + } + } + + if let Some(mpv) = &app.mpv { + let r = if url.starts_with('/') { mpv_load_local(mpv, &url) } else { mpv_load_url(&cfg, mpv, &url) }; + if let Err(e) = r { + app.autoplay = false; + app.autoplay_next_index = None; + app.status = format!("mpv load failed: {e}"); + } else { + app.status = "mpv: playing (autoplay)".into(); + } + } else { + let r = if url.starts_with('/') { spawn_mpv_local(&url) } else { spawn_mpv_url(&cfg, &url) }; + match r { + Ok(mpv) => { + let rx = start_mpv_poller(mpv.socket_path.clone()); + app.mpv_state_rx = Some(rx); + app.mpv = Some(mpv); + app.status = "mpv: playing".into(); + } + Err(e) => { + app.autoplay = false; + app.autoplay_next_index = None; + app.status = format!("mpv spawn failed: {e}"); + } + } + } + return; + } + + View::OfflineLibrary { entries, selected, .. } if !entries.is_empty() => { + let e = &entries[selected]; + if e.is_dir { + app.status = "Select a file to autoplay".into(); + return; + } + + app.autoplay = true; + app.autoplay_prev_time_pos = None; + app.autoplay_view_depth = app.view_stack.len(); + app.autoplay_eof_latched = false; + app.autoplay_next_index = next_offline_playable(&entries, selected); + + app.mpv_now_playing = Some(e.name.clone()); + let url = e.path.to_string_lossy().to_string(); + + if let Some(mpv) = &app.mpv { + if let Err(e) = mpv_load_local(mpv, &url) { + app.autoplay = false; + app.autoplay_next_index = None; + app.status = format!("mpv load failed: {e}"); + } else { + app.status = "mpv: playing (offline autoplay)".into(); + } + } else { + match spawn_mpv_local(&url) { + Ok(mpv) => { + let rx = start_mpv_poller(mpv.socket_path.clone()); + app.mpv_state_rx = Some(rx); + app.mpv = Some(mpv); + app.status = "mpv: playing (offline autoplay)".into(); + } + Err(e) => { + app.autoplay = false; + app.autoplay_next_index = None; + app.status = format!("mpv spawn failed: {e}"); + } + } + } + return; + } + + _ => { + app.status = "Nothing to autoplay here".into(); + return; + } + } + } + KeyCode::Char('r') => { + app.autoplay = false; + app.autoplay_next_index = None; + + let cfg = match &app.config { + Some(c) => c.clone(), + None => { app.status = "No config loaded".into(); return; } + }; + + let (item_id, item_name, start_ticks) = match app.current_view() { + View::Library { items, selected, .. } if !items.is_empty() => { + let it = &items[*selected]; + let ty = it.item_type.as_deref().unwrap_or(""); + if ty != "Episode" && ty != "Movie" { + app.status = "Resume only works on Episodes/Movies (for now).".into(); + return; + } + (it.id.clone(), it.name.clone(), playback_position_ticks(it)) + } + _ => { app.status = "Nothing to resume here".into(); return; } + }; + + app.mpv_now_playing = Some(item_name); + + // --- progress reporting state for resume start --- + app.mpv_now_playing_item_id = Some(item_id.clone()); + app.marked_watched_this_session = false; + app.last_reported_position_ticks = None; + app.last_progress_report_at = Instant::now(); + + let url = build_jellyfin_play_url(&cfg, &item_id); + + // resume is always a stream URL here + app.mpv_now_playing_is_stream = url.starts_with("http://") || url.starts_with("https://"); + + // Notify Jellyfin that playback started (best effort). For resume, include the start ticks if present. + if app.mpv_now_playing_is_stream { + if let Ok(client) = core::jellyfin::JellyfinClient::from_config(&cfg) { + let start_i64 = start_ticks.map(|t| t as i64); + let _ = client.report_playback_start(&item_id, start_i64); + } + } + + if let Some(mpv) = &app.mpv { + match mpv_cmd(&mpv.socket_path, &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url)) { + Ok(_) => { + if let Some(t) = start_ticks { + let _ = mpv_seek_absolute(&mpv.socket_path, ticks_to_seconds(t)); + } + app.status = "mpv: resumed".into(); + } + Err(e) => app.status = format!("mpv resume failed: {e}"), + } + } else { + match spawn_mpv_url(&cfg, &url) { + Ok(mpv) => { + let rx = start_mpv_poller(mpv.socket_path.clone()); + app.mpv_state_rx = Some(rx); + let sock = mpv.socket_path.clone(); + app.mpv = Some(mpv); + if let Some(t) = start_ticks { + let _ = mpv_seek_absolute(&sock, ticks_to_seconds(t)); + } + app.status = "mpv: resumed".into(); + } + Err(e) => app.status = format!("mpv spawn failed: {e}"), + } + } + return; + } + KeyCode::Char('R') => { + // resume + autoplay + let cfg = match &app.config { Some(c) => c.clone(), None => { app.status="No config".into(); return; } }; + + let (item_id, item_name, selected, len, start_ticks) = match app.current_view() { + View::Library { items, selected, .. } if !items.is_empty() => { + let it = &items[*selected]; + let ty = it.item_type.as_deref().unwrap_or(""); + if ty != "Episode" && ty != "Movie" { + app.status = "Resume only works on Episodes/Movies (for now).".into(); + return; + } + (it.id.clone(), it.name.clone(), *selected, items.len(), playback_position_ticks(it)) + } + _ => { app.status = "Nothing to resume here".into(); return; } + }; + + app.mpv_now_playing = Some(item_name); + + // --- progress reporting state for resume+autoplay start --- + app.mpv_now_playing_item_id = Some(item_id.clone()); + app.marked_watched_this_session = false; + app.last_reported_position_ticks = None; + app.last_progress_report_at = Instant::now(); + + app.autoplay = true; + app.autoplay_prev_time_pos = None; + app.autoplay_view_depth = app.view_stack.len(); + app.autoplay_next_index = if selected + 1 < len { Some(selected + 1) } else { None }; + app.autoplay_eof_latched = false; + + let url = build_jellyfin_play_url(&cfg, &item_id); + + app.mpv_now_playing_is_stream = url.starts_with("http://") || url.starts_with("https://"); + + // Notify Jellyfin that playback started (best effort). Include resume ticks if present. + if app.mpv_now_playing_is_stream { + if let Ok(client) = core::jellyfin::JellyfinClient::from_config(&cfg) { + let start_i64 = start_ticks.map(|t| t as i64); + let _ = client.report_playback_start(&item_id, start_i64); + } + } + + if let Some(mpv) = &app.mpv { + match mpv_cmd(&mpv.socket_path, &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url)) { + Ok(_) => { + if let Some(t) = start_ticks { + let _ = mpv_seek_absolute(&mpv.socket_path, ticks_to_seconds(t)); + } + app.status = "mpv: resumed (autoplay)".into(); + } + Err(e) => app.status = format!("mpv resume failed: {e}"), + } + } else { + match spawn_mpv_url(&cfg, &url) { + Ok(mpv) => { + let rx = start_mpv_poller(mpv.socket_path.clone()); + app.mpv_state_rx = Some(rx); + let sock = mpv.socket_path.clone(); + app.mpv = Some(mpv); + if let Some(t) = start_ticks { + let _ = mpv_seek_absolute(&sock, ticks_to_seconds(t)); + } + app.status = "mpv: resumed (autoplay)".into(); + } + Err(e) => app.status = format!("mpv spawn failed: {e}"), + } + } + return; + } + _ => {} + } + } + + // downloads focus overrides normal navigation + // downloads focus overrides normal navigation + + match code { + KeyCode::Char('d') => { + let cfg = match &app.config { + Some(c) => c.clone(), + None => { app.status = "No config loaded".into(); return; } + }; + let user_id = match cfg.user_id.as_deref() { + Some(u) => u, + None => { app.status = "No user_id (login first)".into(); return; } + }; + + let (selected_item, playlist_ctx, selected_index) = match app.current_view() { + View::Library { items, selected, playlist_ctx, .. } if !items.is_empty() => { + (items[*selected].clone(), playlist_ctx.clone(), *selected) + } + _ => { app.status = "Nothing to download here".into(); return; } + }; + + // ensure DM + let dm = match &app.downloads { + Some(dm) => dm, + None => { + if cfg.access_token.is_some() { + app.downloads = Some(downloads::DownloadManager::start(cfg.clone())); + app.downloads.as_ref().unwrap() + } else { + app.status = "Not connected".into(); + return; + } + } + }; + + let client = match core::jellyfin::JellyfinClient::from_config(&cfg) { + Ok(c) => c, + Err(e) => { app.status = format!("downloads: {e}"); return; } + }; + + let ty = selected_item.item_type.as_deref().unwrap_or(""); + + // ------------------------- + // CASE 1: We are INSIDE a playlist view (already works) + // ------------------------- + if let Some(pctx) = playlist_ctx { + let playable = matches!(ty, "Movie" | "Episode" | "Audio"); + if !playable { + app.status = "Playlist downloads only support Movie/Episode/Audio".into(); + return; + } + + dm.send(downloads::DownloadCommand::EnqueuePlaylistItem { + item: selected_item, + playlist_name: pctx.name, + playlist_index: selected_index, + }); + app.status = "queued 1 (playlist)".into(); + return; + } + + // ------------------------- + // CASE 2: We are on the PLAYLIST LIST and selected item IS a playlist + // -> enqueue via playlist-aware path so we get hardlinks/order + // ------------------------- + if ty == "Playlist" { + app.status = format!("Fetching playlist… {}", selected_item.name); + + match client.playlist_items(user_id, &selected_item.id, 0, 200) { + Ok(page) => { + let pname = selected_item.name.clone(); + let mut n = 0usize; + + for (i, it) in page.items.into_iter().enumerate() { + let it_ty = it.item_type.as_deref().unwrap_or(""); + let playable = matches!(it_ty, "Movie" | "Episode" | "Audio"); + if !playable { continue; } + + dm.send(downloads::DownloadCommand::EnqueuePlaylistItem { + item: it, + playlist_name: pname.clone(), + playlist_index: i, + }); + n += 1; + } + + app.status = format!("queued {n} (playlist)"); + } + Err(e) => { + app.status = format!("Playlist load failed: {e}"); + } + } + + return; + } + + // ------------------------- + // CASE 3: Normal (non-playlist-container) download behavior + // ------------------------- + match downloads::expand_for_enqueue(&client, user_id, &selected_item) { + Ok(items) => { + let n = items.len(); + dm.send(downloads::DownloadCommand::Enqueue(items)); + app.status = format!("queued {n}"); + } + Err(e) => app.status = format!("queue failed: {e}"), + } + return; + } + + KeyCode::Char('q') => app.should_exit = true, + + //Enter code Block + KeyCode::Enter => { + let view = app.current_view().clone(); + + match view { + View::Categories => { + let cat = app.items[app.selected].clone(); + + // OFFLINE: local-only library + if app.offline { + if cat == "Downloaded" { + let root = match crate::core::paths::app_config_dir() { + Some(p) => p.join("downloads"), + None => { + app.status = "offline: cannot resolve config dir".into(); + return; + } + }; + + let entries = offline_list_dir(&root); + + app.view_stack.push(View::OfflineLibrary { + title: "Downloaded".into(), + dir: root, + entries, + selected: 0, + }); + app.status = "$".into(); + return; + } + } + + let cfg = match &app.config { + Some(c) => c.clone(), + None => { app.status = "No config loaded".into(); return; } + }; + + let user_id = match cfg.user_id.as_deref() { + Some(u) => u, + None => { app.status = "Missing user_id in config (login again)".into(); return; } + }; + + let client = match core::jellyfin::JellyfinClient::from_config(&cfg) { + Ok(c) => c, + Err(e) => { app.status = e; return; } + }; + + // Special case: Libraries uses /Views, not /Items + if cat == "Libraries" { + app.status = "Fetching Libraries...".into(); + match client.list_views(user_id) { + Ok(items) => { + let total = items.len(); + app.view_stack.push(View::Library { + title: "Libraries".into(), + query: core::jellyfin::LibraryQuery::all(), // placeholder + items, + selected: 0, + start_index: 0, + total, // cheap total for UI + page_size: PAGE_SIZE, + loading: false, + playlist_ctx: None, + }); + app.status = "Loaded".into(); + } + Err(e) => app.status = format!("Views failed: {e}"), + } + return; + } + + // Normal categories use /Items + let query = match cat.as_str() { + "Movies" => core::jellyfin::LibraryQuery::movies(), + "Shows" => core::jellyfin::LibraryQuery::shows(), + "Music" => core::jellyfin::LibraryQuery::music(), + "Playlists" => core::jellyfin::LibraryQuery::all().with_item_types("Playlist"), + + // NEW + "Collections" => core::jellyfin::LibraryQuery::all() + .with_item_types("BoxSet"), + + "Continue Watching" => core::jellyfin::LibraryQuery::all() + .with_item_types("Episode,Movie") + .with_filters("IsResumable") + .with_sort_by("DatePlayed") + .with_sort_order("Descending"), + + "Recently Added" => core::jellyfin::LibraryQuery::all() + .with_item_types("Movie,Series") + .with_sort_by("DateCreated,SortName") + .with_sort_order("Descending"), + + _ => { app.status = format!("Not wired yet: {}", cat); return; } + }; + + app.status = format!("Fetching {}...", cat); + + match client.list_items(user_id, query.clone(), 0, PAGE_SIZE) { + Ok(page) => { + app.view_stack.push(View::Library { + title: cat, + query, + items: page.items, + selected: 0, + start_index: 0, + total: page.total, + page_size: PAGE_SIZE, + loading: false, + playlist_ctx: None, + }); + app.status = "$".into(); + } + Err(e) => app.status = format!("Fetch failed: {e}"), + } + } + + View::Library { title, items, selected, .. } => { + if items.is_empty() { return; } + let it = &items[selected]; + + let ty = it.item_type.as_deref().unwrap_or(""); + + // Libraries (/Views) usually return CollectionFolder. Entering it should list its contents. + if ty == "CollectionFolder" { + let q = core::jellyfin::LibraryQuery::all().with_parent(it.id.clone()); + open_children(app, format!("{} > {}", title, it.name), q); + return; + } + // Shows -> Seasons -> Episodes + if ty == "Series" { + let q = core::jellyfin::LibraryQuery::all() + .with_parent(it.id.clone()) + .with_item_types("Season"); + open_children(app, format!("{} > {}", title, it.name), q); + return; + } + if ty == "Season" { + let q = core::jellyfin::LibraryQuery::all() + .with_parent(it.id.clone()) + .with_item_types("Episode"); + open_children(app, format!("{} > {}", title, it.name), q); + return; + } + // MusicArtist -> MusicAlbum -> Audio + if ty == "MusicArtist" { + let q = core::jellyfin::LibraryQuery::all() + .with_parent(it.id.clone()) + .with_item_types("MusicAlbum"); + open_children(app, format!("{} > {}", title, it.name), q); + return; + } + if ty == "MusicAlbum" { + let q = core::jellyfin::LibraryQuery::all() + .with_parent(it.id.clone()) + .with_item_types("Audio"); + open_children(app, format!("{} > {}", title, it.name), q); + return; + } + if ty == "BoxSet" { + let q = core::jellyfin::LibraryQuery::all().with_parent(it.id.clone()); + open_children(app, format!("{} > {}", title, it.name), q); + return; + } + if ty == "UserView" { + // Most UserViews are libraries like Shows/Movies/Music/Playlists. + // For Playlists view, drill into playlists. + let q = core::jellyfin::LibraryQuery::all() + .with_parent(it.id.clone()) + .with_item_types("Playlist"); + open_children(app, format!("{} > {}", title, it.name), q); + return; + } + if ty == "Playlist" { + let cfg = match &app.config { Some(c) => c.clone(), None => { app.status="No config".into(); return; } }; + let user_id = match cfg.user_id.as_deref() { Some(u) => u, None => { app.status="Missing user_id".into(); return; } }; + let client = match core::jellyfin::JellyfinClient::from_config(&cfg) { Ok(c)=>c, Err(e)=>{ app.status=e; return; } }; + + match client.playlist_items(user_id, &it.id, 0, PAGE_SIZE) { + Ok(page) => { + app.view_stack.push(View::Library { + title: format!("{} > {}", title, it.name), + // store a query placeholder; playlist paging could be done later if you want + query: core::jellyfin::LibraryQuery::all(), + items: page.items, + selected: 0, + start_index: 0, + total: page.total, + page_size: PAGE_SIZE, + loading: false, + playlist_ctx: Some(PlaylistCtx { name: it.name.clone() }), + }); + app.status = "$".into(); + } + Err(e) => app.status = format!("Playlist load failed: {e}"), + } + return; + } + // Playables -> mpv + // Playables: do nothing on Enter (use 'p' to play) + if ty == "Episode" || ty == "Audio" || ty == "Movie" { + app.status = "Press 'p' to play, or 'P' for autoplay.".into(); + return; + } + + app.status = format!("No drilldown for type: {}", ty); + } + + View::OfflineLibrary { title, dir: _, entries, selected } => { + if entries.is_empty() { return; } + let e = &entries[selected]; + + if e.is_dir { + let new_dir = e.path.clone(); + let new_entries = offline_list_dir(&new_dir); + + app.view_stack.push(View::OfflineLibrary { + title: format!("{title} > {}", e.name), + dir: new_dir, + entries: new_entries, + selected: 0, + }); + app.status = "$".into(); + } else { + app.status = "Press 'p' to play local file".into(); + } + return; + } + + _ => {} + } +} + + + KeyCode::Esc | KeyCode::Backspace => { + if app.view_stack.len() > 1 { + app.view_stack.pop(); + app.status = "$".to_string(); + app.selected = 0; + } + } + KeyCode::Up | KeyCode::Char('k') => { + match app.view_stack.last_mut().unwrap() { + View::Categories => { + if app.items.is_empty() { return; } + if app.selected == 0 { app.selected = app.items.len() - 1; } + else { app.selected -= 1; } + //app.status = format!("Selected: {}", app.items[app.selected]); + app.status = "$".into(); + } + View::Library { items, selected, total, .. } => { + if items.is_empty() { return; } + + if *selected == 0 { + // wrap-up wants the real end + if items.len() < *total { + ensure_loaded_to_end(app); + } + + if let Some(View::Library { items, selected, .. }) = app.view_stack.last_mut() { + if !items.is_empty() { + *selected = items.len() - 1; + } + } + } else { + *selected -= 1; + } + + maybe_prefetch(app); + } + View::OfflineLibrary { entries, selected, .. } => { + if entries.is_empty() { return; } + if *selected == 0 { *selected = entries.len() - 1; } + else { *selected -= 1; } + } + _ => {} + } + } + KeyCode::Down | KeyCode::Char('j') => { + match app.view_stack.last_mut().unwrap() { + View::Categories => { + if app.items.is_empty() { return; } + app.selected = (app.selected + 1) % app.items.len(); + //app.status = format!("Selected: {}", app.items[app.selected]); + app.status = "$".into(); + } + View::Library { items, selected, total, .. } => { + if items.is_empty() { return; } + + // If we're at the end of what we have loaded… + if *selected + 1 >= items.len() { + // …and there’s more available on the server, fetch next page. + if items.len() < *total { + load_next_library_page(app); + + // After loading more, move down if possible. + if let Some(View::Library { items, selected, .. }) = app.view_stack.last_mut() { + if !items.is_empty() && *selected + 1 < items.len() { + *selected += 1; + } + } + } else { + *selected = 0; + } + } else { + *selected += 1; + } + + maybe_prefetch(app); + } + View::OfflineLibrary { entries, selected, .. } => { + if entries.is_empty() { return; } + *selected = (*selected + 1) % entries.len(); + } + _ => {} + } + } + KeyCode::Char('/') => { + // Always behave like home-page search (global), regardless of current view. + app.search = Some(SearchState { + scope: SearchScope::All, + mode: SearchMode::All, + input: String::new(), + }); + } + _ => {} + } +} + +fn load_next_library_page(app: &mut App) { + // pull info + mark loading + let (query, next_start, total, page_size) = match app.view_stack.last_mut() { + Some(View::Library { query, items, total, start_index, loading, page_size, .. }) => { + if *loading { return; } + *loading = true; + let loaded = items.len(); + let next_start = *start_index + loaded; + (query.clone(), next_start, *total, *page_size) + } + _ => return, + }; + + let cfg = match &app.config { + Some(c) => c.clone(), + None => { app.status = "No config loaded".to_string(); finish_loading(app); return; } + }; + + let user_id = match cfg.user_id.as_deref() { + Some(u) => u, + None => { app.status = "Missing user_id in config (login again)".to_string(); finish_loading(app); return; } + }; + + let client = match core::jellyfin::JellyfinClient::from_config(&cfg) { + Ok(c) => c, + Err(e) => { app.status = e; finish_loading(app); return; } + }; + + let page = match client.list_items(user_id, query.clone(), next_start, page_size) { + Ok(p) => p, + Err(e) => { app.status = format!("Fetch failed: {e}"); finish_loading(app); return; } + }; + + if let Some(View::Library { items, total, .. }) = app.view_stack.last_mut() { + items.extend(page.items); + *total = page.total; + } + + finish_loading(app); +} + +fn finish_loading(app: &mut App) { + if let Some(View::Library { loading, .. }) = app.view_stack.last_mut() { + *loading = false; + } +} + +fn maybe_prefetch(app: &mut App) { + const PREFETCH_ROWS: usize = 0; + + let should = match app.view_stack.last() { + Some(View::Library { items, selected, total, loading, .. }) => { + if *loading { false } + else if items.len() >= *total { false } + else { + let remaining_loaded = items.len().saturating_sub(*selected); + remaining_loaded <= PREFETCH_ROWS + } + } + _ => false, + }; + + if should { + load_next_library_page(app); + } +} + +fn ensure_loaded_to_end(app: &mut App) { + loop { + let done = match app.view_stack.last() { + Some(View::Library { items, total, loading, .. }) => { + !*loading && items.len() >= *total + } + _ => true, + }; + if done { break; } + + load_next_library_page(app); + } +} + +fn ticks_to_runtime_str(ticks: Option) -> Option { + // Jellyfin ticks are 10,000,000 per second + let t = ticks?; + if t <= 0 { return None; } + + let total_seconds = (t as u64 / 10_000_000) as u64; + let minutes = total_seconds / 60; + let seconds = total_seconds % 60; + + if minutes >= 60 { + let h = minutes / 60; + let m = minutes % 60; + Some(format!("{h}h {m}m")) + } else { + Some(format!("{minutes}m {seconds:02}s")) + } +} + +fn join_artists(artists: &[String]) -> String { + let names: Vec = artists + .iter() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + if names.is_empty() { "-".into() } else { names.join(", ") } +} + +fn first_nonempty(opt: &Option) -> Option { + opt.as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) +} + +fn join_genres(genres: &[String]) -> String { + if genres.is_empty() { "-".into() } else { genres.join(", ") } +} + +fn join_studios(studios: &[core::jellyfin::JfStudio]) -> String { + let names: Vec = studios + .iter() + .map(|s| s.name.clone()) + .filter(|s| !s.trim().is_empty()) + .collect(); + + if names.is_empty() { "-".into() } else { names.join(", ") } +} + +fn year_str(y: Option) -> String { + y.map(|v| v.to_string()).unwrap_or_else(|| "-".into()) +} + +fn overview_str(o: &Option) -> String { + o.as_deref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .unwrap_or("-") + .to_string() +} + +fn format_details(it: &core::jellyfin::JfItem) -> String { + let ty = it.item_type.as_deref().unwrap_or(""); + + match ty { + "Playlist" | "UserView" | "CollectionFolder" | "BoxSet" => { + format!("Name: {}", it.name) + } + + "Series" => format!( + "Name: {}\nYear: {}\nGenres: {}\nStudio: {}\n\nOverview:\n{}", + it.name, + year_str(it.production_year), + join_genres(&it.genres), + join_studios(&it.studios), + overview_str(&it.overview), + ), + + "Movie" => { + let mut header = format!( + "Name: {}\nYear: {}\nRuntime: {}\nGenres: {}\nStudio: {}", + it.name, + year_str(it.production_year), + ticks_to_runtime_str(it.run_time_ticks).unwrap_or_else(|| "-".into()), + join_genres(&it.genres), + join_studios(&it.studios), + ); + + if is_in_progress_fast(it) { + let pos = playback_position_ticks(it).unwrap_or(0); + let pos_secs = (pos / 10_000_000) as f64; + header.push_str(&format!( + "\nIn progress ({}): press r/R to resume.", + fmt_hms(pos_secs) + )); + } + + format!( + "{}\n\nOverview:\n{}", + header, + overview_str(&it.overview), + ) + } + + "Episode" => { + let series = it.series_name.clone().unwrap_or_else(|| "-".into()); + let se = match (it.parent_index_number, it.index_number) { + (Some(s), Some(e)) => format!("{:02}x{:02}", s, e), + _ => "-".into(), + }; + + let mut header = format!( + "Series: {}\nEpisode: {} {}\nYear: {}", + series, + se, + it.name, + year_str(it.production_year), + ); + + if is_in_progress_fast(it) { + let pos = playback_position_ticks(it).unwrap_or(0); + let pos_secs = (pos / 10_000_000) as f64; + header.push_str(&format!( + "\nIn progress ({}): press r/R to resume.", + fmt_hms(pos_secs) + )); + } + + format!( + "{}\n\nOverview:\n{}", + header, + overview_str(&it.overview), + ) + } + + // --- Songs --- + "Audio" => { + let artist = first_nonempty(&it.album_artist) + .or_else(|| it.artists.get(0).cloned()) + .unwrap_or_else(|| "-".into()); + + let album = first_nonempty(&it.album).unwrap_or_else(|| "-".into()); + + format!( + "Name: {}\nRuntime: {}\nArtist: {}\nAlbum: {}\nYear: {}", + it.name, + ticks_to_runtime_str(it.run_time_ticks).unwrap_or_else(|| "-".into()), + artist, + album, + year_str(it.production_year), + ) + } + + // --- Albums --- + "MusicAlbum" => { + let artist = first_nonempty(&it.album_artist) + .or_else(|| it.artists.get(0).cloned()) + .unwrap_or_else(|| "-".into()); + + format!( + "Name: {}\nRuntime: {}\nArtist: {}\nYear: {}", + it.name, + ticks_to_runtime_str(it.run_time_ticks).unwrap_or_else(|| "-".into()), + artist, + year_str(it.production_year), + ) + } + + // --- Artists --- + "MusicArtist" => format!("Name: {}", it.name), + + _ => format!( + "Name: {}\nId: {}\nType: {:?}\nYear: {:?}", + it.name, it.id, it.item_type, it.production_year + ), + } +} + +fn ui(frame: &mut Frame, app: &App) { + let area = frame.area(); + let panes = layout(area); + + // Top + //frame.render_widget( + // Paragraph::new("cjtui").block(Block::bordered()), + // panes.top, + //); + + let top_block = Block::bordered(); + frame.render_widget(top_block.clone(), panes.top); + + let top_inner = top_block.inner(panes.top); + let [top_l, top_r] = Layout::horizontal([Constraint::Fill(1), Constraint::Length(40)]) + .areas(top_inner); + + frame.render_widget(Paragraph::new("cjtui"), top_l); + let server = app + .config + .as_ref() + .map(|c| c.base_url.clone()) + .unwrap_or_else(|| "-".into()); + + frame.render_widget( + Paragraph::new(server) + .alignment(ratatui::layout::Alignment::Right), + top_r, + ); + + // Main + + match app.current_view() { + View::OfflineLibrary { title, dir: _, entries, selected } => { + render_offline_library_dir(frame, title, entries, *selected, panes.left_main, panes.right_main); + } + View::Categories => { + render_categories(frame, app, panes.left_main, panes.right_main); + } + View::Library { title, query, items, selected, start_index, total, .. } => { + let is_audio = query + .include_item_types + .as_deref() + .map(|t| t.split(',').any(|x| x.trim() == "Audio")) + .unwrap_or(false); + + let show_audio_track_numbers = is_audio && query.parent_id.is_some(); + + render_library( + frame, + app, + title, + *start_index, + *total, + items, + *selected, + panes.left_main, + panes.right_main, + show_audio_track_numbers, + ); + } + View::Login => { + render_login(frame, app, panes.left_main, panes.right_main); + } + } + + // Bottom Left + // ---- Bottom Left: prompt / mpv ---- + // Bottom Left: Search (temporary) > MPV (persistent) > "$" + if let Some(s) = &app.search { + let scope = match &s.scope { + SearchScope::All => "All".to_string(), + SearchScope::Library { title, .. } => title.clone(), + }; + let mode = s.mode.label(); + let line = if s.input.is_empty() { + format!("/ ({scope}) [{mode}]") + } else { + format!("/ ({scope}) [{mode}] {}", s.input) + }; + + frame.render_widget( + Paragraph::new(line).block(Block::bordered().merge_borders(MergeStrategy::Exact)), + panes.left_output, + ); + } else if app.downloads_focus || app.downloads.as_ref().map(|d| d.state.lock().map(|s| !s.queue.is_empty()).unwrap_or(false)).unwrap_or(false) { + render_downloads_panel(frame, app, panes.left_output); + } else if app.mpv.is_some() { + // mpv panel takes over the whole bottom-left area + render_mpv_panel(frame, app, panes.left_output); + } else { + frame.render_widget( + Paragraph::new("$").block(Block::bordered().merge_borders(MergeStrategy::Exact)), + panes.left_output, + ); + } + + // ---- Bottom Right: always-on status/help ---- + let connected = if app.offline { + "📴 Offline" + } else if app.config.as_ref().and_then(|c| c.access_token.as_ref()).is_some() { + "✅ Connected" + } else { + "❌ Disconnected" + }; + + let server = app + .config + .as_ref() + .map(|c| c.base_url.as_str()) + .unwrap_or("-"); + + let right_text = format!( + "{connected}\np/P: play/autoplay +r/R: resume/autoplay +d: download / cancel +q: quit +/help: more help" + ); + + frame.render_widget( + Paragraph::new(right_text) + .wrap(Wrap { trim: false }) + .block(Block::bordered().merge_borders(MergeStrategy::Exact)), + panes.right_output, + ); +} + +fn compute_window(len: usize, selected: usize, viewport: usize) -> (usize, usize) { + if len == 0 || viewport == 0 { + return (0, 0); + } + + let viewport = viewport.min(len); + let half = viewport / 2; + + let mut start = selected.saturating_sub(half); + if start + viewport > len { + start = len - viewport; + } + + let end = start + viewport; + (start, end) +} + +fn render_categories(frame: &mut Frame, app: &App, left: Rect, right: Rect) { + // how many rows can we show inside the bordered block? + let viewport = left.height.saturating_sub(2) as usize; + + let len = app.items.len(); + let selected = app.selected.min(len.saturating_sub(1)); + + let (start, end) = compute_window(len, selected, viewport); + + let items: Vec = app.items[start..end] + .iter() + .map(|s| ListItem::new(s.as_str())) + .collect(); + + let list = List::new(items) + .block(Block::bordered().title("Categories").merge_borders(MergeStrategy::Exact)) + .highlight_symbol("> ") + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + + let mut state = ListState::default(); + state.select(Some(selected - start)); // selection relative to slice + + frame.render_stateful_widget(list, left, &mut state); + + // Right pane details (use the real selected item) + frame.render_widget( + Paragraph::new("") + .block(Block::bordered().merge_borders(MergeStrategy::Exact)), + right, + ); +} + +fn render_placeholder(frame: &mut Frame, name: &str, left: Rect, right: Rect) { + frame.render_widget( + Paragraph::new(format!("Inside {}\n\nPress Esc to go back", name)) + .block(Block::bordered().title("Library").merge_borders(MergeStrategy::Exact)), + left, + ); + + frame.render_widget( + Paragraph::new("Right pane reserved for item details / thumbnails") + .block(Block::bordered().title("Details").merge_borders(MergeStrategy::Exact)), + right, + ); +} + +fn render_library( + frame: &mut Frame, + app: &App, + title: &str, + start_index: usize, + total: usize, + items: &[core::jellyfin::JfItem], + selected: usize, + left: Rect, + right: Rect, + show_audio_track_numbers: bool, +) { + let viewport = left.height.saturating_sub(2) as usize; + + let len = items.len(); + let selected = selected.min(len.saturating_sub(1)); + + let (start, end) = compute_window(len, selected, viewport); + + fn show_year_for(item_type: Option<&str>) -> bool { + matches!(item_type, Some("Movie" | "Series" | "MusicAlbum")) + } + + fn left_number_prefix(it: &core::jellyfin::JfItem, show_audio_track_numbers: bool) -> String { + match it.item_type.as_deref() { + Some("Episode") => { + match (it.parent_index_number, it.index_number) { + (Some(season), Some(ep)) => format!("{:02}x{:02} ", season, ep), + (_, Some(ep)) => format!("{:02} ", ep), + _ => String::new(), + } + } + Some("Audio") => { + if !show_audio_track_numbers { + return String::new(); + } + match it.index_number { + Some(n) => format!("{:02}. ", n), + None => String::new(), + } + } + _ => String::new(), + } + } + + let list_items: Vec = items[start..end] + .iter() + .filter(|it| { + // Hide the redundant "collections" folder inside the Collections library + if title.ends_with("Collections") { + if it.item_type.as_deref() == Some("Folder") + && it.name.eq_ignore_ascii_case("collections") + { + return false; + } + } + true + }) + .map(|it| { + let emoji = emoji_prefix_for(app, it); + let prefix = left_number_prefix(it, show_audio_track_numbers); + let year = if show_year_for(it.item_type.as_deref()) { + it.production_year.map(|y| format!(" ({y})")).unwrap_or_default() + } else { + String::new() + }; + ListItem::new(format!("{}{}{}{}", emoji, prefix, it.name, year)) + }) + .collect(); + + let list = List::new(list_items) + .block( + Block::bordered() + .title(format!("{title} [{}/{}]", items.len(), total)) + .merge_borders(MergeStrategy::Exact), + ) + .highlight_symbol("> ") + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + + let mut state = ListState::default(); + state.select(if len > 0 { Some(selected - start) } else { None }); + + frame.render_stateful_widget(list, left, &mut state); + + let detail = if len > 0 { + let it = &items[selected]; + format_details(it) + } else { + "No items returned".to_string() + }; + + frame.render_widget( + Paragraph::new(detail) + .wrap(Wrap { trim: false }) + .block(Block::bordered().title("Details").merge_borders(MergeStrategy::Exact)), + right, + ); +} + +fn open_children(app: &mut App, title: String, query: core::jellyfin::LibraryQuery) { + let cfg = match &app.config { + Some(c) => c.clone(), + None => { app.status = "No config loaded".into(); return; } + }; + let user_id = match cfg.user_id.as_deref() { + Some(u) => u, + None => { app.status = "Missing user_id in config (login again)".into(); return; } + }; + let client = match core::jellyfin::JellyfinClient::from_config(&cfg) { + Ok(c) => c, + Err(e) => { app.status = e; return; } + }; + + app.status = format!("Fetching {}...", title); + + match client.list_items(user_id, query.clone(), 0, PAGE_SIZE) { + Ok(page) => { + app.view_stack.push(View::Library { + title, + query, + items: page.items, + selected: 0, + start_index: 0, + total: page.total, + page_size: PAGE_SIZE, + loading: false, + playlist_ctx: None, + }); + app.status = "$".into(); + } + Err(e) => app.status = format!("Fetch failed: {e}"), + } +} + +fn mpv_socket_path() -> PathBuf { + let mut p = std::env::temp_dir(); + let pid = std::process::id(); + p.push(format!("cjtui-mpv-{}.sock", pid)); + p +} + +fn mpv_request(socket: &PathBuf, json_line: &str) -> Result { + let mut stream = UnixStream::connect(socket) + .map_err(|e| format!("mpv ipc connect failed: {e}"))?; + + // Prevent UI hangs if mpv doesn't respond + let _ = stream.set_write_timeout(Some(Duration::from_millis(40))); + let _ = stream.set_read_timeout(Some(Duration::from_millis(40))); + + stream + .write_all(json_line.as_bytes()) + .map_err(|e| format!("mpv ipc write failed: {e}"))?; + + let mut reader = BufReader::new(stream); + let mut line = String::new(); + + match reader.read_line(&mut line) { + Ok(0) => Err("mpv ipc: EOF".to_string()), + Ok(_) => Ok(line), + Err(e) => { + // timeout is normal; just treat it as "no data yet" + if e.kind() == io::ErrorKind::WouldBlock || e.kind() == io::ErrorKind::TimedOut { + Err("mpv ipc timeout".to_string()) + } else { + Err(format!("mpv ipc read failed: {e}")) + } + } + } +} + +fn mpv_get_bool(mpv: &MpvSession, prop: &str) -> Result { + let resp = mpv_request( + &mpv.socket_path, + &format!(r#"{{"command":["get_property","{}"]}}\n"#, prop), + )?; + + let v: Value = serde_json::from_str(resp.trim()) + .map_err(|e| format!("mpv json parse failed: {e} ({})", resp.trim()))?; + + if v.get("error").and_then(|e| e.as_str()) != Some("success") { + return Err(format!("mpv get_property failed: {}", resp.trim())); + } + + Ok(v.get("data").and_then(|d| d.as_bool()).unwrap_or(false)) +} + +fn mpv_get_f64(mpv: &MpvSession, prop: &str) -> Result, String> { + let resp = mpv_request( + &mpv.socket_path, + &format!(r#"{{"command":["get_property","{}"]}}\n"#, prop), + )?; + + let v: Value = serde_json::from_str(resp.trim()) + .map_err(|e| format!("mpv json parse failed: {e} ({})", resp.trim()))?; + + if v.get("error").and_then(|e| e.as_str()) != Some("success") { + return Err(format!("mpv get_property failed: {}", resp.trim())); + } + + Ok(v.get("data").and_then(|d| d.as_f64())) +} + +fn mpv_send(socket: &PathBuf, json_line: &str) -> Result<(), String> { + let mut stream = UnixStream::connect(socket) + .map_err(|e| format!("mpv ipc connect failed: {e}"))?; + stream + .write_all(json_line.as_bytes()) + .map_err(|e| format!("mpv ipc write failed: {e}"))?; + Ok(()) +} + +fn mpv_cmd(socket: &PathBuf, cmd: &str) -> Result<(), String> { + // cmd should already be valid JSON without trailing newline + mpv_send(socket, &format!("{cmd}\n")) +} + +fn ticks_to_seconds(ticks: u64) -> f64 { + // Jellyfin ticks are 10,000,000 per second + (ticks as f64) / 10_000_000.0 +} + +fn seconds_to_ticks(seconds: f64) -> Option { + if !seconds.is_finite() || seconds < 0.0 { + return None; + } + Some((seconds * 10_000_000.0).round() as i64) +} + +fn mpv_seek_absolute(socket: &PathBuf, seconds: f64) -> Result<(), String> { + // Wait until mpv has loaded something real (duration becomes available), + // then issue a seek and REQUIRE a success reply. + let seek_cmd = format!(r#"{{"command":["seek",{}, "absolute"]}}"#, seconds); + + let start = Instant::now(); + while start.elapsed() < Duration::from_millis(2500) { + // duration being Some is a good signal that the file is actually ready + let dur = mpv_get_f64_jtui(socket, "duration", 1).ok().flatten(); + if dur.is_some() { + // seek, but only accept success + if mpv_cmd_reply(socket, &seek_cmd).is_ok() { + return Ok(()); + } + } + + thread::sleep(Duration::from_millis(50)); + } + + Err("mpv seek failed (mpv never became ready or rejected seek)".into()) +} + +fn spawn_mpv(cfg: &core::config::Config, first_item_id: &str) -> Result { + let token = cfg + .access_token + .as_deref() + .ok_or_else(|| "Missing access_token (login again)".to_string())?; + + let base = cfg.base_url.trim_end_matches('/'); + let url = format!("{}/Items/{}/Download", base, first_item_id); + + let header = format!("X-Emby-Token: {}", token); + let sock = mpv_socket_path(); + + // If a stale socket exists, remove it. + let _ = std::fs::remove_file(&sock); + + let mut child = Command::new("mpv") + .arg(url) + .arg("--force-window=yes") + .arg("--keep-open=yes") + .arg("--idle=yes") + .arg(format!("--http-header-fields={header}")) + .arg(format!("--input-ipc-server={}", sock.display())) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("Failed to spawn mpv: {e}"))?; + + // Wait briefly for IPC socket to appear + let start = Instant::now(); + while start.elapsed() < Duration::from_millis(800) { + if sock.exists() { + return Ok(MpvSession { + socket_path: sock, + child, + }); + } + thread::sleep(Duration::from_millis(10)); + } + + // If socket never appeared, mpv probably died + let _ = child.kill(); + Err("mpv IPC socket did not appear".to_string()) +} + +fn mpv_load_item(cfg: &core::config::Config, mpv: &MpvSession, item_id: &str) -> Result<(), String> { + let base = cfg.base_url.trim_end_matches('/'); + let url = format!("{}/Items/{}/Download", base, item_id); + + // loadfile replace + mpv_cmd( + &mpv.socket_path, + &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url), + ) +} + +fn mpv_cycle_audio(mpv: &MpvSession) -> Result<(), String> { + mpv_cmd(&mpv.socket_path, r#"{"command":["cycle","audio"]}"#) +} + +fn mpv_cycle_subtitles(mpv: &MpvSession) -> Result<(), String> { + mpv_cmd(&mpv.socket_path, r#"{"command":["cycle","sub"]}"#) +} + +fn mpv_toggle_subs(mpv: &MpvSession) -> Result<(), String> { + mpv_cmd(&mpv.socket_path, r#"{"command":["cycle","sub-visibility"]}"#) +} + +fn mpv_stop(mpv: &mut MpvSession) { + let _ = mpv_cmd(&mpv.socket_path, r#"{"command":["quit"]}"#); + let _ = mpv.child.kill(); + let _ = std::fs::remove_file(&mpv.socket_path); +} + +fn mpv_get_prop_jtui(socket: &PathBuf, prop: &str, request_id: u32) -> Result { + let mut stream = UnixStream::connect(socket) + .map_err(|e| format!("mpv ipc connect failed: {e}"))?; + + let _ = stream.set_write_timeout(Some(Duration::from_millis(250))); + let _ = stream.set_read_timeout(Some(Duration::from_millis(250))); + + let cmd = format!( + r#"{{"command":["get_property","{}"],"request_id":{}}}"#, + prop, request_id + ); + + stream + .write_all(format!("{cmd}\n").as_bytes()) + .map_err(|e| format!("mpv ipc write failed: {e}"))?; + + // IMPORTANT: read a whole JSON line (track-list can be >1024 bytes) + let mut reader = BufReader::new(stream); + let mut line = String::new(); + reader + .read_line(&mut line) + .map_err(|e| format!("mpv ipc read failed: {e}"))?; + + if line.trim().is_empty() { + return Err("mpv ipc: empty response".into()); + } + + let v: Value = serde_json::from_str(line.trim()) + .map_err(|e| format!("mpv json parse failed: {e} ({})", line.trim()))?; + + if v.get("error").and_then(|e| e.as_str()) != Some("success") { + return Err(format!("mpv get_property({prop}) failed: {v}")); + } + + Ok(v.get("data").cloned().unwrap_or(Value::Null)) +} + +fn selected_tracks_from_track_list(track_list: &Value) -> (Option, Option) { + let arr = match track_list.as_array() { + Some(a) => a, + None => return (None, None), + }; + + let mut audio: Option = None; + let mut subs: Option = None; + + for t in arr { + let ty = t.get("type").and_then(|x| x.as_str()).unwrap_or(""); + let selected = t.get("selected").and_then(|x| x.as_bool()).unwrap_or(false); + + if !selected { + continue; + } + + match ty { + "audio" => audio = Some(track_label_from_entry(t, "Audio")), + "sub" => subs = Some(track_label_from_entry(t, "Sub")), + _ => {} + } + } + + (audio, subs) +} + +fn mpv_get_f64_jtui(socket: &PathBuf, prop: &str, request_id: u32) -> Result, String> { + Ok(mpv_get_prop_jtui(socket, prop, request_id)?.as_f64()) +} + +fn mpv_get_bool_jtui(socket: &PathBuf, prop: &str, request_id: u32) -> Result { + Ok(mpv_get_prop_jtui(socket, prop, request_id)?.as_bool().unwrap_or(false)) +} + +fn mpv_toggle_fullscreen(mpv: &MpvSession) -> Result<(), String> { + // cycles fullscreen on/off + mpv_cmd(&mpv.socket_path, r#"{"command":["cycle","fullscreen"]}"#) + // alternatively: + // mpv_cmd(&mpv.socket_path, r#"{"command":["set_property","fullscreen","yes"]}"#) +} + +fn mpv_cycle_pause(mpv: &MpvSession) -> Result<(), String> { + mpv_cmd(&mpv.socket_path, r#"{"command":["cycle","pause"]}"#) +} + +fn autoplay_tick(app: &mut App) { + if !app.autoplay { + return; + } + + // If user navigated away from the view where autoplay started, cancel it. + if app.view_stack.len() != app.autoplay_view_depth { + app.autoplay = false; + app.autoplay_next_index = None; + app.autoplay_eof_latched = false; + app.autoplay_prev_time_pos = None; + return; + } + + // Snapshot latest mpv state from poller thread + let st = app.mpv_state.clone(); + + // Optional: live debug in status (toggle with 'D') + if app.debug_mpv { + app.status = fmt_mpv_state(&st); + } + + // Track previous time to detect "snapped back to 0" + if let Some(tp) = st.time_pos { + if tp > 0.25 { + app.autoplay_prev_time_pos = Some(tp); + } + } + + let near_end = match (st.time_pos, st.duration) { + (Some(tp), Some(d)) if d.is_finite() && d > 1.0 => (d - tp) <= 0.50, + _ => false, + }; + + let near_100 = match st.percent { + Some(p) if p.is_finite() => p >= 99.8, + _ => false, + }; + + let snapped_to_start_idle = match (st.idle, st.time_pos, app.autoplay_prev_time_pos) { + (true, Some(tp), Some(prev)) if prev > 1.0 && tp < 0.10 => true, + _ => false, + }; + + let finished = st.eof || near_end || near_100 || snapped_to_start_idle; + + // If not finished, clear latch so we can trigger when it *does* finish later. + if !finished { + app.autoplay_eof_latched = false; + return; + } + + // Only trigger once per finish event + if app.autoplay_eof_latched { + return; + } + app.autoplay_eof_latched = true; + + // Need mpv running to advance + let mpv = match &app.mpv { + Some(m) => m, + None => return, + }; + + let next_i = match app.autoplay_next_index { + Some(i) => i, + None => { + app.autoplay = false; + app.status = "$".into(); + return; + } + }; + + // Choose next target based on current view + enum NextTarget { + Online { id: String, name: String, len: usize }, + Offline { path: PathBuf, name: String, len: usize }, + } + + let next = match app.current_view() { + View::Library { items, .. } if next_i < items.len() => { + NextTarget::Online { + id: items[next_i].id.clone(), + name: items[next_i].name.clone(), + len: items.len(), + } + } + View::OfflineLibrary { entries, .. } if next_i < entries.len() => { + // skip dirs: if next_i lands on a dir, find the next file + let mut j = next_i; + while j < entries.len() && entries[j].is_dir { + j += 1; + } + if j >= entries.len() { + // no more playable files + app.autoplay = false; + app.autoplay_next_index = None; + if !app.debug_mpv { app.status = "$".into(); } + return; + } + + NextTarget::Offline { + path: entries[j].path.clone(), + name: entries[j].name.clone(), + len: entries.len(), + } + } + _ => { + app.autoplay = false; + app.autoplay_next_index = None; + if !app.debug_mpv { app.status = "$".into(); } + return; + } + }; + + match &next { + NextTarget::Online { name, .. } => app.mpv_now_playing = Some(name.clone()), + NextTarget::Offline { name, .. } => app.mpv_now_playing = Some(name.clone()), + } + + // Move selection highlight in UI + compute following index + match app.view_stack.last_mut() { + Some(View::Library { selected, .. }) => { + *selected = next_i; + } + Some(View::OfflineLibrary { selected, entries, .. }) => { + // if we skipped dirs, move selection to actual file index + let mut j = next_i; + while j < entries.len() && entries[j].is_dir { j += 1; } + *selected = j; + } + _ => {} + } + + // compute next index (offline should skip dirs too later) + let len = match &next { + NextTarget::Online { len, .. } => *len, + NextTarget::Offline { len, .. } => *len, + }; + app.autoplay_next_index = if next_i + 1 < len { Some(next_i + 1) } else { None }; + + // Load next + match next { + NextTarget::Online { id: next_id, .. } => { + let cfg = match app.config.clone() { + Some(c) => c, + None => { + app.autoplay = false; + app.autoplay_next_index = None; + app.status = "No config".into(); + return; + } + }; + + // If offline, MUST be local + let local = crate::core::downloads::local_media_path_for_item_id(&next_id); + if app.offline && local.is_none() { + app.autoplay = false; + app.autoplay_next_index = None; + app.status = "Offline: next item not downloaded".into(); + return; + } + + let url = if let Some(p) = local { + p.to_string_lossy().to_string() + } else { + build_jellyfin_play_url(&cfg, &next_id) + }; + + // --- progress reporting state for autoplay transitions --- + app.mpv_now_playing_item_id = Some(next_id.clone()); + app.marked_watched_this_session = false; + app.last_reported_position_ticks = None; + app.last_progress_report_at = Instant::now(); + + app.mpv_now_playing_is_stream = url.starts_with("http://") || url.starts_with("https://"); + + if app.mpv_now_playing_is_stream { + if let Ok(client) = core::jellyfin::JellyfinClient::from_config(&cfg) { + let _ = client.report_playback_start(&next_id, None); + } + } + + if let Err(e) = mpv_load_url(&cfg, mpv, &url) { + app.autoplay = false; + app.autoplay_next_index = None; + app.status = format!("autoplay load failed: {e}"); + return; + } + + } + + NextTarget::Offline { path, .. } => { + let url = path.to_string_lossy().to_string(); + app.mpv_now_playing_item_id = None; + app.mpv_now_playing_is_stream = false; + app.marked_watched_this_session = false; + app.last_reported_position_ticks = None; + app.last_progress_report_at = Instant::now(); + if let Err(e) = mpv_load_local(mpv, &url) { + app.autoplay = false; + app.autoplay_next_index = None; + app.status = format!("offline autoplay load failed: {e}"); + return; + } + } + } + + let _ = mpv_set_pause(mpv, false); + app.autoplay_prev_time_pos = None; + if !app.debug_mpv { + app.status = "$".into(); + } +} + +fn playback_reporting_tick(app: &mut App) { + // Only report for streaming items with a known Jellyfin item id + if !app.mpv_now_playing_is_stream { + return; + } + let item_id = match app.mpv_now_playing_item_id.clone() { + Some(id) => id, + None => return, + }; + + // Need config + user id + let cfg = match app.config.clone() { + Some(c) => c, + None => return, + }; + let user_id = match cfg.user_id.clone() { + Some(u) => u, + None => return, + }; + + // Need current playback time + let secs = match app.mpv_state.time_pos { + Some(s) if s.is_finite() && s >= 0.0 => s, + _ => return, + }; + let pos_ticks = match seconds_to_ticks(secs) { + Some(t) => t, + None => return, + }; + + // Throttle (every 10s) and avoid duplicate sends + let now = Instant::now(); + if now.duration_since(app.last_progress_report_at) < Duration::from_secs(10) { + return; + } + if let Some(last) = app.last_reported_position_ticks { + // if time didn't meaningfully move, don't spam + if (pos_ticks - last).abs() < 5 * 10_000_000 { + app.last_progress_report_at = now; + return; + } + } + + // Send progress (best effort) + if let Ok(client) = core::jellyfin::JellyfinClient::from_config(&cfg) { + // NOTE: adjust this call name/signature to whatever you implemented in core. + // Common pattern: report_playback_progress(item_id, position_ticks) + let _ = client.report_playback_progress(&item_id, Some(pos_ticks), app.mpv_state.paused); + + app.last_reported_position_ticks = Some(pos_ticks); + app.last_progress_report_at = now; + + // Mark watched once when >=95% + let pct = app.mpv_state.percent.unwrap_or(0.0); + if !app.marked_watched_this_session && pct.is_finite() && pct >= 95.0 { + let _ = client.mark_played(&user_id, &item_id); + app.marked_watched_this_session = true; + + // Optional: update current library row immediately if visible + if let Some(View::Library { items, .. }) = app.view_stack.last_mut() { + if let Some(it) = items.iter_mut().find(|it| it.id == item_id) { + let ud = it.user_data.get_or_insert_with(core::jellyfin::JfUserData::default); + ud.played = Some(true); + ud.played_percentage = Some(100.0); + ud.playback_position_ticks = Some(0); + } + } + } + } +} + +fn mpv_set_pause(mpv: &MpvSession, paused: bool) -> Result<(), String> { + // pause is a boolean property in mpv JSON IPC + let v = if paused { "true" } else { "false" }; + mpv_cmd( + &mpv.socket_path, + &format!(r#"{{"command":["set_property","pause",{}]}}"#, v), + ) +} + +fn fmt_mpv_state(st: &MpvState) -> String { + let pct = match st.percent { + Some(p) if p.is_finite() => p, + _ => match (st.time_pos, st.duration) { + (Some(t), Some(d)) if t.is_finite() && d.is_finite() && d > 0.0 => (t / d) * 100.0, + _ => 0.0, + }, + }; + + format!("{pct:5.1}%") +} + +fn start_mpv_poller(socket_path: PathBuf) -> mpsc::Receiver { + let (tx, rx) = mpsc::channel::(); + + std::thread::spawn(move || { + let mut request_id: u32 = 1; + + loop { + let mut st = MpvState::default(); + + // jtui pattern: connect->write->read(1024)->parse->close (per property call) + let tp = mpv_get_f64_jtui(&socket_path, "time-pos", request_id).ok().flatten(); + request_id = request_id.wrapping_add(1); + + let dur = mpv_get_f64_jtui(&socket_path, "duration", request_id).ok().flatten(); + request_id = request_id.wrapping_add(1); + + let percent_pos = mpv_get_f64_jtui(&socket_path, "percent-pos", request_id).ok().flatten(); + request_id = request_id.wrapping_add(1); + + let eof = mpv_get_bool_jtui(&socket_path, "eof-reached", request_id).unwrap_or(false); + request_id = request_id.wrapping_add(1); + + let idle = mpv_get_bool_jtui(&socket_path, "idle-active", request_id).unwrap_or(false); + request_id = request_id.wrapping_add(1); + + let aid = mpv_get_prop_jtui(&socket_path, "aid", request_id).ok().and_then(|v| v.as_i64()); + request_id = request_id.wrapping_add(1); + + let sid = mpv_get_prop_jtui(&socket_path, "sid", request_id).ok().and_then(|v| v.as_i64()); + request_id = request_id.wrapping_add(1); + + let track_list = mpv_get_prop_jtui(&socket_path, "track-list", request_id).ok(); + request_id = request_id.wrapping_add(1); + + let paused = mpv_get_bool_jtui(&socket_path, "pause", request_id).unwrap_or(false); + request_id = request_id.wrapping_add(1); + + let (audio, subs) = match track_list.as_ref() { + Some(v) => selected_tracks_from_track_list(v), + None => (None, None), + }; + + let media_title = mpv_get_prop_jtui(&socket_path, "media-title", request_id) + .ok() + .and_then(|v| v.as_str().map(|s| s.to_string())); + request_id = request_id.wrapping_add(1); + + st.time_pos = tp; + st.duration = dur; + st.eof = eof; + st.idle = idle; + st.media_title = media_title; + st.audio = audio; + st.subs = subs; + st.paused = paused; + + // compute percent yourself (jtui style) + st.percent = percent_pos + .filter(|p| p.is_finite()) + .or_else(|| match (tp, dur) { + (Some(t), Some(d)) if t.is_finite() && d.is_finite() && d > 0.0 => Some((t / d) * 100.0), + _ => None, + }); + + + // Optional: populate last_err if we got nothing useful + if st.time_pos.is_none() && st.duration.is_none() { + st.last_err = Some("mpv poll: no time-pos/duration (check socket / mpv running)".into()); + } + + if tx.send(st).is_err() { + break; + } + + std::thread::sleep(Duration::from_millis(200)); + } + }); + + rx +} + +fn spawn_mpv_url(cfg: &core::config::Config, url: &str) -> Result { + let token = cfg + .access_token + .as_deref() + .ok_or_else(|| "Missing access_token (login again)".to_string())?; + + let header = format!("X-Emby-Token: {}", token); + let sock = mpv_socket_path(); + + let _ = std::fs::remove_file(&sock); + + let mut child = Command::new("mpv") + .arg(url) + .arg("--force-window=yes") + .arg("--keep-open=yes") + .arg("--idle=yes") + .arg(format!("--http-header-fields={header}")) + .arg(format!("--input-ipc-server={}", sock.display())) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("Failed to spawn mpv: {e}"))?; + + let start = Instant::now(); + while start.elapsed() < Duration::from_millis(800) { + if sock.exists() { + return Ok(MpvSession { socket_path: sock, child }); + } + thread::sleep(Duration::from_millis(10)); + } + + let _ = child.kill(); + Err("mpv IPC socket did not appear".to_string()) +} + +fn mpv_load_url(cfg: &core::config::Config, mpv: &MpvSession, url: &str) -> Result<(), String> { + // mpv already has the http header from spawn flags + let _ = cfg; // keep signature symmetric; might be useful later + mpv_cmd( + &mpv.socket_path, + &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url), + ) +} + +fn track_label(title: Option<&str>, lang: Option<&str>, id: Option, kind: &str) -> String { + let mut parts: Vec = Vec::new(); + + if let Some(t) = title.map(|s| s.trim()).filter(|s| !s.is_empty()) { + parts.push(t.to_string()); + } + if let Some(l) = lang.map(|s| s.trim()).filter(|s| !s.is_empty()) { + parts.push(l.to_string()); + } + + if parts.is_empty() { + if let Some(i) = id { + return format!("{kind} {i}"); + } + return "-".into(); + } + + parts.join(" • ") +} + +fn track_label_from_entry(t: &serde_json::Value, kind: &str) -> String { + let title = t.get("title").and_then(|x| x.as_str()).map(str::trim).filter(|s| !s.is_empty()); + let lang = t.get("lang").and_then(|x| x.as_str()).map(str::trim).filter(|s| !s.is_empty()); + + // audio extras are often useful (codec/channels) + let codec = t.get("codec").and_then(|x| x.as_str()).map(str::trim).filter(|s| !s.is_empty()); + let ch = t.get("channels").and_then(|x| x.as_str()).map(str::trim).filter(|s| !s.is_empty()); + + let mut parts: Vec = Vec::new(); + if let Some(v) = title { parts.push(v.to_string()); } + if let Some(v) = lang { parts.push(v.to_string()); } + + if kind == "Audio" { + if let Some(v) = codec { parts.push(v.to_string()); } + if let Some(v) = ch { parts.push(v.to_string()); } + } + + if parts.is_empty() { "-".into() } else { parts.join(" • ") } +} + +fn tracks_from_ids(track_list: &serde_json::Value, aid: Option, sid: Option) -> (Option, Option) { + let arr = match track_list.as_array() { + Some(a) => a, + None => return (None, None), + }; + let mut audio: Option = None; + let mut subs: Option = None; + + for t in arr { + let ty = t.get("type").and_then(|x| x.as_str()).unwrap_or(""); + let id = t.get("id").and_then(|x| x.as_i64()); + + match (ty, id) { + ("audio", Some(id)) if Some(id) == aid => { + audio = Some(track_label_from_entry(t, "Audio")); + } + ("sub", Some(id)) if Some(id) == sid => { + subs = Some(track_label_from_entry(t, "Sub")); + } + _ => {} + } + } + + (audio, subs) +} + +fn fmt_hms(t: f64) -> String { + if !t.is_finite() || t < 0.0 { return "--:--".into(); } + let total = t as u64; + let h = total / 3600; + let m = (total % 3600) / 60; + let s = total % 60; + if h > 0 { format!("{h}:{m:02}:{s:02}") } else { format!("{m}:{s:02}") } +} + +fn mpv_progress(st: &MpvState) -> (u16, String) { + let pct = st.percent.unwrap_or(0.0).clamp(0.0, 100.0); + let p_u16 = pct.round() as u16; + + let cur = st.time_pos.map(fmt_hms).unwrap_or("--:--".into()); + let dur = st.duration.map(fmt_hms).unwrap_or("--:--".into()); + (p_u16, format!("{cur} / {dur} ({pct:0.1}%)")) +} + +fn render_downloads_panel(frame: &mut Frame, app: &App, area: Rect) { + let title = if app.downloads_focus { + "Downloads (focus)" + } else { + "Downloads" + }; + + // rows available inside the border + let viewport = area.height.saturating_sub(2) as usize; + + let mut rows: Vec = Vec::new(); + let mut state = ListState::default(); + + // Helper: "basic" scrolling window (no centering) + // Ensures selected is always visible within [start, start+viewport) + fn basic_window(len: usize, selected: usize, viewport: usize) -> (usize, usize) { + if len == 0 || viewport == 0 { + return (0, 0); + } + let viewport = viewport.min(len); + let mut start = 0usize; + + // If selected is below the visible window, scroll down just enough + if selected >= start + viewport { + start = selected + 1 - viewport; + } + // If selected is above the visible window, scroll up just enough + if selected < start { + start = selected; + } + + // Clamp + if start + viewport > len { + start = len - viewport; + } + + (start, start + viewport) + } + + // Empty states + if app.downloads.is_none() { + rows.push(ListItem::new("(downloads disabled)")); + state.select(None); + + let list = List::new(rows) + .block(Block::bordered().title(title).merge_borders(MergeStrategy::Exact)) + .highlight_symbol(">> ") + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + + frame.render_stateful_widget(list, area, &mut state); + return; + } + + let dm = app.downloads.as_ref().unwrap(); + + match dm.state.lock() { + Err(_) => { + rows.push(ListItem::new("(downloads lock poisoned)")); + state.select(None); + } + Ok(st) => { + let len = st.queue.len(); + + if len == 0 { + rows.push(ListItem::new("(empty)")); + state.select(None); + } else { + // Clamp selection + let selected = app.downloads_selected.min(len.saturating_sub(1)); + + // Basic scrolling window + let (start, end) = basic_window(len, selected, viewport); + + for i in start..end { + let it = match st.queue.get(i) { + Some(v) => v, + None => continue, + }; + + let status = match &it.status { + DownloadStatus::Queued => "queued", + DownloadStatus::Downloading => "downloading", + DownloadStatus::Finalizing => "finalizing", + DownloadStatus::Completed => "done", + DownloadStatus::Cancelled => "cancelled", + DownloadStatus::Failed(_) => "failed", + }; + + let active_prefix = if i == 0 { "▶ " } else { " " }; + + // Prefer aria2-derived info if you have it; otherwise it will show --% (--:--) + let prog_prefix = if matches!(it.status, DownloadStatus::Downloading) { + // uses the fields you now populate from aria2 RPC + // (bytes_downloaded, bytes_total, eta_secs) + let pct_str = match it.bytes_total { + Some(t) if t > 0 => { + let pct = (it.bytes_downloaded as f64 / t as f64) * 100.0; + format!("{:0.0}%", pct.clamp(0.0, 100.0)) + } + _ => "--%".to_string(), + }; + + let eta_str = match it.eta_secs { + Some(s) => fmt_hms(s as f64), + None => "--:--".to_string(), + }; + + format!("{pct_str} ({eta_str}) ") + } else { + String::new() + }; + + rows.push(ListItem::new(format!( + "{active_prefix}{status}: {prog_prefix}{}", + it.title + ))); + } + + // selection must be relative to the window slice + state.select(Some(selected - start)); + } + } + } + + let list = List::new(rows) + .block(Block::bordered().title(title).merge_borders(MergeStrategy::Exact)) + .highlight_symbol(">> ") + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + + frame.render_stateful_widget(list, area, &mut state); +} + +fn next_offline_playable(entries: &[OfflineEntry], from_index: usize) -> Option { + if entries.is_empty() { + return None; + } + let mut i = from_index + 1; + while i < entries.len() { + if !entries[i].is_dir { + return Some(i); + } + i += 1; + } + None +} + +fn spawn_mpv_local(url: &str) -> Result { + let sock = mpv_socket_path(); + let _ = std::fs::remove_file(&sock); + + let mut child = Command::new("mpv") + .arg(url) + .arg("--force-window=yes") + .arg("--keep-open=yes") + .arg("--idle=yes") + .arg(format!("--input-ipc-server={}", sock.display())) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("Failed to spawn mpv: {e}"))?; + + let start = Instant::now(); + while start.elapsed() < Duration::from_millis(800) { + if sock.exists() { + return Ok(MpvSession { socket_path: sock, child }); + } + thread::sleep(Duration::from_millis(10)); + } + + let _ = child.kill(); + Err("mpv IPC socket did not appear".to_string()) +} + +fn mpv_load_local(mpv: &MpvSession, url: &str) -> Result<(), String> { + mpv_cmd( + &mpv.socket_path, + &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url), + ) +} + + +// Returns e.g. "56% (4:32) " +// If total is unknown, returns "--% (--:--) " +fn format_download_pct_eta(downloaded: u64, total: Option, added_at: Instant) -> String { + // Percent + let pct_str = match total { + Some(t) if t > 0 => { + let pct = (downloaded as f64 / t as f64) * 100.0; + format!("{:0.0}%", pct.clamp(0.0, 100.0)) + } + _ => "--%".to_string(), + }; + + // ETA from average speed since added_at (good enough for UI) + let elapsed = added_at.elapsed().as_secs_f64(); + let speed = if elapsed > 0.0 { + downloaded as f64 / elapsed + } else { + 0.0 + }; + + let eta_str = match (total, speed) { + (Some(t), s) if t > downloaded && s > 1.0 => { + let remaining = (t - downloaded) as f64; + let eta_secs = remaining / s; + fmt_hms(eta_secs) + } + _ => "--:--".to_string(), + }; + + format!("{pct_str} ({eta_str}) ") +} + + +fn render_mpv_panel(frame: &mut Frame, app: &App, area: Rect) { + // Make the bar exactly 1 row and don't depend on bordered blocks. + let [c0, c1, c2, c3, c4, c5] = Layout::vertical([ + Constraint::Length(1), // title (no border) + Constraint::Length(1), // audio + Constraint::Length(1), // subs + Constraint::Length(1), // BAR (raw) + Constraint::Length(1), // time + Constraint::Min(1), // controls + ]) + .areas(area); + + let st = &app.mpv_state; + + let title = app + .mpv_now_playing + .clone() + .or_else(|| st.media_title.clone()) + .unwrap_or_else(|| "-".into()); + + let audio = st.audio.clone().unwrap_or_else(|| "-".into()); + let subs = st.subs.clone().unwrap_or_else(|| "None".into()); + + frame.render_widget(Paragraph::new(format!("▶ {title}")), c0); + frame.render_widget(Paragraph::new(format!("Audio: {audio}")), c1); + frame.render_widget(Paragraph::new(format!("Subs: {subs}")), c2); + + let ratio = (st.percent.unwrap_or(0.0) / 100.0).clamp(0.0, 1.0); + let pct = st.percent.unwrap_or(0.0); + + // reserve 1 col so we don't draw into the overlap w/ right pane border + let safe_w = c3.width.saturating_sub(1); + + let bar_line = mpv_bar_line(safe_w, ratio, pct); + frame.render_widget(Paragraph::new(bar_line), c3); + + let cur_secs = st.time_pos.unwrap_or(0.0); + let dur_secs = st.duration.unwrap_or(0.0); + + let cur = st.time_pos.map(fmt_hms).unwrap_or("--:--".into()); + let dur = st.duration.map(fmt_hms).unwrap_or("--:--".into()); + + let remaining = if dur_secs > 0.0 && cur_secs <= dur_secs { + fmt_hms(dur_secs - cur_secs) + } else { + "--:--".into() + }; + + frame.render_widget( + Paragraph::new(format!("{cur} / {dur} ({remaining} remaining)")), + c4, + ); + + let controls = + "Space: pause x: stop f: fullscreen\na: audio s: subs v: sub vis"; + frame.render_widget(Paragraph::new(controls).wrap(Wrap { trim: true }), c5); +} + +fn mpv_bar_line(width: u16, ratio: f64, pct: f64) -> String { + let w = width as usize; + if w == 0 { + return String::new(); + } + + let prefix = "mpv "; + let pct_text = format!("{:>5.1}%", pct.clamp(0.0, 100.0)); // " 43.3%" + let pct_area_min = pct_text.len() + 2; // breathing room around percent + + // If too narrow, just show prefix+percent + if w <= prefix.len() + pct_text.len() { + return format!("{prefix}{pct_text}"); + } + + let avail = w - prefix.len(); + + // Give pct a fixed-ish area if we can, but keep at least 2 cols for the bar: "[]" + let pct_area = pct_area_min.min(avail.saturating_sub(2)); + let bar_w = (avail - pct_area).max(2); + + let bar = unicode_progress_bar(bar_w, ratio); + + // IMPORTANT: compute remaining space using bar_w (columns), not bar.len() (bytes) + let pct_area_actual = avail.saturating_sub(bar_w); + let mut pct_buf = vec![' '; pct_area_actual]; + + if pct_area_actual >= pct_text.len() { + let start = (pct_area_actual - pct_text.len()) / 2; + for (i, ch) in pct_text.chars().enumerate() { + pct_buf[start + i] = ch; + } + } + + let pct_field: String = pct_buf.into_iter().collect(); + format!("{prefix}{bar}{pct_field}") +} + +fn unicode_progress_bar(width: usize, ratio: f64) -> String { + // width includes the brackets: [ .... ] + if width < 2 { + return String::new(); + } + + let inner = width - 2; + let ratio = ratio.clamp(0.0, 1.0); + + // 8-level partial blocks + // 0..=7 maps to: (none), ▏▎▍▌▋▊▉ + const PARTS: [char; 8] = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉']; + + let total_units = inner * 8; + let filled_units = (ratio * total_units as f64).round() as usize; + + let full = filled_units / 8; + let rem = filled_units % 8; + + let mut s = String::with_capacity(width); + s.push('['); + + // full blocks + let full = full.min(inner); + s.push_str(&"█".repeat(full)); + + // partial block (if any room left) + if full < inner { + if rem > 0 { + s.push(PARTS[rem]); + } else { + s.push(' '); + } + + // rest empty + let used = full + 1; + if used < inner { + s.push_str(&" ".repeat(inner - used)); + } + } + + s.push(']'); + s +} + +fn is_music_item(it: &core::jellyfin::JfItem) -> bool { + matches!(it.item_type.as_deref(), Some("Audio" | "MusicAlbum" | "MusicArtist")) +} + +fn is_downloaded(app: &App, it: &core::jellyfin::JfItem) -> bool { + // Basic: only exact item-id matches. + // (No container inference for Series/Season/Album right now.) + app.downloaded_ids.contains(&it.id) +} + +fn is_played(it: &core::jellyfin::JfItem) -> bool { + if it.user_data.as_ref().and_then(|u| u.played) == Some(true) { + return true; + } + + // Jellyfin often provides this for Season/Series + if matches!(it.item_type.as_deref(), Some("Season" | "Series")) { + if it.user_data.as_ref().and_then(|u| u.unplayed_item_count) == Some(0) { + return true; + } + } + + if let Some(p) = it.user_data.as_ref().and_then(|u| u.played_percentage) { + return p >= 99.9; + } + + false +} + +fn playback_position_ticks(it: &core::jellyfin::JfItem) -> Option { + it.user_data + .as_ref() + .and_then(|u| u.playback_position_ticks) + .and_then(|v| if v > 0 { Some(v as u64) } else { None }) +} + +fn is_in_progress_fast(it: &core::jellyfin::JfItem) -> bool { + // Already fully played? Never in-progress. + if is_played(it) { + return false; + } + + match it.item_type.as_deref() { + // Movies / Episodes → resume ticks mean in-progress + Some("Movie" | "Episode") => { + playback_position_ticks(it).is_some() + } + + // Seasons → jtui-style fast logic + Some("Season") => { + // 1) Prefer server percentage if present + if let Some(p) = it.user_data + .as_ref() + .and_then(|u| u.played_percentage) + { + if p > 0.1 && p < 99.9 { + return true; + } + } + + // 2) Fallback to count-based detection: + // 0 < UnplayedItemCount < ChildCount + let total = it.child_count + .and_then(|v| if v > 0 { Some(v as u64) } else { None }); + + let unplayed = it.user_data + .as_ref() + .and_then(|u| u.unplayed_item_count) + .and_then(|v| if v >= 0 { Some(v as u64) } else { None }); + + match (total, unplayed) { + (Some(t), Some(u)) if t > 0 && u > 0 && u < t => true, + _ => false, + } + } + + // Series → complete or nothing (as you requested) + Some("Series") => false, + + _ => false, + } +} + +fn emoji_prefix_for(app: &App, it: &core::jellyfin::JfItem) -> String { + // Music: show only the local-download indicator (💾) and never show played/in-progress. + // Everything else keeps the existing behavior (💾 + ✅/🟠). + let downloaded = is_downloaded(app, it); + + if is_music_item(it) { + return if downloaded { "💾 ".to_string() } else { String::new() }; + } + + let played = is_played(it); + let inprog = is_in_progress_fast(it); + + let mut s = String::new(); + + if downloaded { + s.push_str("💾 "); + } + + if played { + s.push_str("✅ "); + } else if inprog { + s.push_str("🟠 "); + } + + s +} + +/* +fn is_in_progress(it: &core::jellyfin::JfItem) -> bool { + if is_played(it) { + return false; + } + + match it.item_type.as_deref() { + // Movies/Episodes: only if resume ticks exist + Some("Movie" | "Episode") => playback_position_ticks(it).is_some(), + + // Seasons: percent-based + Some("Season") => it.user_data + .as_ref() + .and_then(|u| u.played_percentage) + .is_some_and(|p| p > 0.1 && p < 99.9), + + // Series: no in-progress marker (your requirement) + Some("Series") => false, + + _ => false, + } +} +*/ + +// --- transcoding + resume URL builder (Approach B) --- +fn clamp_mbps_to_bps(mbps: u32) -> u32 { + mbps.saturating_mul(1_000_000) // Mb/s -> bits/s +} + +fn build_jellyfin_play_url(cfg: &core::config::Config, item_id: &str) -> String { + let base = cfg.base_url.trim_end_matches('/'); + format!("{}/Items/{}/Download", base, item_id) +} + +fn mpv_cmd_reply(socket: &PathBuf, cmd: &str) -> Result { + let mut stream = UnixStream::connect(socket) + .map_err(|e| format!("mpv ipc connect failed: {e}"))?; + + let _ = stream.set_write_timeout(Some(Duration::from_millis(500))); + let _ = stream.set_read_timeout(Some(Duration::from_millis(500))); + + stream + .write_all(format!("{cmd}\n").as_bytes()) + .map_err(|e| format!("mpv ipc write failed: {e}"))?; + + let mut reader = BufReader::new(stream); + let mut line = String::new(); + reader + .read_line(&mut line) + .map_err(|e| format!("mpv ipc read failed: {e}"))?; + + if line.trim().is_empty() { + return Err("mpv ipc: empty response".into()); + } + + let v: Value = serde_json::from_str(line.trim()) + .map_err(|e| format!("mpv json parse failed: {e} ({})", line.trim()))?; + + if v.get("error").and_then(|e| e.as_str()) != Some("success") { + return Err(format!("mpv command failed: {}", line.trim())); + } + + Ok(v) +} + +struct Panes { + top: Rect, + left_main: Rect, + right_main: Rect, + left_output: Rect, + right_output: Rect, +} + +fn layout(area: Rect) -> Panes { + use Constraint::Fill; + + let [top, bottom] = + Layout::vertical([Fill(1), Fill(12)]).areas(area); + + let [main_area, output_area] = + Layout::vertical([Fill(9), Fill(3)]) + .spacing(Spacing::Overlap(1)) + .areas(bottom); + + let [left_main, right_main] = + Layout::horizontal([Fill(1), Fill(1)]) + .spacing(Spacing::Overlap(1)) + .areas(main_area); + + let [left_output, right_output] = + Layout::horizontal([Fill(7), Fill(2)]) + .spacing(Spacing::Overlap(1)) + .areas(output_area); + + Panes { + top, + left_main, + right_main, + left_output, + right_output, + } +} + +fn render_login(frame: &mut Frame, app: &App, left: Rect, right: Rect) { + let focus = match app.login_focus { + LoginFocus::User => "username", + LoginFocus::Pass => "password", + }; + + let masked = "*".repeat(app.login_pass.len()); + + let left_text = format!( + "Login (Tab switch, Enter submit)\n\nusername: {}\npassword: {}\n\nfocus: {}\n", + app.login_user, masked, focus + ); + + frame.render_widget( + Paragraph::new(left_text) + .block(Block::bordered().title("Login").merge_borders(MergeStrategy::Exact)), + left, + ); + + let right_text = match &app.login_error { + Some(e) => format!("Error:\n{e}"), + None => "Enter Jellyfin credentials.\nToken will be stored in config.toml.".to_string(), + }; + + frame.render_widget( + Paragraph::new(right_text) + .block(Block::bordered().title("Info").merge_borders(MergeStrategy::Exact)), + right, + ); +} + +fn render_offline_library( + frame: &mut Frame, + title: &str, + items: &[core::downloads::LocalEntry], + selected: usize, + left: Rect, + right: Rect, +) { + let viewport = left.height.saturating_sub(2) as usize; + let len = items.len(); + let selected = selected.min(len.saturating_sub(1)); + + let (start, end) = compute_window(len, selected, viewport); + + let list_items: Vec = items[start..end] + .iter() + .map(|it| ListItem::new(format!("💾 {}", it.title))) + .collect(); + + let list = List::new(list_items) + .block( + Block::bordered() + .title(format!("{title} [{}/{}]", items.len(), items.len())) + .merge_borders(MergeStrategy::Exact), + ) + .highlight_symbol("> ") + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + + let mut state = ListState::default(); + state.select(if len > 0 { Some(selected - start) } else { None }); + + frame.render_stateful_widget(list, left, &mut state); + + let detail = if len > 0 { + let it = &items[selected]; + format!("Title: {}\nItemId: {}\nPath:\n{}", it.title, it.item_id, it.path.display()) + } else { + "No downloaded items found".to_string() + }; + + frame.render_widget( + Paragraph::new(detail) + .wrap(Wrap { trim: false }) + .block(Block::bordered().title("Details").merge_borders(MergeStrategy::Exact)), + right, + ); +} + +fn categories_for_mode(offline: bool) -> Vec { + if offline { + vec![ + "Downloaded".into(), + ] + } else { + vec![ + "Shows".into(), + "Movies".into(), + "Music".into(), + "Playlists".into(), + "Collections".into(), + "Libraries".into(), + "Continue Watching".into(), + "Recently Added".into(), + ] + } +} + +#[derive(Clone, Debug)] +struct OfflineEntry { + name: String, + path: std::path::PathBuf, + is_dir: bool, +} + +fn offline_list_dir(dir: &std::path::Path) -> Vec { + let mut out: Vec = Vec::new(); + + let rd = match std::fs::read_dir(dir) { + Ok(r) => r, + Err(_) => return out, + }; + + for ent in rd.flatten() { + let path = ent.path(); + let name = match path.file_name().and_then(|s| s.to_str()) { + Some(s) => s.to_string(), + None => continue, + }; + + // Hide sidecar files + if !path.is_dir() && is_hidden_sidecar(&name) { + continue; + } + + let is_dir = path.is_dir(); + + // ✅ Hide "empty" folders (only sidecars / empty / no real files under them) + if is_dir && !dir_has_visible_content(&path) { + continue; + } + + out.push(OfflineEntry { name, path, is_dir }); + } + + // dirs first, then files; alpha within groups + out.sort_by(|a, b| { + match (a.is_dir, b.is_dir) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()), + } + }); + + out +} + + +fn is_hidden_sidecar(name: &str) -> bool { + name.ends_with(".cjtui.json") || name.ends_with(".aria2") || name.ends_with(".part") +} + +// Returns true if this directory contains any "visible" file or any subdir that does. +fn dir_has_visible_content(dir: &std::path::Path) -> bool { + let rd = match std::fs::read_dir(dir) { + Ok(r) => r, + Err(_) => return false, + }; + + for ent in rd.flatten() { + let path = ent.path(); + let name = match path.file_name().and_then(|s| s.to_str()) { + Some(s) => s, + None => continue, + }; + + if path.is_dir() { + if dir_has_visible_content(&path) { + return true; + } + continue; + } + + // file + if is_hidden_sidecar(name) { + continue; + } + + return true; // any other file means "visible content" + } + + false +} + + +fn render_offline_library_dir( + frame: &mut Frame, + title: &str, + entries: &[OfflineEntry], + selected: usize, + left: Rect, + right: Rect, +) { + let viewport = left.height.saturating_sub(2) as usize; + + let len = entries.len(); + let selected = selected.min(len.saturating_sub(1)); + + let (start, end) = compute_window(len, selected, viewport); + + let list_items: Vec = entries[start..end] + .iter() + .map(|e| { + if e.is_dir { + ListItem::new(format!("📁 {}", e.name)) + } else { + ListItem::new(format!("💾 {}", e.name)) + } + }) + .collect(); + + let list = List::new(list_items) + .block(Block::bordered().title(title).merge_borders(MergeStrategy::Exact)) + .highlight_symbol("> ") + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + + let mut state = ListState::default(); + state.select(if len > 0 { Some(selected - start) } else { None }); + + frame.render_stateful_widget(list, left, &mut state); + + let detail = if len > 0 { + let e = &entries[selected]; + format!( + "Name: {}\nType: {}\nPath:\n{}", + e.name, + if e.is_dir { "dir" } else { "file" }, + e.path.display() + ) + } else { + "Empty directory".to_string() + }; + + frame.render_widget( + Paragraph::new(detail) + .wrap(Wrap { trim: false }) + .block(Block::bordered().title("Details").merge_borders(MergeStrategy::Exact)), + right, + ); +} + +fn ensure_config_before_tui() -> Result<(), String> { + use std::io::{self, Write}; + + use crate::core::config::{self, Config, ConfigError}; + + match config::load_config() { + Ok(_) => return Ok(()), + Err(ConfigError::NotFound(_)) => { + // first run, we'll prompt below + } + Err(e) => { + // config exists but is broken; still offer to recreate + eprintln!("Config load error: {e:?}"); + eprintln!("We can recreate it now."); + } + } + + let path = config::config_path().ok_or_else(|| "Cannot resolve config path (HOME/XDG?)".to_string())?; + + println!("No usable config found."); + println!("We will create: {}", path.display()); + println!(); + + let mut url = String::new(); + loop { + print!("Jellyfin base URL (example: http://localhost:8096): "); + io::stdout().flush().map_err(|e| e.to_string())?; + + url.clear(); + io::stdin() + .read_line(&mut url) + .map_err(|e| e.to_string())?; + + let u = url.trim().trim_end_matches('/').to_string(); + + if u.is_empty() { + println!("URL cannot be empty.\n"); + continue; + } + if !(u.starts_with("http://") || u.starts_with("https://")) { + println!("Please include http:// or https://\n"); + continue; + } + + let cfg = Config { + base_url: u, + access_token: None, + user_id: None, + device_id: None, // generated and persisted on first login + }; + + config::save_config(&cfg).map_err(|e| format!("save_config failed: {e:?}"))?; + println!("\nWrote config: {}\n", path.display()); + break; + } + + Ok(()) +} + +fn jellyfin_report_stopped_best_effort(app: &mut App) { + // Only report for streaming items with a known Jellyfin item id + if !app.mpv_now_playing_is_stream { + return; + } + let item_id = match app.mpv_now_playing_item_id.clone() { + Some(id) => id, + None => return, + }; + + let cfg = match app.config.clone() { + Some(c) => c, + None => return, + }; + + // position ticks (best effort) + let secs = app.mpv_state.time_pos.unwrap_or(0.0); + let pos_ticks = seconds_to_ticks(secs).unwrap_or(0); + + // IMPORTANT: + // Jellyfin's "Failed" means playback error, not "not completed". + // Completion is handled by your >=95% mark_played logic elsewhere. + let failed = false; + + if let Ok(client) = core::jellyfin::JellyfinClient::from_config(&cfg) { + let _ = client.report_playback_stopped(&item_id, Some(pos_ticks), failed); + } +} + +fn is_playable_item_type(ty: &str) -> bool { + matches!(ty, "Episode" | "Movie" | "Audio") +}