Compare commits
75 Commits
49c4b4da17
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a8a3451c4 | |||
| 65200626d2 | |||
| 6cc4e18f72 | |||
| 19267d169b | |||
| 5c0b7879e3 | |||
| a5aea5adcf | |||
| e328ff3883 | |||
| a281c6730a | |||
| baa91d4091 | |||
| ae87bb8b36 | |||
| 090f077eeb | |||
| 96bfc96656 | |||
| 2279004e91 | |||
| acdd6dc3e9 | |||
| 585907f876 | |||
| b12bba9125 | |||
| 01275e20b8 | |||
| b67a3d38c6 | |||
| f9e78c4549 | |||
| 2d30120072 | |||
| acfb3be1e7 | |||
| d8e26612e4 | |||
| 3fda432dbd | |||
| f21a1fbe63 | |||
| 17cef8e1bf | |||
| 063177b7df | |||
| 5c4c387e5c | |||
| a44d95bb71 | |||
| 78b0c92e9b | |||
| 493edfcf3a | |||
| 3e701c99c9 | |||
| d9c740c506 | |||
| 9743d1993d | |||
| ba04cbe158 | |||
| f8334716af | |||
| 08e4a62228 | |||
| bb0a3e58ce | |||
| 1e89be58b6 | |||
| df35b1cddb | |||
| 318e908da1 | |||
| 0e909001d0 | |||
| 0fe24ff302 | |||
| af282e2a81 | |||
| a6557047fe | |||
| 28c94eb70b | |||
| b15fa23b8a | |||
| 45a32de4c4 | |||
| 0d69d050ee | |||
| 6563a3d38a | |||
| b53b57ee17 | |||
| ff7590dfa7 | |||
| aa66ac9d7f | |||
| 36afc157d1 | |||
| d70a077076 | |||
| a9fef33e51 | |||
| 343010ce5c | |||
| b65834e9d4 | |||
| 6eed1cdd9e | |||
| cf4941c879 | |||
| af8032f12f | |||
| 2f11bdd40a | |||
| 6afe229406 | |||
| 766fef625c | |||
| 8dee7eb382 | |||
| c3f713001d | |||
| df63eb0d85 | |||
| aaa13ab905 | |||
| eee610105e | |||
| f5c1386315 | |||
| 600ccd3f87 | |||
| a9d5949cde | |||
| b2f8407309 | |||
| 1a13fa8f47 | |||
| b38ca30735 | |||
| f3e7e249be |
@@ -1 +1,2 @@
|
|||||||
/target
|
/target
|
||||||
|
TODO.md
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Revision descriptions
|
||||||
|
|
||||||
|
Use concise revision descriptions without a `(TODO.md)` suffix. Include a ticket ID only when the task has a real ticket.
|
||||||
Generated
+225
@@ -79,6 +79,18 @@ version = "1.5.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.23.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64-simd"
|
name = "base64-simd"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -139,6 +151,12 @@ version = "0.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytes"
|
||||||
|
version = "1.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "castaway"
|
name = "castaway"
|
||||||
version = "0.2.4"
|
version = "0.2.4"
|
||||||
@@ -148,6 +166,16 @@ dependencies = [
|
|||||||
"rustversion",
|
"rustversion",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cc"
|
||||||
|
version = "1.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838"
|
||||||
|
dependencies = [
|
||||||
|
"find-msvc-tools",
|
||||||
|
"shlex",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
@@ -238,6 +266,7 @@ version = "0.29.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
|
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"crossterm_winapi",
|
"crossterm_winapi",
|
||||||
"derive_more",
|
"derive_more",
|
||||||
@@ -361,6 +390,12 @@ dependencies = [
|
|||||||
"simd-adler32",
|
"simd-adler32",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "find-msvc-tools"
|
||||||
|
version = "0.1.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flate2"
|
name = "flate2"
|
||||||
version = "1.1.10"
|
version = "1.1.10"
|
||||||
@@ -423,6 +458,22 @@ version = "0.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"itoa",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httparse"
|
||||||
|
version = "1.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icy_sixel"
|
name = "icy_sixel"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -447,6 +498,7 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"byteorder-lite",
|
"byteorder-lite",
|
||||||
|
"image-webp",
|
||||||
"moxcms",
|
"moxcms",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"png",
|
"png",
|
||||||
@@ -454,6 +506,16 @@ dependencies = [
|
|||||||
"zune-jpeg",
|
"zune-jpeg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "image-webp"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder-lite",
|
||||||
|
"quick-error",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.14.2"
|
version = "2.14.2"
|
||||||
@@ -654,6 +716,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell"
|
||||||
|
version = "1.21.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell_polyfill"
|
name = "once_cell_polyfill"
|
||||||
version = "1.70.2"
|
version = "1.70.2"
|
||||||
@@ -732,6 +800,12 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "percent-encoding"
|
||||||
|
version = "2.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "png"
|
name = "png"
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
@@ -799,6 +873,12 @@ dependencies = [
|
|||||||
"wide",
|
"wide",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-error"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.47"
|
version = "1.0.47"
|
||||||
@@ -981,6 +1061,20 @@ dependencies = [
|
|||||||
"syn 3.0.6",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ring"
|
||||||
|
version = "0.17.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cfg-if",
|
||||||
|
"getrandom",
|
||||||
|
"libc",
|
||||||
|
"untrusted",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc_version"
|
name = "rustc_version"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -1016,6 +1110,41 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls"
|
||||||
|
version = "0.23.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-webpki",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-pki-types"
|
||||||
|
version = "1.15.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||||
|
dependencies = [
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-webpki"
|
||||||
|
version = "0.103.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||||
|
dependencies = [
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"untrusted",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
@@ -1098,6 +1227,12 @@ dependencies = [
|
|||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signal-hook"
|
name = "signal-hook"
|
||||||
version = "0.3.18"
|
version = "0.3.18"
|
||||||
@@ -1174,6 +1309,12 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.119"
|
version = "2.0.119"
|
||||||
@@ -1263,6 +1404,12 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinyvec"
|
||||||
|
version = "1.13.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_datetime"
|
name = "toml_datetime"
|
||||||
version = "1.1.2+spec-1.1.0"
|
version = "1.1.2+spec-1.1.0"
|
||||||
@@ -1305,6 +1452,7 @@ name = "tubular"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
|
"crossterm",
|
||||||
"image",
|
"image",
|
||||||
"libc",
|
"libc",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
@@ -1313,6 +1461,9 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"time",
|
"time",
|
||||||
"toml_edit",
|
"toml_edit",
|
||||||
|
"unicode-normalization",
|
||||||
|
"unicode-segmentation",
|
||||||
|
"ureq",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1321,6 +1472,15 @@ version = "1.0.26"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-normalization"
|
||||||
|
version = "0.1.25"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
|
||||||
|
dependencies = [
|
||||||
|
"tinyvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-segmentation"
|
name = "unicode-segmentation"
|
||||||
version = "1.13.3"
|
version = "1.13.3"
|
||||||
@@ -1344,6 +1504,47 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ureq"
|
||||||
|
version = "3.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.23.1",
|
||||||
|
"flate2",
|
||||||
|
"log",
|
||||||
|
"percent-encoding",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"ureq-proto",
|
||||||
|
"utf8-zero",
|
||||||
|
"webpki-roots",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ureq-proto"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.23.1",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8-zero"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8parse"
|
name = "utf8parse"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -1362,6 +1563,15 @@ version = "0.11.1+wasi-snapshot-preview1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wide"
|
name = "wide"
|
||||||
version = "1.7.1"
|
version = "1.7.1"
|
||||||
@@ -1464,6 +1674,15 @@ dependencies = [
|
|||||||
"windows-targets",
|
"windows-targets",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.52.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.59.0"
|
version = "0.59.0"
|
||||||
@@ -1584,6 +1803,12 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zlib-rs"
|
name = "zlib-rs"
|
||||||
version = "0.6.8"
|
version = "0.6.8"
|
||||||
|
|||||||
+7
-3
@@ -2,17 +2,21 @@
|
|||||||
name = "tubular"
|
name = "tubular"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "A terminal YouTube browser that opens selected videos in Terebi"
|
description = "A terminal YouTube and AniList browser that opens selected videos in Terebi"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clap = { version = "4.6.7", features = ["derive"] }
|
clap = { version = "4.6.7", features = ["derive"] }
|
||||||
image = { version = "0.25.10", default-features = false, features = ["jpeg"] }
|
crossterm = { version = "0.29.0", default-features = false, features = ["osc52"] }
|
||||||
|
image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||||
ratatui = { version = "0.30.2", default-features = false, features = ["crossterm", "unstable-rendered-line-info"] }
|
ratatui = { version = "0.30.2", default-features = false, features = ["crossterm", "unstable-rendered-line-info"] }
|
||||||
ratatui-image = { version = "11.1.0", default-features = false, features = ["crossterm"] }
|
ratatui-image = { version = "11.1.0", default-features = false, features = ["crossterm"] }
|
||||||
serde = { version = "1.0.229", features = ["derive"] }
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
serde_json = "1.0.151"
|
serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||||
toml_edit = "0.25.17"
|
toml_edit = "0.25.17"
|
||||||
time = "0.3.55"
|
time = "0.3.55"
|
||||||
|
unicode-normalization = "0.1.25"
|
||||||
|
unicode-segmentation = "1.13.3"
|
||||||
|
ureq = { version = "3.4.2", default-features = false, features = ["rustls", "gzip"] }
|
||||||
|
|
||||||
[target."cfg(unix)".dependencies]
|
[target."cfg(unix)".dependencies]
|
||||||
libc = "0.2.190"
|
libc = "0.2.190"
|
||||||
|
|||||||
@@ -0,0 +1,470 @@
|
|||||||
|
# tubular
|
||||||
|
|
||||||
|
A modal terminal YouTube and AniList browser built with Rust and [Ratatui](https://ratatui.rs/). Search videos or anime catalog titles, organize channels and saved shows with overlapping tags, and open a selection in Terebi. The explicitly separate Miruro legacy catalog and library remain accessible.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- A recent stable Rust toolchain, including Cargo, to build and install.
|
||||||
|
- [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) on `PATH` for YouTube searches, feeds, and full descriptions; AniList and legacy Miruro catalog requests use native HTTPS.
|
||||||
|
- `curl` on `PATH` for thumbnails and anime cover art.
|
||||||
|
- The **Terebi CLI** installed on `PATH`, together with its app bundle and streaming dependencies, for playback. Single videos use `--new-instance --url … --progress-file PATH`; queues require `--new-instance --queue-file PATH`. AniList playback requires the ZokoAnime sub resolver and version-2 `anilist` identity progress support; legacy Miruro retains its own resolver and identities.
|
||||||
|
|
||||||
|
The default playback setup requires **macOS 15+**, since Terebi is a native macOS player. Install Terebi separately and follow its dependency/setup instructions; installing Tubular does not install the player.
|
||||||
|
|
||||||
|
YouTube extraction uses IPv4 to avoid anti-bot failures on affected IPv6 routes. Terebi must be updated separately for the same playback fix; rebuilding Tubular alone does not change its bundled extractor invocation. No browser cookies are read.
|
||||||
|
|
||||||
|
A terminal with native image support can display thumbnails and cover art using its graphics protocol. Other terminals fall back to colored half-block characters.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
From a checkout of this repository:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo install --path . --locked
|
||||||
|
```
|
||||||
|
|
||||||
|
Make sure Cargo's install directory (normally `~/.cargo/bin`) is on your `PATH`.
|
||||||
|
|
||||||
|
To run directly from source instead:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run --locked -- "rust programming"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tubular # Choose YouTube, AniList, or Miruro legacy
|
||||||
|
tubular "rust programming" # Choose a provider, then search this query
|
||||||
|
tubular --anilist "Cowboy Bebop" # Native AniList catalog; resolve sub on selection
|
||||||
|
tubular --miruro "apothecary" # Explicit legacy Miruro browsing
|
||||||
|
tubular --player /path/to/terebi "japanese podcast"
|
||||||
|
tubular --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Startup shows a centered **YouTube / AniList / Miruro legacy** selector. Use `j` / `k` or arrows to select and Enter to open; Esc, `q`, or Ctrl-C quits. Configured selector/quit keys apply. An initial CLI query searches only the chosen catalog. `--anilist` and `--miruro` bypass the chooser and are mutually exclusive; `--help` and `--default-config` remain noninteractive.
|
||||||
|
|
||||||
|
After selection, the browser starts in **normal mode**. Press `i` to type a search; Enter submits a changed query and returns to normal mode. Esc returns without submitting.
|
||||||
|
|
||||||
|
In normal mode, `p` (`app.provider`) reopens the chooser with the current selection. Enter switches; Esc cancels; `q` or Ctrl-C quits. All three browser sessions retain their own queries, results, selection and history. Shared persisted state and pending progress follow the active browser, without mixing provider-scoped collections or show libraries. Switching never interrupts players or reapplies the initial CLI query.
|
||||||
|
|
||||||
|
The browsers share a cyan view heading, bordered search/results/status, and a dim key hint bar. Rows place the title above metadata; YouTube channel/publication/duration behavior is unchanged. Saved titles are green, watch-later titles magenta, and `✓` marks watched entries. AniList and legacy Miruro reuse the anime catalog/series/episode interface, cover-art previews, overlapping show tags, checked queues and offline episode collections.
|
||||||
|
YouTube's dimmed metadata row includes duration as `M:SS` or `H:MM:SS`, or `Unknown duration` when unavailable. Duration is retained in saved collections; older entries without duration remain readable.
|
||||||
|
|
||||||
|
YouTube keeps fast flat listings, then resolves visible videos' original-preferred titles asynchronously, one metadata request at a time. Titles replace the listing text left to right at 20 ms per terminal column, using the smallest groups of whole user-perceived characters with matching column widths. Two narrow characters and one wide character therefore swap together after 40 ms, without shifting the untouched suffix or inserting temporary blanks. Only an exhausted shorter title is padded with trailing spaces; that padding disappears when the animation finishes. Offscreen changes apply immediately, and selection stays on the same video. Results are cached by video ID for the session; failures keep the listing title without automatic retries. This adds background yt-dlp requests, and opening video info can make a separate metadata request. “Original-preferred” uses the full extractor response, not a guarantee that YouTube will never return localized text.
|
||||||
|
|
||||||
|
Selecting a YouTube video launches:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
terebi --new-instance --url 'https://www.youtube.com/watch?v=VIDEO_ID' \
|
||||||
|
--progress-file '/private/session/progress.jsonl'
|
||||||
|
```
|
||||||
|
|
||||||
|
Plain Enter opens one independent player instance without replacing existing playback. Ctrl+Enter appends to a running player's queue instead. Tubular creates a private reporting session per video and consumes Terebi's progress records independently of the launcher. `--player` overrides the Terebi CLI executable; it is not a generic mpv/IINA adapter. Tubular reports launcher failures, while later streaming/playback errors appear in Terebi. Quitting Tubular leaves launched players running.
|
||||||
|
|
||||||
|
### Multiple video selection
|
||||||
|
|
||||||
|
In video and episode lists, **Space** checks/unchecks the hovered row; **Esc** discards checks. **Enter** plays checked rows in the order they were checked, regardless of their current displayed order. Unchecking and rechecking a row moves it to the end. With no checks, Enter plays the hovered row. These controls also work in collection views and metadata previews; Miruro catalog titles and saved shows cannot be checked.
|
||||||
|
|
||||||
|
Selection and watched state share one marker slot: `*` for checked rows, otherwise `✓` for watched rows, otherwise blank. Checking a row only overrides its displayed marker, not its stored watch state. Category and tag selectors keep their checkboxes.
|
||||||
|
|
||||||
|
In normal browsing, Esc discards checks even when none are selected; `-` remains back navigation. Popups and text inputs keep their existing Esc close/cancel behavior. Ctrl-Space remains an alternative way to clear checks.
|
||||||
|
|
||||||
|
Checks belong to each cached view/screen and preserve check order by stable video identity through pagination and reordering. Refresh/replacement queries clear them; collection filtering/removal prunes checks for hidden/removed rows. Successful launcher creation clears checks, while immediate launch failures retain them. Saving, removing and marking watch status still affect the hovered video, not every checked row. Configure `video.select.toggle` and `video.select.clear`; terminals must transmit Ctrl-Space, or bind another chord.
|
||||||
|
|
||||||
|
Two or more checked videos launch `terebi --new-instance --queue-file '/private/session/queue.json'`. The private version-1 manifest contains ordered canonical episode/video page URLs and one independent `progress_file` per item. Terebi reuses its queue navigation and resolves each item when selected, not in advance; no expiring playback sources are stored. Each item's watching/watched state and resume identity remain independent, including after Tubular restarts. Single-video launches retain the command above. See the [queue contract](docs/terebi-url-playback-handoff.md#ordered-stream-queue-handoff).
|
||||||
|
|
||||||
|
**Ctrl+Return** (`video.enqueue`) sends the same checked rows in check order, or the hovered row when none are checked, to a running Terebi queue without replacing its current playback. The terminal must transmit the modified Return, or you can rebind the action. Enqueue always uses `terebi --enqueue --queue-file '/private/session/queue.json'`, even for one video, preserving independent per-item progress. If Terebi is not running, it starts with that queue. Plain Return still opens a new instance. These shortcuts do nothing on Miruro catalog titles or saved shows and do not submit text inputs.
|
||||||
|
|
||||||
|
Tubular requests the Kitty keyboard protocol's escape-code disambiguation for its entire terminal session and restores the previous mode on exit. Supporting terminals can then distinguish Ctrl+Return from plain Return. A terminal or multiplexer that does not support/forward this protocol may still send identical bytes for both; Tubular cannot distinguish them. In that case, configure another key, for example `[keys.video]` with `enqueue = "alt+e"`.
|
||||||
|
|
||||||
|
### AniList catalog and on-demand sub playback
|
||||||
|
|
||||||
|
Choose **AniList** or use `--anilist`. Search/detail metadata comes directly from `https://graphql.anilist.co` through the existing native TLS client: gzip response compression, a 20-second timeout, no redirects, 2 MiB limits on both wire and decompressed response bodies, HTTP/content-type checks and GraphQL-error rejection. Searches return at most eight titles. Nullable artwork, descriptions, scores, planned totals and airing metadata remain unknown rather than being replaced with fabricated values.
|
||||||
|
|
||||||
|
Opening an AniList search result builds catalog episode rows immediately from the metadata already fetched, without a second detail request. `r` still fetches fresh metadata; opening a saved-library show also fetches current detail. Back/forward retains the loaded screen.
|
||||||
|
|
||||||
|
**Catalog episode numbers are not source availability.** A finished show's known total supplies catalog episode numbers; a releasing show's `nextAiringEpisode.episode - 1` supplies an airing/catalog count, separate from its planned total. Unreleased shows have no aired numbers. Missing totals/airing information remain unknown, not “zero episodes.” In an unknown-count series, press `i` (configured search/edit), enter a positive canonical episode number, then Enter to select it. Enter again launches that explicit episode. The direct source is **sub only**, never an automatic dub substitute. Numbers do not establish that a stream exists, including for specials or split seasons.
|
||||||
|
|
||||||
|
AniList playable rows in series and offline episode collections have three aligned lines: title, existing metadata/watch markers, and a quiet source-check result. The third line starts as `Source check pending`, becomes `Checking source…`, then shows `ZokoAnime · sub`, `No source found` (only an explicit missing-episode response), or `Source check failed` (network/protection/malformed responses, manifest errors, an older CLI, or launcher failure). A positive check means a fresh source configuration and accessible valid root HLS manifest, **not** a guarantee that every segment or the full episode will play.
|
||||||
|
|
||||||
|
Checks run progressively through the configured `--player` executable's headless `--check-source URL` command, one at a time: selected episode first, then only rows actually visible in the current viewport. There is no whole-series scan. Moving the cursor within visible rows lets the current check finish; scrolling it offscreen, navigating away, loading catalog metadata, opening a popup/input/provider chooser, or closing Tubular cancels pending work. Returning to an unobscured episode view resumes checks after its next rendered frame. Each check has a 30-second timeout and a 4 KiB output cap; cancellation kills and reaps the isolated CLI child without a background reader thread.
|
||||||
|
|
||||||
|
Results are session-only by stable episode identity, including failures, so errors do not retry every tick. `r` clears this cache and refreshes the view. Checks do not change saved episodes, watched state, progress, config or stored metadata, and never store resolved URLs. Playback remains available for every status and always resolves the selected episode fresh; a check is not reused as playable media. An older Terebi CLI shows `Source check failed` without disabling playback. A successful handoff does not mark an episode watched.
|
||||||
|
|
||||||
|
The second line retains series-average runtime (`25 min avg`), format and series year followed by collection/watch markers. Missing or zero average runtimes are omitted. These are series-level facts, not measured episode durations or individual air dates; legacy Miruro retains its episode-specific dates/durations and two-line rows. Every line reserves the same checked/watched marker width so its text aligns with the episode title.
|
||||||
|
|
||||||
|
|
||||||
|
Identities are `anilist:<positive decimal AniList ID>:<positive episode>`, with both numbers bounded by Int32. The stable handoff is `https://zokoanime.video/stream/ani/ID/EPISODE/sub` (`ani`, not the broken `anilist` route). Sources, tokens and subtitle URLs are never stored in collections or playback snapshots. Queues use the same stable URL and per-item private progress files; version-2 reports identify `provider = "anilist"`, decimal-string `media_id`, and numeric `episode`.
|
||||||
|
|
||||||
|
`anilist-library.toml` stores AniList shows/tags separately from the untouched `miruro-library.toml`. Shared episode collection/state files use exact provider-qualified keys and filter each catalog independently; `miruro:1:1` is not `anilist:1:1`. No opaque Miruro IDs, saved show metadata or old playback sessions are mapped or reinterpreted.
|
||||||
|
|
||||||
|
The controls in the legacy table below also apply to AniList, except unknown-count series use `i` for explicit episode selection. Saved AniList shows reopen current native detail by ID. The display limit is 10,000 known catalog numbers; oversized counts report an error instead of truncating silently.
|
||||||
|
|
||||||
|
Read-only native smoke (does not launch playback):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run --locked --example anilist_probe
|
||||||
|
cargo test --locked
|
||||||
|
cargo run --locked -- --anilist "Cowboy Bebop"
|
||||||
|
```
|
||||||
|
|
||||||
|
The probe searches the real catalog, selects exact AniList ID 1, fetches detail, and emits the episode-1 identity/stable URL without checking a source. It does not prove playback. For a read-only provider smoke, run `terebi --check-source https://zokoanime.video/stream/ani/1/1/sub`; it prints one version-1 JSON line and does not launch the app. See `docs/anilist-direct-provider-feasibility.md` for historical native source/media evidence and outstanding AniList terms/distribution caveats.
|
||||||
|
|
||||||
|
### Miruro legacy browser
|
||||||
|
|
||||||
|
Choose **Miruro legacy** or use `--miruro`. This explicitly retained catalog has its own **catalog → series → returned episodes** session and saved-show library; it is never consulted for AniList browsing or playback.
|
||||||
|
|
||||||
|
Catalog and episode-metadata requests use the official Miruro 2.0 mirrors (`miruro.cx`, `.pw`, `.ws`, `.to`, `.tv`, `.bz`), not their legacy `www.` versions. Each session starts with `.cx`, remembers the last working mirror, and tries each mirror at most once when a host is unreachable, blocked, redirected, unavailable, or returns an unexpected Content-Type/unreadable body. Requests have a shared 25-second budget with at most five seconds per mirror; rate limits and other invalid/unauthorized requests stop immediately. No per-request randomization, directory scraping, cookies, or Cloudflare bypass is used. If every mirror is blocked, browsing still fails with the number of mirrors attempted and the last failure. Playback retains Terebi's existing `barelystarted.miruro.tv` URL contract; catalog failover does not add playback-mirror support.
|
||||||
|
|
||||||
|
Catalog autocomplete returns at most eight anime titles, without pagination. Titles show the available format, status, season/year and planned/listed episode counts; press `d` for cover art, a bold title, dimmed metadata and a synopsis excerpt, using the same info layout as YouTube. Press `d` again for the full scrollable synopsis and returned facts; another `d` returns to info, and Esc closes either mode. The series screen lists only actual returned episodes—not planned or absolute episode numbers—with titles, dates and durations when supplied. Selected episode details appear first, above the series synopsis and beside the series cover art in info mode. Listed counts do not guarantee a playable source; Terebi resolves playback.
|
||||||
|
|
||||||
|
| Key | Miruro action |
|
||||||
|
| --- | --- |
|
||||||
|
| `i` / `/` | Edit the catalog query; Enter searches, Esc cancels |
|
||||||
|
| `p` | Reopen the provider chooser in normal mode |
|
||||||
|
| `j` / `k`, ↓ / ↑; `g` / `G`, Home / End | Select a title or episode |
|
||||||
|
| Space / Esc | Check/uncheck the hovered episode / discard episode checks |
|
||||||
|
| Enter | Open a series, or play checked episodes in check order (hovered episode when none are checked) |
|
||||||
|
| Ctrl+Return | Append checked episodes, or the hovered episode, to a running Terebi queue (`video.enqueue`) |
|
||||||
|
| `y` | Copy the hovered episode's page URL (also in previews and offline collections; `video.copy_url`) |
|
||||||
|
| `-` / `+` | Back / forward through cached screens, preserving query and selection |
|
||||||
|
| `w` / `v` | Save the selected episode to watch later / saved episodes |
|
||||||
|
| `s` | Edit the selected show's category tags; Enter saves the show, including with no tags |
|
||||||
|
| `c` | Choose All saved shows or a category, then Enter opens a saved show’s episodes |
|
||||||
|
| `m` / `M` | Mark the selected episode as watching / watched (also in metadata) |
|
||||||
|
| `W` / `V` / `C` | Open Miruro's offline watch-later / saved / currently-watching episodes |
|
||||||
|
| `f` | Cycle All / Unwatched / Watched in watch later |
|
||||||
|
| `x` / `X` | Remove the selected / confirm removal of visible episodes from the active collection; `x` also removes a saved show from the library |
|
||||||
|
| `d` | Open info with cover art → full synopsis/metadata → info; ↓ / ↑, Ctrl-j / Ctrl-k and page keys scroll the full description; `j` / `k` change selection |
|
||||||
|
| Esc | Cancel input or close help/metadata and return to the episode/title list |
|
||||||
|
| `r` | Refresh the active screen and reload configured keys |
|
||||||
|
| `?` / `q` | Keyboard help / quit |
|
||||||
|
|
||||||
|
These actions reuse the existing `[keys]` configuration; live hints and help omit unsupported YouTube actions. Show saving/tagging uses `channel.tags`, and the library picker uses `category.browse`. From catalog results, a series screen, or the saved-show library, press `s` to edit the **show's** tags: `j` / `k` selects, Space checks/unchecks, `i` filters, and `n` enters a new tag name. Enter stages a new tag; Enter again from the tag list saves the show and all checked tags. Esc cancels without saving. A show can belong to multiple categories or remain saved without tags. The `c` picker starts in normal mode; `i` filters, Enter browses, and Esc leaves filtering before closing. Saved-show lists are alphabetical and stored offline; Enter fetches current episodes using the saved catalog ID and title. `x` removes only the show and its tags, leaving episode collections and watch status unchanged. Categories disappear when no saved shows use them. Miruro's show categories are independent of YouTube's channel tags.
|
||||||
|
|
||||||
|
Space checks categories for combined browsing; Enter opens their deduplicated union even when the filter hides them. With no checks, Enter opens the hovered category or All saved shows.
|
||||||
|
|
||||||
|
Only episodes can have their watch status edited. Reopening a saved episode passes its stable identity to Terebi for fresh resolution; identity is the opaque Miruro catalog ID plus its returned episode number. Each interface shows only its provider's collections, while playback, watched state and storage remain shared. Episode markers show saved/watch-later/currently-watching membership and watched status; Terebi owns resume positions.
|
||||||
|
|
||||||
|
Catalog results, series screens and saved-show lists use the returned `cover_url`; missing artwork leaves a text-only preview, and image failures do not hide metadata. Offline episode collections retain their stored text-only details. JPEG, PNG and WebP covers load, decode and resize off-thread, with native terminal graphics or proportional half-block rendering. The session image cache retains eight inactive images plus the current selection; a successful `r` refresh clears it.
|
||||||
|
|
||||||
|
Tubular owns catalog browsing, selection, collections and watched decisions. Terebi owns source resolution, stream/subtitle headers, subtitles, playback titles and optional timeline metadata. Miruro launches use `https://barelystarted.miruro.tv/watch/OPAQUE_ID/episode?ep=RETURNED_EPISODE`; `episode` is a non-identifying placeholder slug, not a title. No source file or collection migration is required. Current Terebi displays the generic title `Miruro episode N`; title enrichment and intro/outro bands in the controls progress bar remain Terebi work. No automatic skipping or skip buttons are requested. See the [Terebi URL-playback handoff](docs/terebi-url-playback-handoff.md) for canonical-route handling and acceptance scenarios; this is not a claim of implemented timeline UI.
|
||||||
|
|
||||||
|
|
||||||
|
## YouTube controls
|
||||||
|
|
||||||
|
These are the default bindings. Overrides in `[keys]` replace an action's bindings; the live hints and `?` reference show the configured keys.
|
||||||
|
|
||||||
|
### Normal mode
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
| --- | --- |
|
||||||
|
| `j` / `k`, ↓ / ↑ | Select the next / previous video |
|
||||||
|
| Space / Esc | Check/uncheck the hovered video / discard checks |
|
||||||
|
| Enter | Play checked videos in check order, or the hovered video when none are checked |
|
||||||
|
| Ctrl+Return | Append checked videos, or the hovered video, to a running Terebi queue (`video.enqueue`) |
|
||||||
|
| `p` | Reopen the provider chooser |
|
||||||
|
| `o` | Open the selected YouTube video in the default browser (also works in previews; configurable as `video.open`) |
|
||||||
|
| `y` | Copy the hovered video's page URL (also in previews and offline collections; `video.copy_url`) |
|
||||||
|
| `g` / Home, `G` / End | Select the first / last loaded video |
|
||||||
|
| `i` | Edit the current category/combined feed search; other views use general YouTube Search |
|
||||||
|
| `/` | Go directly to general YouTube Search |
|
||||||
|
| `F` (Shift-F) | Go directly to the subscription Feed |
|
||||||
|
| `d` | Open video info; press again for the full description, again to return to info |
|
||||||
|
| Esc | Close a preview first; otherwise discard multi-selection without navigating |
|
||||||
|
| `-` | Return to the previous view |
|
||||||
|
| `+` | Go forward after navigating back |
|
||||||
|
| `c` | Browse saved categories |
|
||||||
|
| `u` | Browse an individual saved channel |
|
||||||
|
| `U` (Shift-U) | Open the selected video's channel |
|
||||||
|
| `L` (Shift-L, in a channel view) | Browse that channel's public playlists |
|
||||||
|
| `s` | Edit the selected channel's tags |
|
||||||
|
| `S` | Edit tags for all saved channels in the current category |
|
||||||
|
| `R` (Shift-R) | Rename the current category, including category-scoped collection views |
|
||||||
|
| `P` (Shift-P) | Save the current combined-category feed as a new category |
|
||||||
|
| `w` | Save the selected video to watch later (also works in previews) |
|
||||||
|
| `W` | Choose combined or category watch later from any video view or preview |
|
||||||
|
| `Alt-w` | Open the current category's watch later |
|
||||||
|
| `f` | In watch later, cycle All → Unwatched → Watched |
|
||||||
|
| `x` | In watch-later, saved-video, or currently-watching lists, remove the selected video |
|
||||||
|
| `X` (Shift-X) | In collection lists, confirm bulk removal of the current list/category/filter subset |
|
||||||
|
| `v` | Save the selected video to saved videos (also works in previews) |
|
||||||
|
| `V` | Choose combined or category saved videos from any video view or preview |
|
||||||
|
| `Alt-v` | Open the current category's saved videos |
|
||||||
|
| `C` (Shift-C) | Open the combined Currently watching list |
|
||||||
|
| `m` / `M` | Mark the selected video as watching / watched (also works in previews) |
|
||||||
|
| Tab / Shift-Tab | Switch between visible views |
|
||||||
|
| `r` | Reload configuration and refresh the current view |
|
||||||
|
| `?` | Open / close the scrollable keybinding reference |
|
||||||
|
| `q` | Quit |
|
||||||
|
|
||||||
|
**Ctrl-C quits from any mode or popup.**
|
||||||
|
|
||||||
|
URL copying always uses the highlighted row, not the playback checks, and copies the same canonical page URL used for playback—not an expiring stream URL. It uses the terminal's OSC 52 clipboard support without external clipboard tools. Clipboard writes must be supported and enabled by your terminal and any multiplexer; Tubular can report sending the URL, but cannot confirm that the terminal accepted it. Text inputs and selectors keep `y` as ordinary input or ignore it.
|
||||||
|
|
||||||
|
The bottom bar shows essential actions for the current context. Press `?` in normal mode—including selectors and previews—for the full reference. Scroll help with `j` / `k`, arrows, Ctrl-J / Ctrl-K, or PageDown / PageUp; `?` or Esc closes it without changing the underlying view or dialog. In text fields, `?` remains ordinary input.
|
||||||
|
The overview groups shortcut/action rows under bold section headers such as `VIEWS AND VIDEOS`, with an empty line beneath each header. Shortcuts and action descriptions remain normal weight; the rows reflect configured bindings rather than showing internal action paths.
|
||||||
|
|
||||||
|
Back and forward navigation restore cached videos, selection, and pagination, including replaced temporary tabs. Removed views are skipped; empty history leaves the current view unchanged. Opening a different view after going back discards the old forward branch. Esc first closes a dialog or leaves insert mode without navigating. Minus and plus remain ordinary text inside input fields.
|
||||||
|
|
||||||
|
The full-description view scrolls with ↓ / ↑, Ctrl-J / Ctrl-K, or PageDown / PageUp. Plain `j` / `k` still changes the selected video. The bold title, dimmed author/publication row, and description scroll together.
|
||||||
|
|
||||||
|
### Text input
|
||||||
|
|
||||||
|
These shortcuts work in search, category/channel/tag-filter, new-tag, and category-rename fields:
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
| --- | --- |
|
||||||
|
| Enter | Submit the search, browse the selected category/channel, leave tag-filter mode, stage a new tag, or commit a rename |
|
||||||
|
| Esc | Leave insert mode or abandon the new-tag / rename prompt |
|
||||||
|
| Backspace | Delete one character |
|
||||||
|
| Option/Alt-Backspace or Ctrl-W | Delete the previous whitespace-delimited word |
|
||||||
|
| Cmd/Super-Backspace or Ctrl-U | Clear the line |
|
||||||
|
|
||||||
|
If the terminal intercepts modified Backspace rather than transmitting it, use Ctrl-W / Ctrl-U or configure equivalent terminal key mappings. Input fields show a block cursor.
|
||||||
|
|
||||||
|
In terminals supporting bracketed paste, clipboard text is inserted in one operation into the active search, filter, or new-tag field; filters update once for the whole paste. Pasting never submits input or triggers shortcuts. Line breaks and tabs become spaces, other control characters are removed, and pastes outside text-entry mode are ignored. Bracketed-paste mode is disabled again when Tubular exits. Terminals without bracketed-paste support still deliver pasted text as ordinary keystrokes.
|
||||||
|
|
||||||
|
### Category browser (`c`)
|
||||||
|
|
||||||
|
- **Normal:** `j` / `k` or arrow keys navigate; Space checks/unchecks a category; `i` enters filter mode; Enter browses checked categories, or the hovered category if none are checked; `p` toggles its persisted pin; `R` renames it; Esc closes.
|
||||||
|
- **Insert:** type a case-insensitive substring to filter; arrow keys or Ctrl-J / Ctrl-K navigate matches; Enter browses the chosen categories; Esc returns to normal mode without clearing the filter. Space is ordinary input here.
|
||||||
|
- Checkboxes select categories for browsing; the dimmed `p` suffix marks pins independently. Hidden checks survive filtering. Enter still browses checked categories when there are no visible matches; without checks, an empty result makes browsing a no-op. Pinning, renaming, and toggling require a visible selected row.
|
||||||
|
|
||||||
|
### Category search
|
||||||
|
|
||||||
|
Press `i` in a category or combined-category feed to search only its channels' histories—not just already loaded videos. Enter submits a changed query and returns to normal mode; an unchanged query keeps cached results and pagination. Clear the field and submit to restore the ordinary channel catalog. Esc leaves the draft unsubmitted. Scoped queries still use YouTube's channel-search endpoint, which can omit music release tracks even when unfiltered browsing includes them.
|
||||||
|
|
||||||
|
Each category and combined feed retains its own draft, submitted query, results, and pagination independently of general Search. Subsequent pages use the same scoped query and shared 60-video budget. Empty categories and failed scoped requests never fall back to global search. Search input in other views still opens general YouTube Search.
|
||||||
|
|
||||||
|
The direct Search and Feed jumps use the configurable `view.search` and `view.feed` actions. They preserve cached views and history, and do not run inside text inputs or selectors.
|
||||||
|
|
||||||
|
### Combined category feeds
|
||||||
|
|
||||||
|
Check multiple categories in `c`, then press Enter to open their combined feed. One checked category opens its ordinary view; no checks retain the usual single-category shortcut. Esc discards the selector's staged choices. Reopening `c` from a combined feed prechecks its member categories.
|
||||||
|
|
||||||
|
The combined feed uses the shared temporary tab, replacing the prior unpinned category/channel/collection view. Channels and video IDs are deduplicated, and videos use the same newest-first ordering and infinite scrolling as other feeds. Back/forward navigation restores its query, cached rows, selection, and pagination.
|
||||||
|
|
||||||
|
Press `P` to save the viewed union of channels under a new category name. Enter commits; Esc cancels; duplicate or invalid names leave the prompt open without overwriting anything. The new flat category becomes active without automatic pinning or re-fetching matching cached pages. Its memberships are an independent copy, not links to the original categories.
|
||||||
|
|
||||||
|
Renaming a member category updates the combined view and its history. Membership changes recompute the channel union, preserving cached rows when the resolved union is unchanged; deleting a member invalidates the combined view rather than silently widening it. Explicit refresh still reloads the active view from page one.
|
||||||
|
|
||||||
|
Combined feeds are not individual categories: `S`, `R`, and `Alt-w` / `Alt-v` require a single category. Save with `P` first to use those actions. Configure multi-selection with `selector.toggle` and saving with `category.save`; name entry uses the shared `input.*` controls.
|
||||||
|
|
||||||
|
### Saved channels (`u`)
|
||||||
|
|
||||||
|
- **Normal:** `j` / `k` or arrow keys navigate; `i` enters filter mode; Enter opens the selected channel's videos; Esc closes.
|
||||||
|
- **Insert:** type a case-insensitive substring to filter; arrow keys or Ctrl-J / Ctrl-K navigate matches; Enter browses; Esc returns to normal mode without clearing the filter.
|
||||||
|
- The list includes registered channels, including untagged ones, and channels saved only in categories. Aliases of the same channel appear once, using a mapped handle when available; otherwise the saved channel identity is shown.
|
||||||
|
- Channels and unpinned categories share **one temporary browsing tab**. Choosing either replaces the previous temporary view; pinned category tabs stay visible. Revisiting or navigating back restores cached videos, selection, and pagination.
|
||||||
|
- Channel selection is single-choice and does not pin channels or change subscriptions. `s` still edits the selected video's channel tags; bulk `S` remains category-only.
|
||||||
|
|
||||||
|
Press **`U` (Shift-U)** on a video to browse its creator directly, including channels you have not saved. This replaces the current temporary browsing tab without adding a subscription. Channel IDs are preferred; a valid handle is used when an ID is unavailable. Missing channel metadata leaves the current view unchanged. Unsaved channel views survive configuration changes for the session; explicitly removing a saved subscription still removes its channel view.
|
||||||
|
|
||||||
|
### Channel playlists (`L`)
|
||||||
|
|
||||||
|
Open a channel with `u` or `U`, then press **`L` (Shift-L)** in normal mode. Its public playlists load into a searchable selector: `j` / `k` or arrows move, `i` filters by title, and Enter opens the selected playlist. In filter mode, arrows or Ctrl-J / Ctrl-K move; Esc returns to normal mode, then closes. Closing cancels an in-flight playlist lookup; close and reopen to retry a failed lookup. Configure the shortcut with `channel.playlists`.
|
||||||
|
|
||||||
|
Playlist videos use the ordinary video table, 60-row pagination, playback checks, previews, and collection controls. They retain playlist order rather than sorting newest-first; repeated video IDs appear once, like other video views. Playlist views share the temporary tab without changing subscriptions. `-` returns to the channel and `+` restores the playlist's cached rows and selection. `r` refreshes the playlist from page one.
|
||||||
|
|
||||||
|
### Channel tags (`s`, or `S` for bulk editing)
|
||||||
|
|
||||||
|
When search results omit a channel handle, `s` reuses its saved mapping or looks up the handle from the channel's canonical ID before opening the editor. Esc cancels a pending lookup without saving. Failed lookups or mismatched channel metadata leave the config unchanged.
|
||||||
|
|
||||||
|
Tubular validates handles as channel addresses, not against [YouTube's current handle-creation guidelines](https://support.google.com/youtube/answer/11585688). Existing handles such as `@hontame_` keep their leading/trailing separators and round-trip through saved mappings and categories. Whitespace inside handles, control characters, slashes, query strings, and fragments remain rejected.
|
||||||
|
|
||||||
|
- **Normal:** `j` / `k` or arrow keys navigate; Space toggles a tag; `i` edits the filter; `R` renames the selected tag.
|
||||||
|
- **Insert:** type a case-insensitive substring to filter live; arrow keys or Ctrl-J / Ctrl-K navigate matches. Enter or Esc returns to normal mode without saving or clearing the filter.
|
||||||
|
- Filtering only hides rows: checked and unchecked staged memberships outside the filter are retained. With no matches, navigation and toggling do nothing. Reopening the dialog clears its filter.
|
||||||
|
- `n` prompts for a new tag name. Enter stages it checked, clears the filter, and selects the new tag; Esc abandons the name.
|
||||||
|
- Enter **in the normal-mode tag list** saves all staged memberships, including hidden tags. Esc cancels those staged changes; it does not undo a committed rename of an existing category. These controls apply to both single-channel and bulk tagging.
|
||||||
|
- Filter actions are configurable as `tags.edit`, `tags.input.next`, and `tags.input.previous`; text editing uses the shared `input.*` actions.
|
||||||
|
|
||||||
|
Bulk editing applies the exact checked tag set to **every saved channel in the current category**, not only channels with loaded videos. Tags shared by every target start checked; mixed memberships start unchecked. Review the checked set before saving: unchecked tags are removed from all targets. Other channels, saved registrations, empty categories, pins, and comments are preserved.
|
||||||
|
|
||||||
|
### Renaming categories (`R`)
|
||||||
|
|
||||||
|
In normal mode, press `R` (Shift-R) to rename the active category, or the hovered category in a category/collection selector or single-channel/bulk tag list. Combined collection entries and channel rows cannot be renamed.
|
||||||
|
|
||||||
|
The floating prompt starts with the current name. Edit it with the shared text controls (`Ctrl-U` clears it), then press Enter to commit or Esc to cancel. Names are trimmed and must be nonempty, free of control characters, and distinct from existing or staged category names. Errors leave the prompt open without changing the file. If the source category changed externally, close dialogs and reload config with `r` before retrying.
|
||||||
|
|
||||||
|
Renaming an existing category saves its identity immediately while preserving channel memberships, pins, comments, cached videos, selection, pagination, watch filters, and back/forward history. In tag dialogs, checkbox changes remain staged until the main tag-list Enter; cancelling them does not undo the rename. A newly staged, unsaved tag stays local when renamed and reaches disk only when the tags are saved.
|
||||||
|
|
||||||
|
Configure the shortcut with `category.rename`; the prompt uses the shared `input.*` actions. `R` remains ordinary text while editing search or filters.
|
||||||
|
|
||||||
|
## Subscriptions and feeds
|
||||||
|
|
||||||
|
Configuration lives at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$XDG_CONFIG_HOME/tubular/config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
If `XDG_CONFIG_HOME` is unset, empty, or relative, the fallback is `~/.config/tubular/config.toml`. Edit the file directly and press `r` to reload, or save memberships from the tag dialog.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[settings]
|
||||||
|
pinned_categories = ["Favorites"]
|
||||||
|
|
||||||
|
[categories]
|
||||||
|
Creators = ["@jawed"]
|
||||||
|
Favorites = ["@jawed"]
|
||||||
|
|
||||||
|
[channels]
|
||||||
|
"@jawed" = "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A"
|
||||||
|
```
|
||||||
|
|
||||||
|
`[categories]` contains **flat, overlapping tags**. A channel can belong to several categories; there is no hierarchy or inheritance. `[channels]` maps readable handles to stable channel URLs or IDs. Saving a channel through the UI records its handle and mapping when metadata is available.
|
||||||
|
|
||||||
|
`[settings]` holds global options such as `pinned_categories`. The `[settings]`, `[channels]`, `[categories]`, and `[keys]` sections can appear in any order; pin/unpin and tag edits preserve the existing section order, key overrides, and other preferences. For older configs, add `[settings]` immediately above `pinned_categories`. A top-level declaration is no longer accepted.
|
||||||
|
|
||||||
|
Category entries accept `@handles`, HTTPS YouTube channel URLs, or canonical `UC…` channel IDs. Mapped handles use their stable identity; unmapped handles work directly.
|
||||||
|
|
||||||
|
Video tables color channel names **cyan** when the creator belongs to at least one category. Untagged registrations keep the default text color; selected rows retain their black-on-cyan highlight. Membership is matched by channel ID or handle, not the displayed name, and updates after saving tags or reloading config.
|
||||||
|
|
||||||
|
- **Search** and **Feed** are permanent YouTube tabs. Feed combines all YouTube `[channels]` registrations, including untagged channels, plus channels entered directly in categories, without duplicates.
|
||||||
|
- Browsing an unpinned category, combined-category feed, channel, watch-later list, or saved-video list uses one shared temporary tab; choosing another replaces it. Pinned categories keep their own tabs, marked with a dimmed `p`.
|
||||||
|
- Removing a channel from a category does **not** unsubscribe it from Feed. To unsubscribe, remove its `[channels]` mappings and category references, then press `r`.
|
||||||
|
- Temporary tabs do not survive restarting; saved categories and pins do. Hidden views retain loaded rows, selection, and pagination during the session.
|
||||||
|
|
||||||
|
Bulk editing refuses to clear all tags when a target has no `[channels]` registration, preventing accidental unsubscription of category-only channels. If source memberships change while a bulk dialog is open, close and reopen it before saving.
|
||||||
|
|
||||||
|
## Watch later
|
||||||
|
|
||||||
|
Watch later is **one stored collection**, deduplicated by YouTube video ID or exact provider-qualified AniList/Miruro episode identity, with separate provider views. YouTube category lists still derive from channel identities and current subscription categories; anime uses flat provider-local episode lists. No category memberships are stored on individual videos.
|
||||||
|
|
||||||
|
In YouTube, `W` opens a searchable collection selector from any video view or preview. The All categories (combined) entry comes first, followed by configured categories; it remains available even when there are no categories. Navigate with `j` / `k` or arrows in normal mode, or press `i` to filter by name and use arrows / `Ctrl-j` / `Ctrl-k` while typing. `Enter` browses the selected match; with no matches it does nothing. `Esc` returns to normal mode, then closes the selector without navigating. Reopening clears the search. `Alt-w` still opens the current category's watch later directly.
|
||||||
|
|
||||||
|
Moving between watch-later scopes carries the current All/Unwatched/Watched filter to the destination, including cached lists; back navigation restores the originating category list. Choosing the current list does not add history. The border title shows the active filter. Lists use saved title, creator, and publication metadata without fetching video lists, so they also work offline.
|
||||||
|
|
||||||
|
Watch-later-only titles are **magenta** in all video tables; categorized channel names remain cyan. A `✓` marks watched videos, including outside watch-later views. The selected row retains its usual highlight.
|
||||||
|
|
||||||
|
### Watch status
|
||||||
|
|
||||||
|
Terebi reports acknowledged playback positions. A video becomes watched when an eligible position reaches **95% of a known positive duration**. Seeking near the end counts; live/non-seekable, unknown-duration, and audio-only inputs do not. Launcher exit or window closure alone never marks a video watched. Watched status is global across categories and remains after removing a video from watch later.
|
||||||
|
|
||||||
|
Press `m` to mark the selected video/episode **watching**: clear its watched flag and add it to Currently watching with offline metadata. Press `M` to mark it **watched**: set the watched flag and remove it from Currently watching. Both work in lists and previews/metadata, persist across restarts, and immediately update watch-later filters and markers without changing watch-later or saved membership. Miruro catalog titles are not editable. Configure `video.status.watching` and `video.status.watched` to remap or disable these actions.
|
||||||
|
|
||||||
|
Manual edits take precedence over reports from existing bound playback sessions, including reports recovered after restart. Start a new playback session to resume automatic status updates. Editing watch status does not change Terebi's resume position.
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
Files are siblings of `config.toml`:
|
||||||
|
|
||||||
|
- `watch-later.toml`: versioned saved-video metadata.
|
||||||
|
- `saved-videos.toml`: independent versioned saved-video metadata.
|
||||||
|
- `currently-watching.toml`: versioned metadata for unfinished videos, added by playback or marked manually.
|
||||||
|
- `watch-state.toml`: versioned global watched video IDs.
|
||||||
|
- `miruro-library.toml`: versioned saved-show catalog metadata and overlapping category tags, independent of episode collections.
|
||||||
|
- `playback-sessions/`: private per-video progress streams, expected identities, metadata snapshots, durable admission/manual-edit markers, and ordered `queue.json` manifests for multi-video launches.
|
||||||
|
|
||||||
|
State writes are atomic and owner-only. Invalid state files or detected external edits are reported rather than overwritten; repair the file or restart before retrying. Each state file is limited to 8 MiB.
|
||||||
|
|
||||||
|
Progress sessions survive Tubular exit so later player reports can be recovered on the next launch. Session directories are `0700`; snapshots, identity markers and Terebi-created reports are owner-only. New Miruro launches contain no resolved-source documents or CDN credentials. Existing sessions from earlier versions remain readable and are not automatically removed. Sessions are retained because CLI exit does not prove the independent GUI has finished; up to 4,096 are monitored. Close the corresponding players before removing their inactive session directories, then restart Tubular. Never remove an active player's directory.
|
||||||
|
|
||||||
|
Progress polling keeps byte offsets and partial records in memory, opening and closing each report for each poll. Historical sessions do not retain open report descriptors; late writes from running players are still consumed.
|
||||||
|
|
||||||
|
## Saved videos
|
||||||
|
|
||||||
|
Saved videos is a separate stored collection from watch later, deduplicated by YouTube video ID or stable Miruro episode identity, with provider-specific views. It uses offline saved metadata; YouTube entries follow **current channel category memberships**, not categories stored on individual videos. Its All categories (combined) list contains only YouTube videos; Miruro opens a flat saved-episode list.
|
||||||
|
|
||||||
|
In YouTube, `V` opens the same searchable selector for saved videos, with **All categories (combined)** first and configured categories beneath it. `Alt-v` opens the current category's saved videos directly. `Alt-w` / `Alt-v` retain the explicit category when switching between category-scoped collection lists; without a current category, they leave the view unchanged. All four shortcuts work in normal mode and previews, not text-entry fields or modal selectors. Both collections share the existing temporary-tab and back/forward history behavior.
|
||||||
|
|
||||||
|
Saved lists have no watched filter. Individual and bulk removal use the shared collection-list controls below.
|
||||||
|
|
||||||
|
Saved titles are **green**, taking precedence over watch-later magenta when a video belongs to both collections. Removing it from saved videos restores magenta if it remains in watch later. Channel styling, selected-row highlighting, and watched markers are unchanged.
|
||||||
|
|
||||||
|
The collection is persisted independently in `saved-videos.toml` beside `config.toml`, using the same atomic, owner-only writes and validation as watch later. Saving repeatedly does not add duplicates. Configure saving and the collection selector with `video.saved.save` and `view.saved.browse`; direct category browsing uses `view.saved.category`. The corresponding watch-later navigation actions are `view.watch_later.browse` and `view.watch_later.category`. Selectors use the shared `selector.*` and `input.*` bindings. The live hints and `?` reference reflect overrides. Alt shortcuts require the terminal to transmit Alt/Meta; if macOS Option inserts symbols, enable Option-as-Meta or remap the actions.
|
||||||
|
|
||||||
|
## Currently watching
|
||||||
|
|
||||||
|
`C` opens an offline list of the current provider's unfinished videos/episodes, started through playback or marked watching with `m`. YouTube uses its shared temporary tab; Miruro uses a cached episode-list screen. Both support back/forward navigation; configure the shortcut with `view.watching.browse`.
|
||||||
|
|
||||||
|
A validated, acknowledged **positive playback position** automatically adds the video—not merely launching Terebi or receiving position zero. Reaching the eligible **95% watched threshold**, or marking it watched with `M`, removes it. Already-watched videos do not enter automatically; `m` explicitly clears watched status and adds them. Unknown-duration or ineligible playback can appear but stays unfinished unless a later eligible report marks it watched, you mark it watched, or you remove it.
|
||||||
|
|
||||||
|
Entries and their title/creator/publication metadata survive restarts. Reports written while Tubular is closed are recovered on the next launch. Older sessions without video snapshots can populate the list when matching watch-later/saved metadata exists; otherwise they continue updating watched status without inventing missing metadata.
|
||||||
|
|
||||||
|
Use `x` for individual removal or `X` for bulk confirmation. Removal neither marks the video watched nor changes watch later/saved videos. Continued reports from existing sessions cannot re-add a removed entry, including after restart; deliberately starting a new session can re-add it if still unfinished.
|
||||||
|
|
||||||
|
## Collection removal
|
||||||
|
|
||||||
|
Episode removal controls apply **only in watch-later, saved-video, and currently-watching views**, including previews opened from those lists. The Miruro saved-show library additionally supports `x` to remove a show and its tags, without removing episodes or watch status. Search, Feed, Miruro catalog/episode browsing, ordinary YouTube channel/category views, and their previews do not expose removal actions or hints. Text fields and selectors retain their normal controls.
|
||||||
|
|
||||||
|
`x` immediately removes only the selected video/episode from the active collection. `X` snapshots the current provider's visible list and opens bulk confirmation: YouTube watch later uses the current category and All/Unwatched/Watched filter, and saved videos uses the current category's entire saved list; Miruro uses its flat list and any active watch-later filter. Currently watching uses the current provider's entire list. An empty list does not open confirmation. Neither interface's removal controls touch the other provider's entries.
|
||||||
|
|
||||||
|
The YouTube prompt names the collection, category, count and applicable filter; Miruro names the collection and episode count, with the active filter in the screen header. **`y` confirms; `n` or Esc cancels.** Miruro also accepts Enter to confirm. Only the snapshotted IDs are removed, even if membership or watched status changes while confirmation is open.
|
||||||
|
|
||||||
|
Removal changes only the active collection. Matching views update, while other collections and global watched status remain untouched.
|
||||||
|
|
||||||
|
The old collection-specific removal settings are no longer accepted. Replace them with:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[keys]
|
||||||
|
view.collection.remove.selected = "x"
|
||||||
|
view.collection.remove.bulk = "X"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configurable keybindings
|
||||||
|
|
||||||
|
Print a complete default configuration, including every action and its default bindings:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tubular --default-config
|
||||||
|
```
|
||||||
|
|
||||||
|
This prints TOML to stdout and exits without reading or writing config, starting the TUI, or needing `yt-dlp`, Terebi, or a terminal. Copy the settings you want into your config; `r` reloads them.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[keys]
|
||||||
|
video.info.overview = "I"
|
||||||
|
video.info.description = "d"
|
||||||
|
video.info.hide = ["escape", "backspace"]
|
||||||
|
view.history.previous = "-"
|
||||||
|
view.history.next = "+"
|
||||||
|
video.play = ["enter", "ctrl+p"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Each string or array **replaces** that action's default aliases; omitted actions keep their defaults, and `[]` disables an action. Dotted assignments, corresponding subtables such as `[keys.video.info]`, and quoted action names are supported. Unknown actions, invalid chords, duplicate action declarations, and ambiguous bindings in the same context are rejected before saving.
|
||||||
|
|
||||||
|
Characters are case-sensitive (`i` differs from `I`). Named keys are case-insensitive: `escape`, `enter`, `tab`, `space`, arrows, `home`, `end`, `pageup`, `pagedown`, `backspace`, `delete`, and `f1`–`f24`. Combine modifiers with `+`, for example `ctrl+j`, `alt+backspace`, `cmd+backspace`, or `shift+tab`. `control`, `option`, and `super` are accepted aliases; `+` itself, or `ctrl++`, can be bound. The terminal must transmit the chord for it to work.
|
||||||
|
|
||||||
|
Using the same binding for overview and description preserves the default cycle. Distinct bindings, as above, open either view directly. Preview close takes precedence over history back; description scrolling takes precedence over video selection for shared bindings. View commands do not run inside text inputs or selectors; those have their own actions listed by `--default-config`.
|
||||||
|
|
||||||
|
## Loading and previews
|
||||||
|
|
||||||
|
General Search loads up to 60 results per page. Feed and category views, including scoped category searches, share a **60-video total request budget per load**, distributed fairly across active channels—not 60 per channel. Each channel retains its own offset; exhausted channels stop receiving requests, and feeds with more than 60 channels rotate through them across loads. Matching upload/search ranges are batched across at most four concurrent yt-dlp processes, with further batches queued. Release discovery and album extraction use the same four-process limit. Individual channel views can use the full 60-row budget.
|
||||||
|
|
||||||
|
Unfiltered channel, Feed, and category browsing traverses each channel's uploads first, then the tracks in its **Releases** albums. Album tracks—not album links—consume the video budget; repeated tracks are deduplicated in the loaded results without refunding their raw-row quota. This includes artist catalogs whose music appears in general Search but not the channel's Videos tab. Channels without a Releases tab keep their uploads normally; other discovery or album failures remain page errors. Later pages re-traverse earlier releases to recover their track offsets, so large artist archives can take longer to load.
|
||||||
|
|
||||||
|
In normal mode, forward video navigation authorizes prefetch when 20 loaded rows remain. Each successful page clears that intent, so short, duplicate-only, or unavailable batches do not immediately chain additional loads at startup. Only one page request runs at a time; insert mode and open popups pause automatic prefetch. A feed page is accepted only after every batch succeeds. Switching views cancels running and queued batches; returning resumes the same unaccepted per-channel ranges. Deep channel archives can get slower because yt-dlp offsets traverse earlier pages again.
|
||||||
|
|
||||||
|
Malformed individual entries—including invalid video IDs, missing required fields, and wrong field types—are discarded without losing valid videos from the same page. Raw entries, including skipped and duplicate rows, consume the request budget and determine each channel's exhaustion; a full unavailable batch therefore does not hide later results. Invalid JSON, malformed playlist-level metadata, missing/extra playlists, oversized responses, and extractor failures still fail the page without advancing channel offsets.
|
||||||
|
|
||||||
|
YouTube Search, feeds, category searches, and channel browsing hide videos that yt-dlp marks `subscriber_only` (members-only). Public, unlisted, and unknown-availability videos remain visible; titles or channel membership promotions are not used to infer restrictions. Filtered rows still count toward pagination, and offline saved collections are unchanged.
|
||||||
|
|
||||||
|
Feed views sort all loaded videos newest-first using available upload dates, with undated videos last. The budget limits what has been inspected: later loads may discover newer videos and insert them above older rows, while selection and pending description requests stay attached to the same video ID. Publication labels use `YYYY-MM-DD`; `~` marks an estimated UTC date, and `Unknown` means unavailable metadata.
|
||||||
|
|
||||||
|
Pane titles show animated spinners while content loads. Refresh keeps the current rows and selection visible until a valid first page replaces them, including when the new result is empty. Errors retain loaded rows and stop automatic retries; `r` refreshes the current view from page one.
|
||||||
|
|
||||||
|
Video info shows a thumbnail, title, author/publication details, and a clipped description excerpt. Press `d` again to read the full description in the same popup. Thumbnails and full descriptions are cached in memory across selections and views: 64 full descriptions and eight inactive decoded thumbnails, plus the selected image. Caches last for the session; a successful refresh clears them. Preview loading, decoding, and image resizing run asynchronously.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
[Miruro API notes](docs/miruro-api.md) document the native catalog-only `tubular::miruro` wrapper, stable episode/progress contracts and historical source/CDN research. The [Terebi URL-playback handoff](docs/terebi-url-playback-handoff.md) defines the playback interface and remaining title/timeline work.
|
||||||
|
|
||||||
|
Unit tests live in `tests/unit/`, grouped by source module and loaded with `#[cfg(test)]` and `#[path]` so they retain access to private implementation details. Cargo runs them with the commands below; they are not separate integration-test crates.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo fmt --check
|
||||||
|
cargo test --locked
|
||||||
|
cargo clippy --locked --all-targets -- -D warnings
|
||||||
|
```
|
||||||
|
|
||||||
|
Read-only native Miruro smoke (search → detail/episodes):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run --locked --example miruro_probe
|
||||||
|
```
|
||||||
|
|
||||||
|
This uses the documented research title and stops on errors/protection pages. The sample can become unavailable; passing it proves catalog retrieval, not Terebi playback. Earlier source/HLS/subtitle probe results remain historical evidence in the API notes.
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
- [x] bulk category editing: when on a certain category view, add bulk category editing, such that all channels saved to that category are updated with the new categories set.
|
|
||||||
- [x] ui improvement: display the pinned status on category tab titles for pinned categories
|
|
||||||
- [x] start the terebi player as a new instance explicitly:
|
|
||||||
```sh
|
|
||||||
terebi --new-instance
|
|
||||||
terebi --new-instance "/path/to/video.mp4"
|
|
||||||
terebi --new-instance --url 'https://youtu.be/jNQXAC9IVRw'
|
|
||||||
```
|
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# AniList catalog and direct streaming feasibility
|
||||||
|
|
||||||
|
Observed **2026-10-25**. Research only: no application/player implementation, browser automation, GUI playback, protection bypass, remote mutation, or persisted stream URLs. This investigation follows the proposed AniList catalog → independently resolved episode path, not another Miruro mirror change.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**The catalog-to-native-media path works for a sample without Miruro.** AniList's native GraphQL interface returned catalog metadata, and ZokoAnime accepted both the returned MAL ID and its working AniList-ID route. Fresh HLS and English subtitles were retrieved; FFmpeg decoded a video frame at the start and after seeking to ten minutes.
|
||||||
|
|
||||||
|
**This is not yet a drop-in Terebi integration.** The sampled playlist crosses CDN origins that Terebi deliberately rejects, and no authoritative ZokoAnime episode-inventory interface was found in the inspected documentation/selector. Do not replace the browsing UI, synthesize available episodes from AniList totals, or disable the player's origin checks on the strength of this sample.
|
||||||
|
|
||||||
|
Evidence labels: **Observed** = executed bounded native requests/media probes; **Source** = inspected first-party documentation/JavaScript or maintained extractor code; **[INFERENCE]** = recommendation or implication not exercised in Tubular/Terebi.
|
||||||
|
|
||||||
|
## Discovery and exact identity mapping
|
||||||
|
|
||||||
|
**Source:** current [ani-cli master][ani-cli] identifies itself as version 5.1.6 and uses `hianime.at`, not the historical AllAnime interface. Its search/episode/server flow selects a ZokoAnime embed and reads its static source configuration. This is extractor-source evidence, not a recommendation to execute ani-cli or use its browser User-Agent/cipher configuration.
|
||||||
|
|
||||||
|
**Observed:** default native `ureq` search for Cowboy Bebop returned separate series, movie and special entries. Selecting the exact TV-series entry yielded 26 actual episode links. Episode 1's declared sub server pointed to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://zokoanime.video/stream/mal/1/1/sub
|
||||||
|
```
|
||||||
|
|
||||||
|
The [ZokoAnime site][zoko] and [documentation][zoko-docs] advertise MAL/AniList-ID embeds. Their deployed [selector implementation][selector] emits source values `mal` and **`ani`**, whereas the [documentation implementation][docs-js] describes `anilist`.
|
||||||
|
|
||||||
|
**Observed discrepancy:** `/stream/anilist/1/1/sub` returned HTTP 404 JSON, `Route GET:/stream/anilist/1/1/sub not found`. `/stream/ani/1/1/sub` returned a playable configuration. Use the demonstrated `ani` route or AniList's `idMal`, not the prose's broken `anilist` route.
|
||||||
|
|
||||||
|
AniList's [media interface][media] exposes both identifiers. The exact first catalog query was:
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
query {
|
||||||
|
Media(id: 1, type: ANIME) {
|
||||||
|
id
|
||||||
|
idMal
|
||||||
|
title { romaji english }
|
||||||
|
format
|
||||||
|
status
|
||||||
|
episodes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Observed:** unauthenticated POST `https://graphql.anilist.co` returned HTTP 200 `application/json`, with AniList ID 1, MAL ID 1, title Cowboy Bebop, format TV, status FINISHED and total 26. After this query, the direct ZokoAnime lookup used only the returned identifier; no Miruro or HiAnime request participated in that lookup.
|
||||||
|
|
||||||
|
A second native GraphQL query established the split-season and movie identifiers below. Route success means a source configuration was returned, not that every episode or the semantic content was visually verified.
|
||||||
|
|
||||||
|
| Catalog entry | AniList ID | MAL ID | Catalog total | Direct lookup observed |
|
||||||
|
| --- | ---: | ---: | ---: | --- |
|
||||||
|
| Cowboy Bebop | 1 | 1 | 26 | `ani` and `mal`, episode 1: source + English subtitle; `ani`, episode 26: configuration present |
|
||||||
|
| Attack on Titan Season 3 Part 2 | 104578 | 38524 | 10 | `ani`, episode 1: source + English/Spanish/Portuguese subtitles |
|
||||||
|
| Cowboy Bebop: The Movie | 5 | 5 | 1 | `ani`, episode 1: configuration present |
|
||||||
|
|
||||||
|
**[INFERENCE]:** no external mapping dataset or title matching is needed for these routes. [Fribb/anime-lists][fribb] offers catalog-ID and TVDB/TMDB season/offset mappings, but it is unnecessary for this demonstrated direct-ID path and does not establish ZokoAnime's episode availability. No dataset was downloaded/adopted or its license relied on.
|
||||||
|
|
||||||
|
## Native request and media evidence
|
||||||
|
|
||||||
|
The temporary Rust probe used the project's existing `ureq 3.4.2`/rustls transport, normal certificate verification, default User-Agent, a 20-second global timeout, zero automatic redirects and a 2 MiB text-body limit. No cookies, authentication, browser impersonation, cipher tuning, challenge solution or proxy was supplied. Resource requests used `Referer: https://zokoanime.video/`; whether that header is mandatory was not tested.
|
||||||
|
|
||||||
|
Bodies were captured in memory; only metadata and redacted summaries were surfaced. Player JavaScript was inspected, never executed. The embed's `window.__P` configuration uses the Base64/XOR JSON transform explicitly implemented in [its own decoder][obfuscate]; no DRM, challenge or ad-verification protocol was implemented. Advertising scripts and view/telemetry actions were not requested.
|
||||||
|
|
||||||
|
| Resource | HTTP | Content-Type | Body bytes / result |
|
||||||
|
| --- | ---: | --- | --- |
|
||||||
|
| [HiAnime title search][search] | 200 | text/html | 250238 bytes; series/movie/special candidates |
|
||||||
|
| [Selected series][series] | 200 | text/html | 186932 bytes |
|
||||||
|
| [Actual episode list][episodes] | 200 | application/json | 25926 bytes; 26 links; episode 1 ID 21418, title Asteroid Blues |
|
||||||
|
| [Episode 1 servers][servers] | 200 | application/json | 1936 bytes; ZokoAnime and HD-1 for sub/dub |
|
||||||
|
| [Direct MAL episode embed][mal-embed] | 200 | text/html | 1832 bytes; source + English subtitle configuration |
|
||||||
|
| [Direct AniList episode embed][ani-embed] | 200 | text/html | 1832 bytes; source + English subtitle configuration |
|
||||||
|
| Fresh HLS master, URL redacted | 200 | application/vnd.apple.mpegurl | 98 bytes; one advertised variant |
|
||||||
|
| HLS variant, URL redacted | 200 | application/vnd.apple.mpegurl | 38193 bytes; 353 segments; ENDLIST; no EXT-X-KEY tags |
|
||||||
|
| English subtitle, URL redacted | 200 | text/vtt | 14466 bytes; WEBVTT; 217 cue arrows |
|
||||||
|
|
||||||
|
**Observed native media:** `ffprobe 9.0.2` exited 0 and reported H.264 video at 1448×1080, AAC audio at 48000 Hz, and duration 1483.145982 seconds. The manifest advertised 1920×1080; the decoded stream dimensions are the stronger evidence. No audio track/language equivalence across other items was verified.
|
||||||
|
|
||||||
|
Two bounded `ffmpeg` runs, each capped at 30 seconds, requested one video frame with input seeks of 0 and 600 seconds. Both exited 0 with `frame=1` and `progress=end`. Final `ffprobe` and decode checks explicitly set `tls_verify=1` and `max_redirects=0`, used `http,https,tcp,tls` protocols only, five-second socket timeouts, 1 MiB probe size, two-second analysis duration, and `extension_picky=0`. The latter permits segment-content probing rather than trusting CDN filename extensions; Terebi already uses the same setting. No whole-episode download or GUI playback occurred.
|
||||||
|
|
||||||
|
### Missing episodes are not HTTP errors
|
||||||
|
|
||||||
|
**Observed:** [episode 27][missing] of the finished 26-episode sample returned **HTTP 200 `text/html`**, but no `window.__P` configuration. Its visible page said “Error 404 / Not found” and that there was nothing to play. Status/MIME alone cannot establish availability. The resolver must recognize missing configuration as unavailable, not a successful empty source.
|
||||||
|
|
||||||
|
**Source:** AniList's `episodes` field is the amount when complete; its [streamingEpisodes][streaming-episodes] links are legal streaming-site links, not a general native-stream inventory. The inspected ZokoAnime documentation exposes a numbered embed lookup, and the player selector takes a manually entered episode number. No authoritative episode-list interface was established. The HiAnime listing proves the sample's inventory but would add another aggregation dependency if used in production.
|
||||||
|
|
||||||
|
**[INFERENCE]:** episodic browsing needs a separate availability decision: obtain a supported inventory, or explicitly distinguish catalog episode numbers from provider-confirmed playable episodes and resolve on demand. This report does not choose or silently change Tubular's current available-episodes-only behavior. Split seasons, movies and specials still need explicit mapping semantics; the sampled routes are not universal proof.
|
||||||
|
|
||||||
|
## Terebi compatibility and security
|
||||||
|
|
||||||
|
**Observed:** the master and variant were on `hls.dramahot.top`, while their segment URLs named `hls1.drama1.cfd`. URLs/query tokens are deliberately not retained here.
|
||||||
|
|
||||||
|
**Source:** current [`resolvedIOOpen`][player-policy] rejects any resource whose origin differs from the initial source's origin, and rejects redirects before forwarding custom headers. [`StreamURL.parse`][url-parse] has a dedicated Miruro case only; other hosts enter the YouTube path, not a ZokoAnime resolver. Existing Miruro identity/progress/state must not be reused to disguise this provider.
|
||||||
|
|
||||||
|
**[INFERENCE]:** the sampled manifest would fail the current protected HLS path despite generic FFmpeg being able to decode it. Native FFmpeg success is not Terebi acceptance, subtitle rendering, resume/progress, or complete playback proof. No Terebi source or security policy was changed or exercised with this provider.
|
||||||
|
|
||||||
|
A future integration needs a separately reviewed, origin-scoped resource/header policy for the concrete CDN relationships, or a demonstrably compatible source. Do not simply remove the origin comparison, globally allow cross-origin redirects, or forward headers to arbitrary manifest hosts.
|
||||||
|
|
||||||
|
## Recommendation and remaining prerequisites
|
||||||
|
|
||||||
|
- **Go for a focused implementation design:** AniList catalog → exact AniList/MAL ID → ZokoAnime native resolver is demonstrated independently of Miruro. Keep catalog/selection in Tubular and fresh source/subtitle resolution in Terebi. Do not add a general multi-provider framework or mapping database for this one path.
|
||||||
|
- **Before replacing browsing:** settle authoritative episode availability versus explicitly labeled on-demand resolution. Keep unknown/missing distinct from zero and from network/protection errors.
|
||||||
|
- **Before native player integration:** decide the narrow cross-origin policy and provider-correct identity/progress contract; map or explicitly retain existing Miruro-keyed collections rather than reinterpreting them.
|
||||||
|
- **Before distribution:** review [AniList's terms][terms]. They prohibit mass collection, API use as backup/storage, and competing non-complementary list/tracker services. Tubular's local watched-state makes applicability worth clarifying; this investigation does not establish permission. ZokoAnime advertises iframe integration, but no stable documented raw-media/native interface or distribution-rights guarantee was established.
|
||||||
|
- **Known limits:** small point-in-time sample; no ongoing-series inventory, dub media decode, special-numbering coverage, full episode playback, source expiry/IP-binding, uptime, geographical availability or rights verification. ZokoAnime may itself have upstream dependencies; only independence from Miruro on the exercised path is proven.
|
||||||
|
|
||||||
|
### Reproduction record
|
||||||
|
|
||||||
|
The executed temporary harness was built with `cargo build --locked --example direct_provider_probe` and called as `target/debug/examples/direct_provider_probe URL`; AniList POST payloads and resource Referer values were passed through environment variables. A Python subprocess driver kept response bodies and fresh URLs in memory, applied the first-party static config decoder and invoked `ffprobe`/`ffmpeg` directly. The temporary Rust source, binary and driver state were removed after this report; that Cargo example is **not a retained runnable command**. No permanent tests were added for research-only work.
|
||||||
|
|
||||||
|
For a fresh ordinary-HTTPS metadata/status check, the following requests reproduce the public request shapes; the native results above were obtained with ureq, not these curl commands:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl --silent --show-error --max-time 20 \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data '{"query":"query { Media(id: 1, type: ANIME) { id idMal title { romaji } episodes } }"}' \
|
||||||
|
https://graphql.anilist.co
|
||||||
|
|
||||||
|
curl --silent --show-error --max-time 20 --output /dev/null \
|
||||||
|
--write-out 'HTTP %{http_code}; %{content_type}\n' \
|
||||||
|
https://zokoanime.video/stream/ani/1/1/sub
|
||||||
|
```
|
||||||
|
|
||||||
|
Re-run source resolution before any media check; never save or publish resolved tokenized URLs. The first-party configuration decoder and native media flags above document the exercised method without retaining credentials or stale media URLs.
|
||||||
|
|
||||||
|
[ani-cli]: https://raw.githubusercontent.com/pystardust/ani-cli/master/ani-cli
|
||||||
|
[zoko]: https://zokoanime.video/
|
||||||
|
[zoko-docs]: https://zokoanime.video/docs
|
||||||
|
[selector]: https://zokoanime.video/assets/player-CiFNLeyH.js
|
||||||
|
[docs-js]: https://zokoanime.video/assets/docs-sIsMGsz7.js
|
||||||
|
[obfuscate]: https://zokoanime.video/core/obfuscate.js?v=23
|
||||||
|
[media]: https://docs.anilist.co/reference/object/media
|
||||||
|
[terms]: https://docs.anilist.co/guide/terms-of-use
|
||||||
|
[streaming-episodes]: https://docs.anilist.co/reference/object/mediastreamingepisode
|
||||||
|
[fribb]: https://github.com/Fribb/anime-lists
|
||||||
|
[search]: https://hianime.at/search?keyword=Cowboy+Bebop
|
||||||
|
[series]: https://hianime.at/cowboy-bebop-1281
|
||||||
|
[episodes]: https://hianime.at/api/theme/episode/list/1281
|
||||||
|
[servers]: https://hianime.at/api/theme/episode/servers?episodeId=21418
|
||||||
|
[mal-embed]: https://zokoanime.video/stream/mal/1/1/sub
|
||||||
|
[ani-embed]: https://zokoanime.video/stream/ani/1/1/sub
|
||||||
|
[missing]: https://zokoanime.video/stream/ani/1/27/sub
|
||||||
|
[player-policy]: ../../terebi/Sources/Core/player.cpp#L111-L126
|
||||||
|
[url-parse]: ../../terebi/Sources/App/StreamResolver.swift#L8-L35
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
# Miruro API research
|
||||||
|
|
||||||
|
Observed **2026-10-12**, scoped to **https://barelystarted.miruro.tv**; ownership split updated **2026-10-22**. These are reverse-engineering notes, not an official API specification. Tubular now owns catalog/detail retrieval and stable identity; Terebi resolves episode URLs. Source/CDN investigations below are historical evidence, not the current launch path.
|
||||||
|
|
||||||
|
**Mirror update (2026-10-25):** the [official directory](https://www.miruro.com/#domains) links Miruro 2.0 at `https://miruro.cx`, `https://miruro.pw`, `https://miruro.ws`, `https://miruro.to`, `https://miruro.tv`, and `https://miruro.bz`; `www.` hosts are labeled Legacy. Tubular now uses these six explicit origins for catalog/detail requests. Local native `ureq` and curl probes received **403 `text/html`** from all six autocomplete endpoints; curl returned Cloudflare block pages. Thus live schema/ID compatibility and successful browsing on these mirrors remain unverified. The original `cargo run --locked --example miruro_probe` now exercises all six and reports `Miruro request failed on 6/6 mirrors`, rather than stopping at the obsolete browsing origin. Endpoint/schema evidence below remains the historical snapshot, not a claim of current mirror access.
|
||||||
|
|
||||||
|
Evidence labels:
|
||||||
|
|
||||||
|
- **Verified:** an actual HTTP response obtained through the research JavaScript `fetch` transport, or the explicitly identified local Rust probe below, with bounded timeouts and no supplied authentication, cookies, or protection bypass.
|
||||||
|
- **Frontend:** behavior read from deployed first-party JavaScript; not necessarily a server-side guarantee.
|
||||||
|
- **Unknown:** not established by those sources or requests.
|
||||||
|
|
||||||
|
**Transport distinction (historical):** research requests returned JSON, while a local `curl` source-validation request returned **403 `text/html`**, a Cloudflare block page. A subsequent local Rust `ureq` probe passed the search → detail/episodes → sources → HLS master/subtitle path without bypass machinery. Native access was proven for that client and sample, not every transport, network, provider or future request. Inspect HTTP status and Content-Type before decoding; never treat protection pages as empty results. The current client can try the next explicitly listed official mirror, but cannot restore access when every mirror blocks it.
|
||||||
|
|
||||||
|
## Endpoint map
|
||||||
|
|
||||||
|
All paths below are relative to the scoped base URL. Each row was live-verified unless explicitly labeled Frontend. Route-data responses require the [SvelteKit decoder](#sveltekit-route-data), even when Content-Type is `application/json`.
|
||||||
|
|
||||||
|
| Purpose | GET path and parameters | Response |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Autocomplete | `/api/search/browse?q=<query>&limit=8&type=ANIME` | Plain JSON `{data, next_cursor, has_more}` |
|
||||||
|
| Full search / browse | `/search/__data.json?q=<query>&cursor=<returned cursor>` | Route data: `{items, entityItems, nextCursor, hasMore, filters, …}` |
|
||||||
|
| Detail / episode metadata | `/info/<id>/<slug>/__data.json` | Streamed route data: `detail`, promised `episodes` and related metadata |
|
||||||
|
| Watch metadata + initial sources | `/watch/<id>/<slug>/__data.json?ep=<number>` | Streamed route data: `media`, promised `episodesData` and `sourcesData` |
|
||||||
|
| Fresh episode sources | `/api/sources?id=<id>&n=<number>` | Plain JSON `{episodeNumber, tracks, config, providerOrder}` |
|
||||||
|
| Home | `/__data.json` | Streamed route data: `hero`, promised `rails` and `sideLists` |
|
||||||
|
| Trending | `/trending/__data.json` | Route data: `{items, requestedPage, year, season, years, seasons}` |
|
||||||
|
| Schedule page | `/schedule/__data.json` | Route data: `{entries, weekStart, weekOffset, now, track}` |
|
||||||
|
| Schedule events | `/api/schedule?startAt=<Unix seconds>&track=all` | Plain JSON event array |
|
||||||
|
| Genre/tag references | `/api/discover/refs` | Plain JSON `{genres, tags}` |
|
||||||
|
| Discover page | `/discover/__data.json` | Route data: `{lens, lensOffset, spotlights, picks, lensCount}` |
|
||||||
|
| Personalized discover feed | `/discover/feed` | Plain JSON `{spotlights, picks}`; empty arrays verified without seeds |
|
||||||
|
|
||||||
|
Sources: [route map][routes], [search/layout][layout], [detail][detail-client], [watch][watch-client], [home][home-client], [trending][trending-client], [schedule][schedule-client], [discover][discover-client], and the live URLs in the verification ledger. No plain JSON detail/episodes endpoint was identified in those bundles; none was guessed or probed. This is not proof that none exists. Other Miruro domains and third-party `/api/secure/pipe` wrappers are outside this contract.
|
||||||
|
|
||||||
|
## IDs and the search-to-stream flow
|
||||||
|
|
||||||
|
Use Miruro's **opaque catalog ID**, not an external numeric ID:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Miruro id: OERe_a0YS4h-DlsBDOeXwX7HOO6316tw
|
||||||
|
AniList: 195516
|
||||||
|
MAL: 61987
|
||||||
|
Slug: the-apothecary-diaries-season-3
|
||||||
|
```
|
||||||
|
|
||||||
|
Verified search records expose `external_ids` as a map of **string arrays**, for example `{"anilist":["195516"],"mal":["61987"]}`. Unmapped provider keys may be absent. Watch data separately exposes numeric `anilistId`; `media.id` remains the opaque Miruro ID. Numeric-ID reverse lookup and route aliases are unknown.
|
||||||
|
|
||||||
|
The minimum evidenced flow is:
|
||||||
|
|
||||||
|
1. Search and retain the opaque `items[].id` / `data[].id`, title, and artwork.
|
||||||
|
2. Fetch detail route data and resolve its `episodes` chunk, or fetch watch route data and resolve `episodesData`.
|
||||||
|
3. Select an actual returned episode number. Detail `episode_number:1` mapped to `absolute_number:49`; source query uses **1**, not 49 or an array index.
|
||||||
|
4. Fetch `/api/sources?id=<opaque id>&n=<selected number>` immediately before playback.
|
||||||
|
5. Choose a returned track/provider/server with direct streams; preserve that server's headers and runtime configuration. Iframe-only servers are not native media URLs.
|
||||||
|
|
||||||
|
**Frontend (2026-10-12):** [link helpers][link-helpers] generate `/info/<id>/<slug>` and `/watch/<id>/<slug>?ep=<number>`, with title-derived canonical slugs. **2026-10-22:** a placeholder watch slug returned a canonical-route redirect envelope; see the [URL handoff](terebi-url-playback-handoff.md#url-and-canonical-route-handling). Slugs are not episode identity. Tubular does not derive playback routes from display titles.
|
||||||
|
|
||||||
|
## Search and discovery contracts
|
||||||
|
|
||||||
|
### Plain autocomplete
|
||||||
|
|
||||||
|
**Verified:** [this request][browse-live] returned eight summary records, `next_cursor` and `has_more:true`. **Frontend:** layout trims `q`, sends literal `limit=8&type=ANIME`, debounces input 300 ms and aborts older requests. Empty input clears local suggestions without a request.
|
||||||
|
|
||||||
|
An abbreviated observed result, with additional fields omitted:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [{
|
||||||
|
"id": "OERe_a0YS4h-DlsBDOeXwX7HOO6316tw",
|
||||||
|
"external_ids": {"anilist": ["195516"], "mal": ["61987"]},
|
||||||
|
"title": {
|
||||||
|
"romaji": "Kusuriya no Hitorigoto 3rd Season",
|
||||||
|
"english": "The Apothecary Diaries Season 3",
|
||||||
|
"native": "薬屋のひとりごと 第3期"
|
||||||
|
},
|
||||||
|
"format": "TV",
|
||||||
|
"status": "RELEASING",
|
||||||
|
"episode_count": 12,
|
||||||
|
"episode_counts": {"raw": 2, "sub": 2, "dub": null},
|
||||||
|
"episode_duration_minutes": 24,
|
||||||
|
"season": "FALL",
|
||||||
|
"season_year": 2026,
|
||||||
|
"average_score": 85
|
||||||
|
}],
|
||||||
|
"next_cursor": "<redacted returned cursor>",
|
||||||
|
"has_more": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Summary fields also observed: `description`, `cover_url`, `cover_color`, `banner_url`, `background_url`, `logo_url`, `source`, `country_of_origin`, `popularity`, `is_adult`, `next_airing:{episode_number,air_at}`, `show_cluster_id`, `show_cluster_position`. Across records, English title, counts, duration, season/year, score, colors/artwork, next airing, and cluster fields could be null. Null is not zero or an empty string.
|
||||||
|
|
||||||
|
**Pagination trap:** sending this response's `next_cursor` as `cursor` on the same otherwise-valid autocomplete request returned **400**, `detail:"Unsupported browse request."`. `/api/search/browse?limit=2&type=ANIME` also returned 400, but that probe changed both limit and omission of `q`, so it does not isolate the cause. Do not promise plain-API pagination or infer supported limits from these probes.
|
||||||
|
|
||||||
|
### Full search, filters, and pagination
|
||||||
|
|
||||||
|
**Verified:** decoded `/search/__data.json?q=apothecary` yielded:
|
||||||
|
|
||||||
|
```text
|
||||||
|
{
|
||||||
|
mode: "anime", items: MediaSummary[], entityItems: [],
|
||||||
|
nextCursor: null, hasMore: false, limit: 24, cursor: "",
|
||||||
|
entityPage: 1, filters: { ...string-valued fields... },
|
||||||
|
context: null, error: null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
It returned eleven summaries. An unmatched query returned `items:[]`, `entityItems:[]`, `nextCursor:null`, `hasMore:false`, `error:null`, HTTP 200. Empty results are distinct from errors.
|
||||||
|
|
||||||
|
Unfiltered `/search/__data.json` returned 24 summaries and a non-null `nextCursor`; a subsequent request with that token as `cursor` returned another 24 summaries with a different first ID, echoed `cursor`, and `hasMore:true`. Retain the returned token opaquely and URL encode it. Token lifetime, binding to filters/cookies and invalid-token handling are unknown. Do not reuse the autocomplete cursor.
|
||||||
|
|
||||||
|
**Frontend:** [search page][search-client] and route dependency metadata expose these page query keys:
|
||||||
|
|
||||||
|
| Keys | Representation / behavior |
|
||||||
|
| --- | --- |
|
||||||
|
| `mode` | `anime`, `studio`, `staff`, `character` |
|
||||||
|
| `q` | Trimmed query; entity input has a client-only 200-character limit |
|
||||||
|
| `cursor` | Anime pagination; reset when filters change |
|
||||||
|
| `page` | Numeric entity pagination, not anime pagination |
|
||||||
|
| `studios`, `staff`, `characters` | Numeric entity-ID context filters |
|
||||||
|
| `genres`, `format`, `status`, `tracks`, `season`, `source`, `country` | Single or comma-separated selected values; genre names, not reference IDs |
|
||||||
|
| `adult` | Empty/unset is labeled Non-adult; `true` is labeled Adult |
|
||||||
|
| `yearFrom`, `yearTo`, `minScore`, `maxScore` | String-valued range bounds; score slider 0–100 |
|
||||||
|
| `sort` | One frontend option string |
|
||||||
|
|
||||||
|
Frontend option values ([source][filter-options]), **not guaranteed server enums**:
|
||||||
|
|
||||||
|
- `format`: `TV`, `TV_SHORT`, `MOVIE`, `OVA`, `ONA`, `SPECIAL`, `MUSIC`.
|
||||||
|
- `status`: `RELEASING`, `FINISHED`, `NOT_YET_RELEASED`, `HIATUS`, `CANCELLED`.
|
||||||
|
- `tracks`: `raw` (**Sub / Raw**), `dub` (**Dub**), `sub` (**Scheduled Subs**). These discovery labels are not the playback API's `ssub`/`sub` classification.
|
||||||
|
- `season`: `WINTER`, `SPRING`, `SUMMER`, `FALL`; `country`: `JP`, `KR`, `CN`, `TW`.
|
||||||
|
- `source`: `ORIGINAL`, `MANGA`, `LIGHT_NOVEL`, `WEB_NOVEL`, `NOVEL`, `VISUAL_NOVEL`, `VIDEO_GAME`, `GAME`, `DOUJINSHI`, `ANIME`, `COMIC`, `LIVE_ACTION`, `MULTIMEDIA_PROJECT`, `PICTURE_BOOK`, `OTHER`.
|
||||||
|
- `sort`: empty (Most Popular), `-score`, `-season_year`, `season_year`, `title`, `-title`, `-episode_count`, `episode_count`, `-duration`, `duration`, `-started_on`, `-ended_on`, `-updated_at`.
|
||||||
|
|
||||||
|
The grid stores `hestia-page-size` in a cookie, computing 24 or 12/15/18/21 by layout. No full-search query `limit` was observed; 24 is a sampled response, not a guaranteed default. Entity responses/filter semantics, cookie acceptance/ranges and server validation were not exercised.
|
||||||
|
|
||||||
|
### Other discovery surfaces
|
||||||
|
|
||||||
|
**Verified**, with frontend parameter behavior noted separately:
|
||||||
|
|
||||||
|
- **Home:** decoded `{hero, now, rails:Promise, sideLists:Promise}`. Hero records are camelCase (`externalIds`, `coverUrl`, `backdropUrl`, `episodeCounts`, etc.), unlike search summaries. `rails` resolves `raw`/`dub` latest entries with `animeId`, `episode`, `track`, Unix-second `airingAt`; `sideLists` has `airing`/`upcoming` summary arrays. Use `animeId`, not an event ID, for sources.
|
||||||
|
- **Trending:** `{items, requestedPage, year, season, years, seasons}`. **Frontend** `year`/`season` change the loaded collection; `page` slices it locally into 18-item pages. It is not evidence of server-side cursor pagination.
|
||||||
|
- **Schedule page:** `{entries, weekStart, weekOffset, now, track}`; dependency metadata reads `week` and `track`. Plain schedule returns `[{id,airingAt,episode,track,media}]`; `startAt` and `airingAt` are Unix **seconds**. Use `media.id`, not event `id`, for sources. **Frontend** fetches `track=all`, shifts weeks by `7*86400`, and filters all/sub/dub locally. API sub/dub variants are untested; returned entries included `track:"raw"`.
|
||||||
|
- **References:** `/api/discover/refs` returns `genres:[{id,name}]` and `tags:[{id,name,category,parent_id,parent_name}]`; parent fields may be null. Search uses genre names. A tag filter query was not observed in full-search controls.
|
||||||
|
- **Discover page:** `{lens:{id,label,tagline,recipe}, lensOffset, spotlights, picks, lensCount}`. Spotlights are camelCase; picks use snake_case summaries. Dependency metadata reads `season`, `year`, `cursor`, `d`; **Frontend** refresh sets a random `d`. These are not a proven paginated API.
|
||||||
|
- **Personalized feed:** unseeded `/discover/feed` returned empty arrays. **Frontend** sends `seeds=<comma-separated numeric list IDs>&exclude=<comma-separated IDs>&provider=<list provider>`, using up to eight seeds and fallback provider `anilist`. These IDs belong to the external list service, not Miruro's opaque catalog. The UI gates personalization on sign-in; seeded-request authentication and schema are unknown.
|
||||||
|
|
||||||
|
## Detail, episodes, and watch data
|
||||||
|
|
||||||
|
**Verified:** detail route initially decodes to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
{
|
||||||
|
detail: MediaDetail, id, currentSlug, tvdbBackgrounds,
|
||||||
|
characters: Promise, relations: Promise, recommendations: Promise,
|
||||||
|
artworks: Promise, show: Promise, episodes: Promise
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`detail` extends summary metadata with `alternate_titles:[{value,kind,language}]`, `started_on`, `ended_on`, `broadcast`, `release_schedules`, `mal_score`, `mal_members`, `aired_season`, `trailer_youtube_id`, `genres`, `tags:[{name,rank,category,is_spoiler}]`, `studios:[{id,name,is_main,is_animation_studio}]`, `franchise`, `updated_at`. Dates and some nested identifiers/counts can be null. Broadcast/release schedules describe airing metadata, not stream provider URLs or playback availability.
|
||||||
|
|
||||||
|
The sample title's resolved `episodes` contained two records despite a planned `episode_count:12`. Abbreviated episode with all observed keys:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"episode_number": 1, "absolute_number": 49, "aired_season_number": 3,
|
||||||
|
"kind": "regular", "canon_type": null, "title": "Locusts",
|
||||||
|
"synopsis": "A locust plague is looming, and mysteriously, a book on insects belonging to the Shi clan appears.",
|
||||||
|
"thumbnail_url": "https://image.tmdb.org/t/p/original/v2k4Nj11mnVwyUhqktOmYr3E4yV.jpg",
|
||||||
|
"aired_on": "2026-10-02", "duration_seconds": 1500, "finale_type": null,
|
||||||
|
"skip_times": [
|
||||||
|
{"kind":"op","start_seconds":63.313,"end_seconds":153.237},
|
||||||
|
{"kind":"ed","start_seconds":1335.252,"end_seconds":1425.384}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not synthesize playable episodes from planned counts. These records do not carry per-track availability flags; request actual sources for the selected episode. Null dub count does not conclusively prove no playable dub.
|
||||||
|
|
||||||
|
Watch route initially has `{media, episodesData:Promise, sourcesData:Promise, anilistId, externalIds, episodeNumber, tvdbBackgrounds}`. `media` is adapted camelCase: `episodeCounts`, `duration` (minutes), `seasonYear`, `startDate`, `coverImage`, `nextAiringEpisode`, etc.; it is not interchangeable with `detail`.
|
||||||
|
|
||||||
|
Resolved watch `episodesData` has:
|
||||||
|
|
||||||
|
```text
|
||||||
|
{
|
||||||
|
episodes: [{id:"1",number:1,title,image,airDate,duration:1500,description,filler:false}, ...],
|
||||||
|
currentEpisode: <episode 1>, prevEpisode: null, nextEpisode: <episode 2>,
|
||||||
|
aniskip: [
|
||||||
|
{episode:1,type:"op",start:63.313,end:153.237},
|
||||||
|
{episode:1,type:"ed",start:1335.252,end:1425.384}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Watch `episodes[].id` is the number rendered as a string, **not a provider episode ID**. `aniskip` times are seconds; they differ in naming from detail `skip_times`. `/api/sources` does not carry those fields. **Frontend:** watch converts `op`/`mixed-op` to intro and `ed`/`mixed-ed` to outro, filters wrong episodes/invalid intervals and optional provider/vote/duration constraints, then recomputes skips using player-reported duration. No separate skip endpoint was found. Missing/invalid episode behavior and last-episode boundaries are untested.
|
||||||
|
|
||||||
|
## Historical source-resolution and playback research (2026-10-12)
|
||||||
|
|
||||||
|
### Plain source response
|
||||||
|
|
||||||
|
**Verified:** [fresh sources][sources-live] returned the following observed structure. Fields seen only as null have unknown non-null types; this is not an exhaustive guaranteed schema.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Sources { episodeNumber:number, tracks:Track[], config:{provider:ProviderConfig}, providerOrder:string[] }
|
||||||
|
Track { track:string, providers:Provider[] }
|
||||||
|
Provider { provider:string, servers:Server[], subtitles:Subtitle[], thumbnails:null|{vtt:string,sprite:null}, downloads:Download[] }
|
||||||
|
Server { server:string, headers:null|{header:string}, embed:null|{url:string}, streams:Stream[] }
|
||||||
|
Stream { url:string, format:string, quality:null, resolution:null, codec:null, embed:null }
|
||||||
|
Subtitle { language:string, label:string, file:string, format:string, default:boolean }
|
||||||
|
Download { url:string, quality:null, size:null, fansub:null }
|
||||||
|
```
|
||||||
|
|
||||||
|
- Sample tracks were `ssub` (soft subtitles) and `sub` (hard-subtitled, UI `h-sub`); **Frontend** also supports `dub`. No separate `audio` property was observed. Discovery `raw/sub/dub` counts are a different vocabulary.
|
||||||
|
- Sample direct streams used `format:"hls"`. **Frontend** recognizes HLS strings/MIME types, DASH/MPD, MP4, WebM and Ogg, and defaults a missing format to HLS. Those are consumer inputs, not verified server formats.
|
||||||
|
- Quality/resolution/codec were null, yet a fetched master advertised `RESOLUTION=1920x1080`; do not equate null metadata with absent HLS renditions.
|
||||||
|
- An iframe-only server (`bun`) had `streams:[]`, `headers:null`, `embed:{url}`. An embed page is not a native stream.
|
||||||
|
- Subtitle language strings were provider-specific (`en`/`eng`, `sp`, `po`); distinct Spanish labels shared `language:"sp"`. Preserve labels, not only language codes. VTT was verified; SRT support is frontend-only.
|
||||||
|
- Subtitle/thumbnail URLs can end in `.jpeg` while returning `text/vtt`. Classify by format/content, not extension. Relative thumbnail sprite references must be resolved against the VTT URL.
|
||||||
|
- Download URLs can be download pages; their being raw media files is unproven.
|
||||||
|
- Source URLs may contain tokens. Fetch fresh before playing; do not persist tokens as catalog identity or log full URLs. Expiry and IP binding are unknown.
|
||||||
|
|
||||||
|
A sanitized projection (URL values deliberately redacted):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"episodeNumber": 1,
|
||||||
|
"tracks": [{"track":"ssub","providers":[{
|
||||||
|
"provider":"sun",
|
||||||
|
"servers":[{
|
||||||
|
"server":"icarus-2-1",
|
||||||
|
"headers":{"Origin":"https://ultracloud.cc","Referer":"https://ultracloud.cc/"},
|
||||||
|
"embed":null,
|
||||||
|
"streams":[{"url":"<fresh HLS URL>","format":"hls","quality":null,"resolution":null,"codec":null,"embed":null}]
|
||||||
|
}],
|
||||||
|
"subtitles":[{"language":"en","label":"English","file":"<fresh VTT URL>","format":"vtt","default":true}],
|
||||||
|
"thumbnails":{"vtt":"<fresh thumbnail VTT URL>","sprite":null}
|
||||||
|
}]}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This projects one server/provider and omits config, other tracks/providers and downloads; it is not a complete response. Preserve the actual returned header strings without normalization.
|
||||||
|
|
||||||
|
### Runtime config and selection
|
||||||
|
|
||||||
|
**Verified:** response `config` entries contain `capabilities:{sub,ssub,download,skip_times,thumbnails}`, `visible`, `player` (`native`/`iframe` observed), `parent`, `proxy`, `variantOrder`, `cors`, `fallback`, and optional `hls` / `downloadWorker` fields. `proxy` was false or `{rotate:boolean,segments:boolean}`; `hls` contained `segments:{enabled,origin}` and `query:{enabled,params}`. Non-null fallback shape is unknown.
|
||||||
|
|
||||||
|
Observed `providerOrder`: `sun, bee, pewe, hop, ally, kiwi, moo, bonk, knob, nun, bun, cog, twin, telli`. Config includes providers absent from the selected episode. Treat names/order as runtime data. Capability flags and `cors` were false even where streams/subtitles/downloads/thumbnails were returned; they are not verified availability gates.
|
||||||
|
|
||||||
|
**Frontend** ([watch client][watch-client]):
|
||||||
|
|
||||||
|
1. Use saved sub/dub and provider preferences. For sub, consult each provider's `variantOrder` for `sub`/`ssub`, then append missing variants; iterate `providerOrder` and retain only actual provider/track combinations.
|
||||||
|
2. Prefer exact `provider:track`, then same provider, then first available. If a requested language has no entries, try the opposite language. This is existing UI behavior, not a recommendation to silently change a native user's language.
|
||||||
|
3. Flatten selected provider servers' nonempty streams in returned order; start at the first and advance on failure. Retain iframe embeds only for `player:"iframe"`.
|
||||||
|
4. Browser stream projection extracts Referer case-insensitively, but raw API also returns Origin. A native wrapper should retain the entire header map.
|
||||||
|
|
||||||
|
### Direct resources, proxies, and browser player
|
||||||
|
|
||||||
|
**Verified:** using fresh Sun stream/subtitle/thumbnail URLs and the server's Origin/Referer headers, research GETs obtained an HLS master (200 `application/vnd.apple.mpegurl`) and two VTT bodies (200 `text/vtt`). No nested rendition playlist, media segment, key, sprite, download page or player was exercised. Whether either header is mandatory was not tested. **This proves resource retrieval, not full playback.**
|
||||||
|
|
||||||
|
**Frontend:** deployment HTML [environment][homepage] identifies `https://strm.cx`, proxy bases `https://s1.keeply.top/` and `https://s2.keeply.top/`, and a public proxy obfuscation key. Watch source shows:
|
||||||
|
|
||||||
|
- Sun uses direct streams (`proxy:false` in the sample). Bee/Hop use `{rotate:false,segments:true}`; a bare direct URL is not proven equivalent for these providers.
|
||||||
|
- Browser proxy paths encode URL/Referer and use `/pl.m3u8`, `/sub.vtt`, `/seg.jpg`. This is a private deployment convention, not `/api/secure/pipe`. No replay of that obfuscation protocol was needed for direct-resource verification.
|
||||||
|
- Provider `hls` overrides can rewrite segment/init-resource hosts and append query parameters to playlists, including nested playlists. Browser code resolves relative URLs/`URI="…"` attributes, rewrites playlists into `blob:` URLs, drops failed variants, and bounds recursion, size and time. Those blob URLs are browser-local, not reusable native source URLs.
|
||||||
|
- The browser loads [strm.cx/client.js][strm-client], renders `<strmcx-embed>`, and calls `setSession` with local player state/source/subtitle/skip data over `strmcx/v1` messaging. This was **not required for the tested source or direct-resource GETs**. It is not evidence of an authenticated API session prerequisite.
|
||||||
|
- strm client contains a best-effort `POST /v1/embeds`; it was source-inspected only, not invoked. Iframe providers and unrelated comment/telemetry endpoints were not exercised.
|
||||||
|
|
||||||
|
The former Tubular direct Sun integration supplied fresh URLs and headers to stream and VTT resources. That projection has been removed; Terebi now owns native URL resolution, HLS and subtitles. Other providers' transforms remain unverified by this research. See [the current integration](#tubular--terebi-integration).
|
||||||
|
|
||||||
|
## SvelteKit route data
|
||||||
|
|
||||||
|
Sources: [deployed fetch/devalue decoder][transport-client], [route map][routes], and live detail/watch/home responses.
|
||||||
|
|
||||||
|
Append `/__data.json` to the page path and retain query parameters. The browser transport can add `x-sveltekit-invalidated=<bit string>` and `x-sveltekit-trailing-slash=1`; neither was necessary in the research requests.
|
||||||
|
|
||||||
|
An **illustrative**, minimal envelope:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"data","nodes":[{"type":"data","data":[{"someKey":1},"someValue"],"uses":{}},null]}
|
||||||
|
```
|
||||||
|
|
||||||
|
Actual responses had layout at node 0, route at node 1. Each node's `data` is its own **devalue flattened table**, with root at index 0. Object-property and ordinary-array numeric values are references into that table, not literal payload numbers. Null is a table value; undefined uses sentinel `-1`. Decoder also handles array holes (`-2`), NaN/infinities/negative zero (`-3`…`-6`), sparse arrays (`-7`) and tagged Date/Set/Map/BigInt/other types. A generic recursive integer dereferencer is not a complete decoder. Keep layout configuration separate from route models.
|
||||||
|
|
||||||
|
Promised fields are tagged `['Promise', <table index referencing promise ID>]`. Streamed `text/sveltekit-data` is **newline-delimited JSON**, not one JSON object:
|
||||||
|
|
||||||
|
```text
|
||||||
|
{"type":"data","nodes":[...]}
|
||||||
|
{"type":"chunk","id":<promise ID>,"data":<independent devalue table>}
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolve by decoded promise ID; each chunk has a new table. Arrival order is not field order: sample detail chunks arrived 2,1,6,3,4,5. IDs are response-local, not constants. Consume the full body or track promises incrementally. Frontend also decodes `error` chunks to reject a promise; that error path was not live-exercised. Top-level redirect envelopes require separate handling.
|
||||||
|
|
||||||
|
**Verified:** `/info/invalid/invalid/__data.json` returned HTTP **200** with `{"type":"redirect","location":"/"}`. Status 200 therefore does not prove a successful detail response. Empty search arrays, redirect envelopes, API problem responses and transport failures must remain distinct.
|
||||||
|
|
||||||
|
## Errors and caching
|
||||||
|
|
||||||
|
**Verified API problem shape:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"about:blank","title":"Bad Request","status":400,"detail":"Unsupported request parameters."}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Content-Type: application/problem+json`, `Cache-Control: no-store`.
|
||||||
|
|
||||||
|
- `/api/sources` without parameters: 400, `detail:"Anime and episode are required."`.
|
||||||
|
- Known ID with `n=0` or `n=not-a-number`: 400, `detail:"Unsupported request parameters."`.
|
||||||
|
- `id=invalid&n=1`: same 400/detail; this does not establish behavior for a syntactically valid nonexistent ID.
|
||||||
|
- Unsupported tested browse requests: 400, `detail:"Unsupported browse request."`.
|
||||||
|
- Local curl of known ID with `n=0`: Cloudflare **403 HTML**, before the JSON validation behavior seen in research transport. The cause/client fingerprint/IP distinction is unknown.
|
||||||
|
|
||||||
|
Source success and source errors had no `Access-Control-Allow-Origin` header. The direct Sun master/subtitle responses had ACAO `https://ultracloud.cc`. Neither observation guarantees general browser cross-origin access. Server rate limits, stable error taxonomy, authentication guarantees, token expiry and protection behavior are unknown; no rate/protection stress tests occurred.
|
||||||
|
|
||||||
|
Observed Cache-Control values:
|
||||||
|
|
||||||
|
| Surface | Cache-Control |
|
||||||
|
| --- | --- |
|
||||||
|
| Plain browse success | `public, max-age=300, s-maxage=900, stale-while-revalidate=3600, stale-if-error=86400` |
|
||||||
|
| Plain schedule | `public, max-age=0, s-maxage=60, stale-while-revalidate=60` |
|
||||||
|
| Discover references | `public, max-age=0, s-maxage=300, stale-while-revalidate=60` |
|
||||||
|
| Source success/errors and normal route data | `no-store` |
|
||||||
|
| Missing-detail redirect; discover feed | `private, no-store` |
|
||||||
|
|
||||||
|
Respect current response headers; these are sampled deployment values, not constants. Metadata cacheability does not make stream URLs durable.
|
||||||
|
|
||||||
|
## Verification ledger
|
||||||
|
|
||||||
|
All research data GETs below used `fetch` with 20–25-second timeouts, no supplied auth/cookie/Origin/Referer unless stated. JS bundles used 15–20-second bounds. These are research-transport results, **not successful local curl/native playback claims**. Stream tokens and pagination cursors are intentionally not recorded.
|
||||||
|
|
||||||
|
| Request | Status / Content-Type | Observed result |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| [Autocomplete][browse-live] | 200 / application/json | Eight summaries; next_cursor; has_more |
|
||||||
|
| Browse without q, changed limit 2 | 400 / application/problem+json | Unsupported browse request |
|
||||||
|
| Valid browse + own returned cursor as `cursor` | 400 / application/problem+json | Unsupported browse request |
|
||||||
|
| [Full query search][search-live] | 200 / application/json | Eleven summaries; no nextCursor |
|
||||||
|
| [Full browse][search-all-live] + its nextCursor request | Both 200 / application/json | Two 24-item pages; different first IDs |
|
||||||
|
| [Unmatched search][empty-search-live] | 200 / application/json | Empty items; error:null |
|
||||||
|
| [Detail][detail-live] | 200 / text/sveltekit-data | Initial detail + six promised chunks; two episodes |
|
||||||
|
| [Invalid detail][missing-detail-live] | 200 / application/json | Redirect envelope to `/` |
|
||||||
|
| [Watch ep=1][watch-live] | 200 / text/sveltekit-data | Initial media + episodesData/sourcesData chunks |
|
||||||
|
| [Source ep=1][sources-live] | 200 / application/json | ssub/sub, direct + iframe providers, runtime config |
|
||||||
|
| Source missing parameters / n=0 / nonnumeric n / invalid ID | Each 400 / application/problem+json | Validation details above |
|
||||||
|
| [Home][home-live] | 200 / text/sveltekit-data | Hero + rail/side-list chunks |
|
||||||
|
| [Trending][trending-live] | 200 / application/json | 97 sampled items; year/season choices |
|
||||||
|
| [Schedule page][schedule-live] / [plain schedule][events-live] | Both 200 / application/json | 240 sampled entries |
|
||||||
|
| [References][refs-live] | 200 / application/json | 19 genres, 1,742 tags in sample |
|
||||||
|
| [Discover][discover-live] | 200 / application/json | Lens, 12 spotlights, 12 picks |
|
||||||
|
| [Unseeded feed][feed-live] | 200 / application/json | Empty spotlights/picks |
|
||||||
|
| Fresh ssub/Sun first-server master, with server Origin/Referer | 200 / application/vnd.apple.mpegurl | `#EXTM3U`; one 1920x1080 variant; no redirect |
|
||||||
|
| Same provider default subtitle / thumbnail VTT, same headers | Both 200 / text/vtt | WEBVTT cues; thumbnail sprite coordinates; no redirect |
|
||||||
|
| [strm client][strm-client] | 200 / text/javascript | Source inspected, not executed |
|
||||||
|
| Local shell curl, known source ID with n=0 | 403 / text/html | Cloudflare block page |
|
||||||
|
|
||||||
|
One worker's homepage fetch without a trailing slash returned nginx 404, while the trailing-slash HTML read succeeded. This unresolved transport/URL discrepancy is not evidence of an API rule. The initial documentation research did not run application tests; the later native-wrapper verification is recorded below. Neither investigation used a browser, playback, media-segment downloads, account mutations or protection bypass.
|
||||||
|
|
||||||
|
## Bounded reproduction requests
|
||||||
|
|
||||||
|
These curl recipes show request syntax; **successful execution with local curl is not established**. One local curl probe was blocked. Check status/Content-Type and stop on a block page. Example IDs/dates describe the observed catalog snapshot; titles and availability can change.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
BASE='https://barelystarted.miruro.tv'
|
||||||
|
ID='OERe_a0YS4h-DlsBDOeXwX7HOO6316tw'
|
||||||
|
SLUG='the-apothecary-diaries-season-3'
|
||||||
|
|
||||||
|
curl --silent --show-error --max-time 25 -i --get "$BASE/api/search/browse" \
|
||||||
|
--data-urlencode 'q=apothecary' --data-urlencode 'limit=8' --data-urlencode 'type=ANIME'
|
||||||
|
|
||||||
|
curl --silent --show-error --max-time 25 -i --get "$BASE/search/__data.json" \
|
||||||
|
--data-urlencode 'q=apothecary'
|
||||||
|
|
||||||
|
curl --silent --show-error --max-time 25 -i "$BASE/info/$ID/$SLUG/__data.json"
|
||||||
|
|
||||||
|
curl --silent --show-error --max-time 25 -i --get "$BASE/watch/$ID/$SLUG/__data.json" \
|
||||||
|
--data-urlencode 'ep=1'
|
||||||
|
|
||||||
|
curl --silent --show-error --max-time 25 -i --get "$BASE/api/sources" \
|
||||||
|
--data-urlencode "id=$ID" --data-urlencode 'n=1'
|
||||||
|
```
|
||||||
|
|
||||||
|
For full-search pagination, decode `nextCursor` from `/search/__data.json`, then request the same route with `--data-urlencode "cursor=$CURSOR"`, retaining filters/cookie context. For current schedule, decode `weekStart` from `/schedule/__data.json`, then request `/api/schedule` with `startAt=<that value>&track=all`. To inspect media resources, select fresh stream/subtitle URLs from the source response and use that server's actual headers, a bounded GET and in-memory results; do not save tokenized URLs to these notes.
|
||||||
|
|
||||||
|
Equivalent plain-JSON research request (runtime/network portability is unproven):
|
||||||
|
|
||||||
|
```js
|
||||||
|
const response = await fetch(
|
||||||
|
'https://barelystarted.miruro.tv/api/search/browse?q=apothecary&limit=8&type=ANIME',
|
||||||
|
{ signal: AbortSignal.timeout(20_000) }
|
||||||
|
);
|
||||||
|
console.log(response.status, response.headers.get('content-type'));
|
||||||
|
console.log(await response.text());
|
||||||
|
```
|
||||||
|
|
||||||
|
## Native Rust catalog wrapper
|
||||||
|
|
||||||
|
The historical native probe succeeded using Rust 1.90.0, `ureq` 3.4.2, rustls 0.23.45 and the `ring` TLS provider. The current `tubular::miruro` module retains catalog/detail retrieval, the bounded route decoder and stable identity, not source resolution. It uses ordinary TLS verification, no cookies, impersonation, clearance tokens or alternate transport.
|
||||||
|
|
||||||
|
Current library interface:
|
||||||
|
|
||||||
|
| Call | Result / contract |
|
||||||
|
| --- | --- |
|
||||||
|
| `Client::default()` | Reusable blocking HTTP agent; six official 2.0 mirrors; sticky successful origin; 25-second total request budget, at most five seconds per attempt; no automatic redirects |
|
||||||
|
| `client.search(query)` | `io::Result<Vec<serde_json::Value>>`; trimmed, nonempty autocomplete query; no cursor pagination |
|
||||||
|
| `client.detail(id, slug)` | `io::Result<Detail>`; decoded `media` JSON and `episodes: Vec<Value>`; validates returned media ID |
|
||||||
|
| `Identity::new(id, episode)`, `Identity::from_key(key)` | Validated catalog namespace/episode identity; `key()` is `miruro:<opaque ID>:<episode>` |
|
||||||
|
| `identity.watch_url()` | Validates identity and produces `https://barelystarted.miruro.tv/watch/<opaque ID>/episode?ep=<returned episode>` |
|
||||||
|
|
||||||
|
Raw catalog methods preserve JSON metadata without a second deployment-specific schema. IDs/slugs are validated path segments; HTTP/HTML/Content-Type failures remain errors, not empty results. Successful bodies are limited to 2 MiB. Source selection, resource headers and subtitles belong to Terebi.
|
||||||
|
|
||||||
|
The client starts at `miruro.cx` and remembers the last origin whose response passed HTTP, Content-Type and bounded-body checks. On transport/body failures, unexpected Content-Type, HTTP 301–308, 403, 404, 408 or 5xx, it tries the remaining origins once in directory order. Other HTTP errors, including 400, 401 and 429, stop immediately; rotating mirrors must not circumvent rate limits. Exhaustion reports the attempted/total mirror count and last origin/error. JSON/route decoding and detail-ID validation remain mandatory and do not trigger additional fallback. The directory is not scraped at runtime, and requests are not randomized. Playback URLs deliberately retain `barelystarted.miruro.tv`: current Terebi admits and resolves only that hostname, so playback failover requires a separate player change.
|
||||||
|
|
||||||
|
Its private route decoder resolves only the needed detail value and episode promise, using separate value tables and promise IDs. Unrelated sidebar promises are not returned. It supports the JSON-shaped tables verified on this path, **not arbitrary devalue**: tagged special types and negative sentinels are explicit errors, never silently coerced to null. Out-of-range references, rejected/missing/duplicate episode chunks, cycles/excessive depth and decoded-size amplification are rejected. Depth is bounded to 64 and expanded values share a 2 MiB size budget. These are wrapper safety limits, not Miruro server limits.
|
||||||
|
|
||||||
|
The retained `cargo run --locked --example miruro_probe` smoke now covers search → detail/episodes only, not sources or playback.
|
||||||
|
|
||||||
|
### Historical wrapper/source probe evidence (before URL cutover)
|
||||||
|
|
||||||
|
The output and verification below describe the former source-capable wrapper, not the current example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
search: 8 records; selected Miruro ID OERe_a0YS4h-DlsBDOeXwX7HOO6316tw
|
||||||
|
detail: 2 episodes; selected episode 1
|
||||||
|
sources: 2 tracks; selected ssub/sun; 2 server headers retained
|
||||||
|
hls_master: HTTP 200; Content-Type application/vnd.apple.mpegurl
|
||||||
|
subtitle: HTTP 200; Content-Type text/vtt
|
||||||
|
PASS: native search -> detail/episodes -> sources -> HLS master + subtitle; no media segments or tokens saved
|
||||||
|
```
|
||||||
|
|
||||||
|
The preceding scratch probe also reported HTTP 200 for search (`application/json`), detail (`text/sveltekit-data`, seven records), and sources (`application/json`). Detail decoding found two episodes and selected `episode_number:1`; source decoding found two tracks. The master and default subtitle were fetched using the actual returned `Origin: https://ultracloud.cc` and `Referer: https://ultracloud.cc/`. URL/header necessity and full playback were not tested.
|
||||||
|
|
||||||
|
The former source-capable example used the documented title and `ssub/sun`, limiting requests to HTTPS `*.keeply.top` without redirects. It fetched no nested playlist, media segment, key, sprite or download page and saved no tokens/bodies. Those resource requests are no longer in the catalog example.
|
||||||
|
|
||||||
|
Wrapper-extraction verification, before TUI integration: `cargo test --locked` passed all 164 tests, including the episode-promise regression; `cargo clippy --locked --all-targets -- -D warnings` passed; `rustfmt --edition 2024 --check src/lib.rs src/miruro.rs examples/miruro_probe.rs` passed. The decoder regression covers independent tables, out-of-order unrelated chunks, preserved episode/absolute numbers and nulls, unresolved/rejected promises, bad references/cycles, unsupported tags and route redirects.
|
||||||
|
|
||||||
|
## Tubular → Terebi integration
|
||||||
|
|
||||||
|
The startup selector and `--miruro` open the dedicated catalog → series → episodes interface. `miruro_ui` owns navigation, metadata presentation, cached history and collections; `miruro_browser` supplies catalog/episode models and catalog/detail worker requests. Autocomplete returns at most eight titles. Episode Enter passes a stable URL to Terebi; no Tubular source request or projection remains.
|
||||||
|
|
||||||
|
Both browsers share `src/ui.rs` for layout, search editing/cursor scrolling, selection styling and loading animation. Miruro uses the same full-width results/status chrome and centered metadata/help/removal popups as YouTube, while preserving its own catalog/episode rows and navigation. Press `d` for metadata; arrows/Ctrl-j/k scroll it, while plain j/k change the selected row.
|
||||||
|
|
||||||
|
Titles normalizing to an empty slug fail explicitly; the wrapper does not guess an alternative detail route. This does not reject accented Latin titles, which use NFD decomposition.
|
||||||
|
|
||||||
|
### URL launch and ownership
|
||||||
|
|
||||||
|
Both providers use `State::progress_file(video)` and `PlayerLaunch::start(&video.id, player, progress_file)`. Miruro's `Identity::from_key(id)?.watch_url()?` produces:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
terebi --new-instance \
|
||||||
|
--url 'https://barelystarted.miruro.tv/watch/OPAQUE_ID/episode?ep=RETURNED_EPISODE' \
|
||||||
|
--progress-file '/private/session/progress.jsonl'
|
||||||
|
```
|
||||||
|
|
||||||
|
The literal `episode` slug is non-identifying. Terebi's parser uses opaque ID plus episode; metadata acquisition must safely resolve canonical-route redirect envelopes. See the actionable [Terebi URL-playback handoff](terebi-url-playback-handoff.md).
|
||||||
|
|
||||||
|
Tubular registers the expected stable identity in a private reporting session before launch. Terebi exclusively creates the report (`0600`); session directories remain private (`0700`). CLI exit acknowledges launch acceptance, not media readiness. Retention, the 4,096-session cap, explicit inactive-session cleanup and restart recovery remain unchanged; close corresponding players before removing sessions. No new `source.json`, source-v2 contract, segment file, persistent `Video` fields or collection migration is required.
|
||||||
|
|
||||||
|
Terebi owns fresh sources, headers, subtitles, playback titles, resume and optional timeline metadata for both direct and Tubular URL launches. Current native URL/progress support is not title/timeline completion: the title is currently `Miruro episode N`; title enrichment and intro/outro progress-bar bands remain Terebi work. Optional metadata failure must not prevent playback. No caller-specific source fallback, automatic skipping or skip buttons are requested.
|
||||||
|
|
||||||
|
### Progress and collections
|
||||||
|
|
||||||
|
Miruro JSONL reports use version 2:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"version":2,"event":"progress","media_identity":{"provider":"miruro","media_id":"opaque_ID","episode":1},"position_seconds":95.0,"duration_seconds":100.0,"completion_eligible":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
Tubular validates the version, namespace, media ID, episode, event and numeric fields, then compares the complete identity against that launch's private session marker. Cross-episode/provider/media records cannot admit or complete another episode. YouTube's canonical `media_url` and version-1 validation remain supported, including its existing unknown-field tolerance.
|
||||||
|
|
||||||
|
Matching acknowledged positive positions admit currently-watching entries. Watched status retains `completion_eligible && duration_seconds > 0 && position_seconds / duration_seconds >= 0.95`; null duration is ineligible. Seeking near the end can satisfy it. Launch/window closure alone cannot. Duplicate records, partial trailing lines and later reports recovered after Tubular restart retain the existing session-consumption behavior.
|
||||||
|
|
||||||
|
Collections and watched state use `miruro:<opaque ID>:<canonical episode>`. Catalog rows are not playable episode identities. Saved and old snapshot playback needs only that key, not a canonical slug, title-derived route or stored source URL. Terebi resolves fresh resources; CDN tokens never become collection keys. Each interface filters shared collections by provider. Only actual returned episode numbers are selectable; planned counts do not guarantee source availability.
|
||||||
|
|
||||||
|
### Historical source-file integration verification (before URL cutover)
|
||||||
|
|
||||||
|
The actual Tubular terminal workflow was exercised with isolated `$XDG_CONFIG_HOME` state and the updated Terebi developer bundle selected by `--player`: autocomplete returned eight titles; the selected title returned two episodes; episode 1 was saved to both collections, freshly resolved, and launched through `State::miruro_session` / `PlayerLaunch::start_source`.
|
||||||
|
|
||||||
|
The private source document retained Origin/Referer headers and nine VTT tracks with English declared default. Session mode `0700` and source/report modes `0600` were checked without logging resource URLs or header values. Actual Terebi v2 records matched the selected opaque ID/episode and acknowledged position advanced from about 7 to 141 seconds, with positive duration and completion eligibility. Tubular admitted that identity to its visible Currently watching list and persisted source-free collection metadata.
|
||||||
|
|
||||||
|
After Tubular exited successfully, its independent GUI continued appending matching v2 progress and the private source file remained byte-for-byte unchanged. That player/session was intentionally retained for the user's visible subtitle/seek check; no source cleanup was attempted while playback remained active.
|
||||||
|
|
||||||
|
Initial integrated checks passed: `cargo fmt --check`, all **175 tests**, `cargo clippy --locked --all-targets -- -D warnings`, `cargo build --locked`, and the built executable's `--help`. Regressions cover v1 compatibility, v2 identity/95%/null-duration boundaries, restart/partial-report recovery, source/session permissions and retention, collection routing, stale responses and canonical Unicode slugging. The installed Terebi CLI was older and lacked `--source-file`; the explicit developer-bundle override avoided changing that installation.
|
||||||
|
|
||||||
|
Startup provider-selection verification: **176 tests**, formatting, Clippy and build passed. Actual terminal runs showed the centered selector, routed both provider choices into their existing interfaces, carried an initial query into Miruro autocomplete, honored remapped selector keys, canceled on an encoded terminal Escape event, and preserved direct `--miruro` startup and noninteractive help/default-config. Bare Escape-byte injection in the automated terminal remained ambiguous; the complete terminal key encoding was exercised instead.
|
||||||
|
|
||||||
|
Those initial integration runs prove Tubular-to-Terebi launch, acknowledged playback/progress consumption and independent lifetime—not visible subtitle rendering, perceived A/V synchronization, or seeks through the user's installed release. English subtitle appearance/synchronization before and after seeking still requires a visible-app check.
|
||||||
|
|
||||||
|
### Dedicated-interface verification
|
||||||
|
|
||||||
|
The cutover passed **181 tests**, formatting, Clippy with warnings denied and build. Regressions cover cached catalog/series navigation, real episode identities, stale source rejection, provider-scoped collections and filtered removal, matching v2 completion, validated private source sessions, sanitized input/errors and configured controls.
|
||||||
|
|
||||||
|
Actual terminal runs exercised both chooser routes and direct `--miruro` startup. Miruro showed eight catalog titles with anime metadata, two returned episodes rather than the twelve planned episodes, selected-episode synopsis/date/duration above series metadata, cached back/forward selection and dedicated keyboard help. YouTube retained Search/Feed and its existing video/collection controls. Mixed-provider fixtures showed only the selected provider's entries; removing two Miruro saved episodes left the YouTube sentinel and both Miruro watch-later entries intact, including after restarting into YouTube. Configured F8/F9 controls were exercised; replaced default quit/interrupt keys no longer forced an exit.
|
||||||
|
|
||||||
|
A freshly resolved episode-2 source retained nine VTT tracks with English default, explicit stream/subtitle header names and the expected opaque episode identity. Terebi's existing `resolved-playback-check` decoded video/audio, advanced playback and passed default-VTT/forward/backward acknowledged cue checks. It also emitted three redacted FFmpeg error lines; their cause was not established. Session/source/report modes were `0700`/`0600`/`0600`; the source hash remained unchanged after Tubular exited.
|
||||||
|
|
||||||
|
The independent GUI accepted that launch and opened its private report, but the report remained empty: no GUI ready state, advancing GUI progress or current-watching admission is claimed for this run. The visible window needs a human check for a startup prompt or playback error, plus subtitle/seek synchronization. The native diagnostic's pass is not a GUI sign-off. Its active private session was retained rather than deleted.
|
||||||
|
|
||||||
|
## Remaining research questions
|
||||||
|
|
||||||
|
- Will this same native client remain accessible on other networks/deployments? Its sampled path passed; local curl previously failed, and no workaround is implemented.
|
||||||
|
- Is there a stable plain metadata/episode API? The wrapper currently owns the bounded JSON-shaped SvelteKit decoder for the observed deployment.
|
||||||
|
- What are server-side filter/enum constraints, multi-value semantics, cursor lifetime, page-size-cookie limits, numeric-ID lookups and slug behavior?
|
||||||
|
- What happens for unavailable episodes, empty tracks, provider failures, bad cursors, rejected promise chunks and syntactically valid missing IDs?
|
||||||
|
- Which headers/config transforms are required by providers other than the verified direct Sun path?
|
||||||
|
- Will other streams require redirects or cross-origin HLS resources that Terebi deliberately rejects? The Terebi team's handoff exercised direct Sun native decode, subtitles and acknowledged seeks, not every provider or comprehensive visible synchronization.
|
||||||
|
- What are seeded discovery authentication requirements, rate limits, token expiry/IP binding and content-use permissions? Reachable internal endpoints do not imply a supported third-party integration or permission to redistribute content.
|
||||||
|
|
||||||
|
Hashed JavaScript URLs below identify the inspected deployment, not stable versions. For later research, re-read the homepage's entry-script imports and [route map][routes] to locate the current bundles; do not hardcode these hashes in the wrapper.
|
||||||
|
|
||||||
|
[routes]: https://barelystarted.miruro.tv/_app/immutable/entry/app.BSfZufle.js
|
||||||
|
[layout]: https://barelystarted.miruro.tv/_app/immutable/nodes/0.TgQ_FRp6.js
|
||||||
|
[search-client]: https://barelystarted.miruro.tv/_app/immutable/nodes/5.B8psgBrv.js
|
||||||
|
[filter-options]: https://barelystarted.miruro.tv/_app/immutable/chunks/BFtFCiqs.js
|
||||||
|
[link-helpers]: https://barelystarted.miruro.tv/_app/immutable/chunks/DRUtYxUt.js
|
||||||
|
[detail-client]: https://barelystarted.miruro.tv/_app/immutable/nodes/6.BPCeS58O.js
|
||||||
|
[watch-client]: https://barelystarted.miruro.tv/_app/immutable/nodes/11.CVVa7Rmv.js
|
||||||
|
[home-client]: https://barelystarted.miruro.tv/_app/immutable/nodes/2.D7a6D6w9.js
|
||||||
|
[trending-client]: https://barelystarted.miruro.tv/_app/immutable/nodes/9.CWTtBRa5.js
|
||||||
|
[schedule-client]: https://barelystarted.miruro.tv/_app/immutable/nodes/7.BJ4_XLlf.js
|
||||||
|
[discover-client]: https://barelystarted.miruro.tv/_app/immutable/nodes/12.C2zcxgjP.js
|
||||||
|
[transport-client]: https://barelystarted.miruro.tv/_app/immutable/chunks/DGYr79W3.js
|
||||||
|
[homepage]: https://barelystarted.miruro.tv/
|
||||||
|
[strm-client]: https://strm.cx/client.js
|
||||||
|
[browse-live]: https://barelystarted.miruro.tv/api/search/browse?q=apothecary&limit=8&type=ANIME
|
||||||
|
[search-live]: https://barelystarted.miruro.tv/search/__data.json?q=apothecary
|
||||||
|
[search-all-live]: https://barelystarted.miruro.tv/search/__data.json
|
||||||
|
[empty-search-live]: https://barelystarted.miruro.tv/search/__data.json?q=zzzznoanimezzzz
|
||||||
|
[detail-live]: https://barelystarted.miruro.tv/info/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/the-apothecary-diaries-season-3/__data.json
|
||||||
|
[missing-detail-live]: https://barelystarted.miruro.tv/info/invalid/invalid/__data.json
|
||||||
|
[watch-live]: https://barelystarted.miruro.tv/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/the-apothecary-diaries-season-3/__data.json?ep=1
|
||||||
|
[sources-live]: https://barelystarted.miruro.tv/api/sources?id=OERe_a0YS4h-DlsBDOeXwX7HOO6316tw&n=1
|
||||||
|
[home-live]: https://barelystarted.miruro.tv/__data.json
|
||||||
|
[trending-live]: https://barelystarted.miruro.tv/trending/__data.json
|
||||||
|
[schedule-live]: https://barelystarted.miruro.tv/schedule/__data.json
|
||||||
|
[events-live]: https://barelystarted.miruro.tv/api/schedule?startAt=1791763200&track=all
|
||||||
|
[refs-live]: https://barelystarted.miruro.tv/api/discover/refs
|
||||||
|
[discover-live]: https://barelystarted.miruro.tv/discover/__data.json
|
||||||
|
[feed-live]: https://barelystarted.miruro.tv/discover/feed
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# Miruro video-segment feasibility
|
||||||
|
|
||||||
|
Investigated **2026-10-22**, scoped to **https://barelystarted.miruro.tv**. Investigation only: no production changes, media playback, playlist/segment/subtitle downloads, browser testing, or remote mutations.
|
||||||
|
|
||||||
|
**Conclusion (updated after the approved ownership split):** Miruro supplies optional episode-relative opening/ending intervals. Terebi must acquire and display them through its native episode-URL resolver; Tubular owns catalog/selection/collections/watched decisions only. Current Terebi does not display these intervals. The former source-file recommendation below is superseded by the [URL-playback handoff](terebi-url-playback-handoff.md). Availability is sparse; actual media-duration bounds are necessary but do not prove alignment across cuts.
|
||||||
|
|
||||||
|
Evidence labels: **Observed** = responses obtained today; **Frontend** = today's deployed first-party JavaScript, not a server guarantee; **Earlier** = the 2026-10-12 investigation in [miruro-api.md](miruro-api.md); **Recommendation** = proposed minimum behavior, not implemented.
|
||||||
|
|
||||||
|
## Live metadata
|
||||||
|
|
||||||
|
**Observed — historical native Tubular probe before URL cutover:** the then-existing `Client::detail`, `Client::sources`, and `SourceDocument::from_sources` path ran against the [detail route][detail] and [sources endpoint][sources]. The temporary example was removed afterward; `cargo run --locked --example miruro_segments_probe` describes that investigation, **not a permanent runnable command**. Source resolution/projection has since been removed from Tubular. Safe output projection:
|
||||||
|
|
||||||
|
```text
|
||||||
|
detail: 3 episodes
|
||||||
|
episode 1: absolute_number=49 duration_seconds=1500
|
||||||
|
skip_times=[{kind:op,start_seconds:63.313,end_seconds:153.237},
|
||||||
|
{kind:ed,start_seconds:1335.252,end_seconds:1425.384}]
|
||||||
|
episode 2: absolute_number=50 duration_seconds=1500 skip_times=[]
|
||||||
|
episode 3: absolute_number=51 duration_seconds=1500 skip_times=[]
|
||||||
|
sources top-level keys: config episodeNumber providerOrder tracks
|
||||||
|
projected source-document keys: headers identity subtitles title url version
|
||||||
|
```
|
||||||
|
|
||||||
|
Those are episode **1** intervals, not absolute episode 49 or a provider-specific episode identifier. Two empty arrays show why metadata must remain optional; they do not establish that those episodes have no opening/ending.
|
||||||
|
|
||||||
|
**Observed — watch metadata:** JavaScript `fetch` of the [watch route][watch] returned **HTTP 200**, **`text/sveltekit-data`**, 33,483 text characters. The newline-delimited SvelteKit response contained an `episodesData` promise chunk; decoding its indexed table yielded:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"episodes": [
|
||||||
|
{"number": 1, "duration": 1500},
|
||||||
|
{"number": 2, "duration": 1500},
|
||||||
|
{"number": 3, "duration": 1500}
|
||||||
|
],
|
||||||
|
"currentEpisode": {"number": 1},
|
||||||
|
"prevEpisode": null,
|
||||||
|
"nextEpisode": {"number": 2},
|
||||||
|
"aniskip": [
|
||||||
|
{"episode": 1, "type": "op", "start": 63.313, "end": 153.237},
|
||||||
|
{"episode": 1, "type": "ed", "start": 1335.252, "end": 1425.384}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is an abbreviated projection: episode objects have additional descriptive fields. The two `aniskip` objects are complete and have **no `provider`, `votes`, or `episode_length`**. Both routes express these intervals in seconds, retaining fractional precision; `skip_times` uses `kind/start_seconds/end_seconds`, whereas watch uses `type/start/end` plus the episode number. The sampled values match across both routes. `/api/sources` contains no segment field; a source-only request cannot recover these sampled intervals.
|
||||||
|
|
||||||
|
**Earlier vs current:** [miruro-api.md](miruro-api.md#detail-episodes-and-watch-data) recorded two available episodes on 2026-10-12; today's probes found three. Its detail/watch shapes and episode-1 intervals are revalidated above. Its old hashed frontend links are not today's deployment. The current watch Content-Type is `text/sveltekit-data`, so do not assume every route-data response is `application/json`. Native `ureq` success and previous curl/Cloudflare failure are transport-specific observations, not permission or an availability guarantee.
|
||||||
|
|
||||||
|
## Deployed first-party normalization
|
||||||
|
|
||||||
|
**Frontend:** today's [homepage][home] imports [`app.Bj5KeT2s.js`][app], whose route dictionary maps `/watch/[id]/[slug]` to node 11, [`11.DKyvW9qB.js`][client]. In that watch bundle, function `wn` and sets `xn`/`Sn` implement this complete mapping:
|
||||||
|
|
||||||
|
| Input `type` | Emitted player segment |
|
||||||
|
| --- | --- |
|
||||||
|
| `op`, `mixed-op` | `{kind:"intro", start, end}` |
|
||||||
|
| `ed`, `mixed-ed` | `{kind:"outro", start, end}` |
|
||||||
|
| Any other value, including `recap` | No segment from this normalizer |
|
||||||
|
|
||||||
|
Only `op` and `ed` occurred in today's metadata. The mixed variants are **frontend-supported**, not live-observed API records. There is no evidenced exhaustive server enum, recap interval, preview interval, provider-specific interval, or mixed-kind decomposition. In particular, `mixed-op` is treated as one whole intro interval, not split into opening and recap portions.
|
||||||
|
|
||||||
|
The algorithm in `wn(records, episodeNumber, duration)` is:
|
||||||
|
|
||||||
|
1. Return `[]` when records are empty, the episode is null, or duration is absent/nonpositive.
|
||||||
|
2. Reject a record when its optional `provider`, lowercased, contains `crunchyroll`; its `episode` is not strictly equal to the selected episode number; or `(votes ?? 0) < 0`.
|
||||||
|
3. Require finite `start` and `end`, `start >= 0`, `end > start`, and `end <= duration` (an end exactly at duration is allowed).
|
||||||
|
4. When `episode_length` is a positive number, require `abs(episode_length - duration) / duration <= 0.05`. Missing/non-number/nonpositive lengths bypass this comparison. This optional field was absent in the sample, so catalog duration 1500 does **not** trigger that 5% check by itself.
|
||||||
|
5. Sort survivors by descending `(votes ?? 0)`. Take the first opening-kind record and the first ending-kind record. Emit intro first, then outro; at most two segments. It does not merge, scale, shift, trim, or resolve overlapping intro/outro intervals.
|
||||||
|
|
||||||
|
Useful anchors in the minified [bundle][client]: `Cn=.05`, `function wn`, `provider?.toLowerCase().includes(\`crunchyroll\`)`, `episode_length`, and `strmcx-duration-change`. The provider check concerns the **metadata record's provenance**, not equality with the currently selected stream provider; the algorithm has no selected-provider argument. Vote/provider/duration-length rules are consumer behavior, not evidence that Miruro currently returns those optional fields.
|
||||||
|
|
||||||
|
**Frontend timing/lifecycle:** the player component starts with empty segments and resets them, together with duration, during source resolution. A `strmcx-duration-change` event with positive numeric duration invokes `wn` using player-reported duration, the selected episode number, and watch `aniskip`. The normalized array is passed as `segments` to the strm.cx session, separately from the `options.autoskip` preference. Catalog `episode.duration` is not used as the normalization duration. Iframe playback takes a separate embedded-URL path rather than this direct-player session; the inspected code does not establish segment handoff to an arbitrary iframe provider.
|
||||||
|
|
||||||
|
## Limits and minimum recommendation
|
||||||
|
|
||||||
|
- **Observed:** metadata exists and is not tied to an expiring source URL. **Unknown:** coverage across the catalog, a stable/public metadata contract, how timings were generated, non-opening/ending availability, and alignment across providers/dubs/cuts. Merely being in bounds does not prove synchronization with a particular cut.
|
||||||
|
- **Recommendation:** reuse the existing detail episode metadata; retain selected episode identity, optional raw kinds, and fractional second boundaries alongside it. Normalize only the four evidenced opening/ending kinds for initial intro/outro behavior. Do not relabel recap or unknown kinds as intro/outro or invent missing intervals.
|
||||||
|
- **Approved seam (supersedes the earlier source-session recommendation):** Terebi's existing episode-URL resolver owns optional metadata acquisition and validation against actual media duration. Direct and Tubular launches must use the same path. No source-document version change, separate segment file, new endpoint or Tubular metadata persistence is required. Preserve optional provenance/reference-length fields if available; do not invent fields absent from the sample or claim bounds prove synchronization.
|
||||||
|
- **Recommendation:** address every launch path, including saved/watch-later/currently-watching reopening, rather than enriching only a currently open catalog episode.
|
||||||
|
|
||||||
|
## Tubular → Terebi seam
|
||||||
|
|
||||||
|
The approved implementation contract and acceptance scenarios are consolidated in [Terebi URL-playback handoff](terebi-url-playback-handoff.md). Tubular launches the stable opaque ID and returned episode number through `PlayerLaunch::start`, using a non-identifying `episode` watch slug. Old/offline snapshots need no new fields or migration. Terebi owns canonical-route resolution, title enrichment and optional timeline metadata; title text is not identity.
|
||||||
|
|
||||||
|
The requested UI is **intro/outro bands in the controls progress bar only**, not automatic skipping or skip buttons. Validate episode binding, finite ordered nonnegative times and actual-duration bounds; clear metadata on media replacement. Empty, unavailable or invalid metadata must not prevent ordinary playback, and unknown kinds must not be relabeled.
|
||||||
|
|
||||||
|
### Historical source-file decoder evidence (superseded transport)
|
||||||
|
|
||||||
|
Before this cutover, Tubular retained `skip_times` in browsing metadata but omitted it from generic `Video` snapshots and source documents. Terebi's source-v1 decoder did not retain intervals. The following isolated decoder observation explains why adding JSON fields alone would not have implemented timeline display; it is not a recommendation to restore source files.
|
||||||
|
|
||||||
|
**Observed — actual decoder smoke:** compiled the existing `ResolvedSource.swift` with a temporary Foundation-only harness, then called `ResolvedSource.load(from:)` on synthetic local source documents. No player/network/GUI was started.
|
||||||
|
|
||||||
|
```text
|
||||||
|
interval-array: accepted; segments absent from ResolvedSource
|
||||||
|
wrong-type: accepted; segments absent from ResolvedSource
|
||||||
|
version-2: rejected
|
||||||
|
```
|
||||||
|
|
||||||
|
Thus merely adding `segments` to a version-1 JSON file would silently discard it, even with a malformed value. Changing the version alone also does not work. This is a source-document version, independent of the existing version-2 progress records.
|
||||||
|
|
||||||
|
The earlier recommendation to introduce a new source-document version and carry intervals through Tubular is **superseded**. No source-v2 or segment file is required. Terebi's native URL resolver already owns `/api/sources`; it must additionally acquire title and optional episode metadata via canonical route data for all callers. See the handoff for lifecycle and acceptance requirements. Existing source-v1 decoder observations above remain historical evidence only.
|
||||||
|
|
||||||
|
|
||||||
|
## Fetch record
|
||||||
|
|
||||||
|
All requests were read-only. Static source was inspected, not executed as a webpage. No expiring source URL/token was printed or saved.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Research eval JavaScript transport; each response's status/type was inspected.
|
||||||
|
await fetch("https://barelystarted.miruro.tv/");
|
||||||
|
await fetch("https://barelystarted.miruro.tv/_app/immutable/entry/app.Bj5KeT2s.js");
|
||||||
|
await fetch("https://barelystarted.miruro.tv/_app/immutable/nodes/11.DKyvW9qB.js");
|
||||||
|
await fetch("https://barelystarted.miruro.tv/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/the-apothecary-diaries-season-3/__data.json?ep=1");
|
||||||
|
```
|
||||||
|
|
||||||
|
The homepage/app were first fetched with the static `read` tool (`:raw` for imports); the watch bundle was read and then fetched with eval: **200 `text/javascript`**, 84,045 characters. Watch route: **200 `text/sveltekit-data`**, 33,483 characters; only decoded episode/segment metadata was printed, never `sourcesData`. The native detail/source probe is documented above. The decoder smoke used `swiftc -parse-as-library ../terebi/Sources/App/ResolvedSource.swift <temporary harness> -o <temporary binary>`, followed by running that binary. Both temporary source harnesses and the decoder's generated fixture/binary were removed. No test suites, linters, formatters, full application builds or UI smoke tests were run; only the native metadata probe and isolated decoder harness were compiled and executed.
|
||||||
|
|
||||||
|
[home]: https://barelystarted.miruro.tv/
|
||||||
|
[app]: https://barelystarted.miruro.tv/_app/immutable/entry/app.Bj5KeT2s.js
|
||||||
|
[client]: https://barelystarted.miruro.tv/_app/immutable/nodes/11.DKyvW9qB.js
|
||||||
|
[detail]: https://barelystarted.miruro.tv/info/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/the-apothecary-diaries-season-3/__data.json
|
||||||
|
[watch]: https://barelystarted.miruro.tv/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/the-apothecary-diaries-season-3/__data.json?ep=1
|
||||||
|
[sources]: https://barelystarted.miruro.tv/api/sources?id=OERe_a0YS4h-DlsBDOeXwX7HOO6316tw&n=1
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# WCO feasibility investigation
|
||||||
|
|
||||||
|
Observed **2026-10-20 UTC**. Scope: adding Western-cartoon browsing and native playback to Tubular, using ordinary HTTPS without browser automation, challenge/login/premium bypass, fabricated ad-verification results, or weakened TLS. No production implementation was added.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Browsing is feasible for the sampled catalog. Native video extraction and playback are not established.** The current declared free-player route reaches a JavaScript ad-verification step; investigation stopped before submitting that verification or requesting the post-gate player. Separately, the current Tubular/Terebi source and progress contracts reject an honest WCO identity.
|
||||||
|
|
||||||
|
Do not build another full browser UI or weaken Terebi's network policy on this evidence alone. The missing prerequisite is a legitimately obtained final media URL, its required headers, and a successful native decode/seek probe. Nothing below demonstrates that WCO cannot ever support a native client.
|
||||||
|
|
||||||
|
Evidence labels:
|
||||||
|
|
||||||
|
- **Observed:** live requests or an executed check in this investigation.
|
||||||
|
- **Source:** current site markup, JavaScript, or implementation inspection.
|
||||||
|
- **[INFERENCE]:** architectural implication, not verified WCO playback.
|
||||||
|
|
||||||
|
## Live native HTTP evidence
|
||||||
|
|
||||||
|
A temporary Rust example used the project's existing `ureq 3.4.2` dependency with its `rustls` feature: default agent, TLS verification enabled, 25-second global timeout, `http_status_as_error(false)`, and zero automatic redirects. Responses were bounded to 512 KiB, or 4 KiB for the artwork signature check. No cookies, browser impersonation, proxy, script execution, or challenge solution was supplied. Player/initialization requests carried their referring page explicitly.
|
||||||
|
|
||||||
|
| Resource | Transport | HTTP | Content-Type | Body bytes read |
|
||||||
|
| --- | --- | ---: | --- | ---: |
|
||||||
|
| [Cartoon directory][directory] | ureq/rustls | 200 | text/html | 311285 |
|
||||||
|
| [OTGW series][otgw] | ureq/rustls | 200 | text/html | 42840 |
|
||||||
|
| [Adventure Time series][adventure] | ureq/rustls | 200 | text/html | 148050 |
|
||||||
|
| [OTGW episode 1][episode] | ureq/rustls | 200 | text/html | 39154 |
|
||||||
|
| Episode's declared iframe, `embed.wcostream.com/inc/embed/index.php` | ureq/rustls | 200 | text/html | 9177 |
|
||||||
|
| [Declared player initialization][preinit] | ureq/rustls | 200 | application/javascript | 3871 |
|
||||||
|
| [OTGW cover][cover] | ureq/rustls | 200 | image/jpeg | 4096 |
|
||||||
|
| Same OTGW episode-1 URL | Python urllib | 403 | text/html | 5601 |
|
||||||
|
| Same OTGW episode-1 URL | curl | 403 | text/html | 5580 |
|
||||||
|
|
||||||
|
**Observed:** the cover prefix has a JPEG signature. Full image decoding was not tested. Native successful page responses contained neither the Cloudflare challenge markers checked nor the explicit premium-only marker. Python's episode response contained Cloudflare's “Just a moment”; curl independently returned 403 HTML. Accessibility is transport-dependent; successful web-reader access alone would not have established local native access.
|
||||||
|
|
||||||
|
## Smallest catalog integration
|
||||||
|
|
||||||
|
**Observed/Source:** the directory and series metadata are server-rendered. No JavaScript execution is needed to read the sampled listings.
|
||||||
|
|
||||||
|
1. `GET /cartoon-list`; parse `#ddmcc_container` title/series links.
|
||||||
|
2. Filter that Western-cartoon directory locally. The site's own directory search performs case-insensitive substring filtering, not another API request.
|
||||||
|
3. Fetch the selected series page for description, genres, cover reference, season labels, and `#episodeList a.dark-episode-item` entries.
|
||||||
|
4. Preserve the provider's actual playable-item labels and opaque page paths.
|
||||||
|
|
||||||
|
The global search form declares **POST `/search`**, with `catara=<query>` and `konuara=series` or `episodes`; the input declares a 40-character maximum. This is a markup contract, **not a successful live POST probe**. Local directory filtering avoids depending on that unverified endpoint for Western-cartoon title search. [Site form][otgw]; [reference search implementation][kodi-search].
|
||||||
|
|
||||||
|
Series identity should use the observed canonical provider path, not a slug generated from the display title or the numeric artwork filename. The directory contains historical paths such as `/anime/american-dad-season-1` for “American Dad!”. Long-term URL stability has not been established. [Directory][directory].
|
||||||
|
|
||||||
|
Artwork is available through `og:image` and `#sidebar_cat img.img5`; normalize scheme-relative references. Sidebar prose and genre links are available, but quality varies: Adventure Time's synopsis describes its pilot rather than the whole series. No episode runtime, publication date, or chapter timestamps were established.
|
||||||
|
|
||||||
|
### Identity and ordering traps
|
||||||
|
|
||||||
|
- OTGW has **eight page entries covering ten numbered segments**: 1–6 separately, 7–8 together, 9–10 together. These eight links were also extracted from the native response.
|
||||||
|
- Adventure Time season 1 labels one video “Episode 1 Slumber Party Panic / Trouble in Lumpy Space”; season 10 has a combined “Episode 13–16 Come Along with Me”. Pilot, specials, Distant Lands and Side Quests coexist on the series page.
|
||||||
|
- Map `data-season` through `#seasonFilter option[value]`; preserve the token and label. Adventure Time includes `s-1-0`, labeled “Special: Distant Lands”.
|
||||||
|
- Read the direct title span, not all anchor text: quality/finale badges are separate metadata.
|
||||||
|
- Preserve source order initially. It is mostly descending, but not consistently numerically sorted.
|
||||||
|
- Use an opaque episode-page identity, not series ID plus an assumed single positive episode number. Do not synthesize chapters, segment boundaries, or separate watched states from slash-separated titles.
|
||||||
|
|
||||||
|
Sources: [OTGW][otgw], [Adventure Time][adventure]. The current Miruro identity cannot be reused unchanged: [`src/miruro.rs:28-77`](../src/miruro.rs).
|
||||||
|
|
||||||
|
## Actual page-to-player boundary
|
||||||
|
|
||||||
|
**Observed:** ordinary native requests reached this sequence:
|
||||||
|
|
||||||
|
1. The episode HTML contains a statically encoded iframe string. Its visible script applies Base64 decoding, retains decimal digits, subtracts a page-supplied constant, then constructs characters. This transform was decoded as data; no downloaded JavaScript was executed.
|
||||||
|
2. The decoded iframe targets `embed.wcostream.com/inc/embed/index.php` with episode query parameters.
|
||||||
|
3. That iframe returns an **“Announcement”** document, not an MP4/HLS URL. Its close button is initially disabled, with a 10-second countdown and a 12-second fallback timer.
|
||||||
|
4. The document loads [`/inc/embed/pre-init.js?v2`][preinit]. That script generates a browser nonce, performs DOM ad-bait and script-loading checks, and sends an `/ad-verify` beacon containing the nonce, video ID and `clear`/`blocked` result.
|
||||||
|
5. Closing the announcement normally navigates to `video-js.php`, using `AdGate.buildUrl()` to append the nonce. The page also has a fallback navigation if `AdGate` is unavailable; that fallback was not requested to skip verification.
|
||||||
|
|
||||||
|
**Boundary:** no `/ad-verify` request was submitted; no DOM results were fabricated, scripts executed, announcement flow skipped, alternative player endpoint substituted, premium workaround invoked, or post-gate player/media requested. Server-side enforcement of the nonce was **not tested**. A script containing a gate is not proof that every WCO stream is inaccessible to a native client.
|
||||||
|
|
||||||
|
The [Kodi resolver][kodi-resolver] is reference code, not a proven current contract. It rewrites `index.php` to `video-js-new.php`, parses internal source JSON, resolves media redirects, and contains separate premium-workaround paths. Those behaviors were not transplanted or exercised. Its recent maintenance does not establish that this sample is playable through Tubular's permitted flow.
|
||||||
|
|
||||||
|
**Unverified:** current source-JSON schema after the gate; final media type/codecs; required headers/cookies; redirect chain; URL expiration/IP binding; byte-range/seek support; nested HLS origins; subtitles; actual Terebi playback. No episode media was downloaded.
|
||||||
|
|
||||||
|
## Actual Tubular/Terebi compatibility
|
||||||
|
|
||||||
|
### Executed source-contract smoke
|
||||||
|
|
||||||
|
A temporary Foundation-only Swift executable compiled **the actual** `../terebi/Sources/App/ResolvedSource.swift` unchanged. It validated a synthetic, otherwise valid source document with provider `miruro`, then changed only the provider to `wco` and required rejection. The synthetic example URL was not fetched.
|
||||||
|
|
||||||
|
Observed output, exit 0:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Actual Terebi validator: Miruro control accepted; identical document with provider=wco rejected
|
||||||
|
```
|
||||||
|
|
||||||
|
This proves source-contract rejection, not anything about live media decoding. No player UI was launched.
|
||||||
|
|
||||||
|
### Required integration changes if usable media is established
|
||||||
|
|
||||||
|
- **Provider selection:** Tubular's chooser is Boolean/two-way, not a third-provider interface: `src/main.rs:3674-3698,3762-3774`.
|
||||||
|
- **Honest stable identity:** Tubular's `Identity` validation/key parsing and Terebi's source validator hardcode Miruro. WCO needs a provider-correct opaque playback-item identity that accommodates paired episodes and specials. Do not disguise WCO media as Miruro. `src/miruro.rs:28-77`; `../terebi/Sources/App/ResolvedSource.swift:84-102`.
|
||||||
|
- **Native admission and safety:** native progress identity validation accepts only canonical YouTube URLs or `miruro:`. Resolved-source security classification also uses `starts_with("miruro:")`. Updating Swift admission alone is insufficient and would not apply the protected resolved-source path. `../terebi/Sources/Core/progress_reporter.cpp:62-90`; `../terebi/Sources/Core/player.cpp:565-567,1460-1466`.
|
||||||
|
- **Progress/state:** native version-2 reporting emits a literal Miruro provider. Tubular sessions, snapshot validation, report admission and provider-specific collection filtering also need coordinated changes. Broadening the Miruro identity parser without updating its UI callers would mix WCO records into Miruro collections. `../terebi/Sources/Core/progress_reporter.cpp:163-186`; `src/video_state.rs:379-396,651-669`; `src/miruro_ui.rs:265-268,989-990,1938-1941`.
|
||||||
|
- **Reuse, not another player:** the existing source launcher already passes private source/progress files to Terebi. Resume storage is identity-keyed. No new playback framework is indicated. `src/youtube.rs:937-981`; `../terebi/Sources/App/TerebiApp.swift:516-524,1525-1530`; `../terebi/Sources/App/PlaybackProgress.swift:24-35,54-57`.
|
||||||
|
|
||||||
|
**[INFERENCE] Direct MP4 should fit the general native media architecture.** Source-file validation is not HLS-only; native FFmpeg opens input without forcing a demuxer and discovers audio/video streams. The HLS-only rule belongs to Miruro's Sun projection, not the general source document. WCO must not be routed through that projection. `src/miruro.rs:101-178`; `../terebi/Sources/Core/player.cpp:543-547,565-580`.
|
||||||
|
|
||||||
|
The existing protected network path rejects **all media redirects**, even same-origin redirects, and requires HLS variants/segments/keys/init resources to share the initial media origin. Preserve that policy. A resolver would need to obtain an already-final acceptable URL through a separately reviewed extraction flow; no need for redirect/cross-origin support has yet been established. `../terebi/Sources/Core/player.cpp:111-126,532-537`.
|
||||||
|
|
||||||
|
The existing `resolved-playback-check` is not an unchanged subtitle-free WCO smoke: it requires a default subtitle and two cues. The full player suite also requires graphical/audio resources. Neither was run. See `../terebi/Checks/resolved-playback.swift:24-80` and `../terebi/README.md:178-190`.
|
||||||
|
|
||||||
|
## Recommendation and limits
|
||||||
|
|
||||||
|
- **Go:** a native WCO catalog is evidenced for the sampled directory, two shows, episode page and cover host.
|
||||||
|
- **Not yet go:** do not implement end-to-end WCO playback on the strength of catalog access or old extractor code.
|
||||||
|
- **Next prerequisite:** observe/document an authorized normal free-player route through the verification boundary, or obtain a supported native playback interface. Browser observation would need explicit permission and would not itself prove native extraction. Do not emulate successful ad-verification results or weaken security to create a passing demo.
|
||||||
|
- **Then:** resolve one real item, inspect only bounded media/manifest data with explicit headers, and verify native decode, duration and seek behavior before implementing browsing/state changes. Test a combined entry as well as a single episode before claiming identity correctness.
|
||||||
|
|
||||||
|
Investigation changes are this report only; temporary HTTP/Swift probes and response files were removed. No application behavior, player source, dependency, remote state, or persistent stream token was changed. Findings are a point-in-time sample, not a service reliability or distribution-rights guarantee.
|
||||||
|
|
||||||
|
[directory]: https://www.wco.tv/cartoon-list
|
||||||
|
[otgw]: https://www.wco.tv/anime/over-the-garden-wall
|
||||||
|
[adventure]: https://www.wco.tv/anime/adventure-time
|
||||||
|
[episode]: https://www.wco.tv/over-the-garden-wall-episode-1-the-old-grist-mill
|
||||||
|
[preinit]: https://embed.wcostream.com/inc/embed/pre-init.js?v2
|
||||||
|
[cover]: https://images.wcostream.com/catimg/161158.jpg
|
||||||
|
[kodi-search]: https://github.com/christianhaitian/plugin.video.watchnixtoons2/blob/kodi19/lib/plugin.py#L1201-L1263
|
||||||
|
[kodi-resolver]: https://github.com/christianhaitian/plugin.video.watchnixtoons2/blob/kodi19/lib/plugin.py#L1353-L1641
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
//! Read-only native smoke: cargo run --locked --example anilist_probe
|
||||||
|
use std::io;
|
||||||
|
use tubular::{
|
||||||
|
anilist::{Client, catalog_count},
|
||||||
|
anime::Identity,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn main() -> io::Result<()> {
|
||||||
|
let client = Client::default();
|
||||||
|
let results = client.search("Cowboy Bebop")?;
|
||||||
|
let media = results
|
||||||
|
.iter()
|
||||||
|
.find(|media| media["id"] == "1")
|
||||||
|
.ok_or_else(|| io::Error::other("Exact Cowboy Bebop AniList ID 1 absent from search"))?;
|
||||||
|
let id = media["id"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| io::Error::other("Missing catalog ID"))?;
|
||||||
|
let detail = client.detail(id)?;
|
||||||
|
let count = catalog_count(&detail)?.ok_or_else(|| io::Error::other("Catalog count unknown"))?;
|
||||||
|
if count == 0 {
|
||||||
|
return Err(io::Error::other("No catalog episode number 1"));
|
||||||
|
}
|
||||||
|
let identity = Identity::anilist(id.into(), 1)?;
|
||||||
|
println!(
|
||||||
|
"native search: {} records; exact AniList ID {id}",
|
||||||
|
results.len()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"detail: {count} catalog episode numbers; selected episode 1; source not checked by this probe"
|
||||||
|
);
|
||||||
|
println!("identity: {}", identity.key());
|
||||||
|
println!("watch URL: {}", identity.watch_url()?);
|
||||||
|
println!(
|
||||||
|
"PASS: native AniList search -> exact detail -> catalog episode -> stable sub URL (no source check or playback request)"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
//! Live read-only smoke: cargo run --locked --example miruro_probe
|
||||||
|
//! Exercises native catalog metadata and stable episode URLs; no playback requests.
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
use tubular::{anime::Identity, miruro::Client};
|
||||||
|
|
||||||
|
fn invalid(message: &str) -> io::Error {
|
||||||
|
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> io::Result<()> {
|
||||||
|
let client = Client::default();
|
||||||
|
let results = client.search("apothecary")?;
|
||||||
|
let media = results
|
||||||
|
.iter()
|
||||||
|
.find(|media| media["id"] == "OERe_a0YS4h-DlsBDOeXwX7HOO6316tw")
|
||||||
|
.ok_or_else(|| invalid("Research title no longer appears in search"))?;
|
||||||
|
let id = media["id"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| invalid("Missing media ID"))?;
|
||||||
|
println!("search: {} records; selected Miruro ID {id}", results.len());
|
||||||
|
let detail = client.detail(id, "the-apothecary-diaries-season-3")?;
|
||||||
|
let number = detail
|
||||||
|
.episodes
|
||||||
|
.first()
|
||||||
|
.and_then(|episode| episode["episode_number"].as_u64())
|
||||||
|
.and_then(|number| u32::try_from(number).ok())
|
||||||
|
.ok_or_else(|| invalid("No episode metadata available"))?;
|
||||||
|
println!(
|
||||||
|
"detail: {} returned episodes; selected episode {number}",
|
||||||
|
detail.episodes.len()
|
||||||
|
);
|
||||||
|
let identity = Identity::miruro(id.into(), number)?;
|
||||||
|
println!("watch URL: {}", identity.watch_url()?);
|
||||||
|
println!("PASS: native search -> detail/episodes -> stable watch URL; no playback requests");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
+223
@@ -0,0 +1,223 @@
|
|||||||
|
//! AniList metadata only. Episode numbers are catalog information, never stream availability.
|
||||||
|
use crate::anime::positive_decimal;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::{io, time::Duration};
|
||||||
|
|
||||||
|
const FIELDS: &str = "id title { english romaji native } format status episodes season seasonYear description(asHtml: false) coverImage { extraLarge large medium } bannerImage duration averageScore genres source countryOfOrigin nextAiringEpisode { episode }";
|
||||||
|
const BODY_LIMIT: u64 = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
pub struct Client {
|
||||||
|
agent: ureq::Agent,
|
||||||
|
}
|
||||||
|
impl Default for Client {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
agent: ureq::Agent::config_builder()
|
||||||
|
.timeout_global(Some(Duration::from_secs(20)))
|
||||||
|
.http_status_as_error(false)
|
||||||
|
.max_redirects(0)
|
||||||
|
.build()
|
||||||
|
.new_agent(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Client {
|
||||||
|
pub fn search(&self, query: &str) -> io::Result<Vec<Value>> {
|
||||||
|
let query = query.trim();
|
||||||
|
if query.is_empty() || query.len() > 4096 {
|
||||||
|
return Err(invalid("AniList query must contain 1–4096 bytes"));
|
||||||
|
}
|
||||||
|
let mut response = self.query(&format!("query ($search: String!) {{ Page(page: 1, perPage: 8) {{ media(search: $search, type: ANIME, sort: SEARCH_MATCH) {{ {FIELDS} }} }} }}"), json!({"search": query}))?;
|
||||||
|
let Some(Value::Array(items)) = response.pointer_mut("/Page/media").map(Value::take) else {
|
||||||
|
return Err(invalid("AniList search is missing its media array"));
|
||||||
|
};
|
||||||
|
if items.len() > 8 {
|
||||||
|
return Err(invalid("AniList search exceeded eight results"));
|
||||||
|
}
|
||||||
|
let mut ids = std::collections::HashSet::new();
|
||||||
|
items
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| {
|
||||||
|
let media = normalize(item)?;
|
||||||
|
if !ids.insert(media["id"].as_str().unwrap().to_owned()) {
|
||||||
|
return Err(invalid("AniList returned duplicate IDs"));
|
||||||
|
}
|
||||||
|
Ok(media)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
pub fn detail(&self, id: &str) -> io::Result<Value> {
|
||||||
|
let id_number = positive_decimal(id)?;
|
||||||
|
let mut response = self.query(
|
||||||
|
&format!("query ($id: Int!) {{ Media(id: $id, type: ANIME) {{ {FIELDS} }} }}"),
|
||||||
|
json!({"id": id_number}),
|
||||||
|
)?;
|
||||||
|
let media = response
|
||||||
|
.get_mut("Media")
|
||||||
|
.filter(|v| !v.is_null())
|
||||||
|
.map(Value::take)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
io::Error::new(io::ErrorKind::NotFound, "AniList catalog title not found")
|
||||||
|
})?;
|
||||||
|
let media = normalize(media)?;
|
||||||
|
if media["id"].as_str() != Some(id) {
|
||||||
|
return Err(invalid("AniList detail ID does not match request"));
|
||||||
|
}
|
||||||
|
Ok(media)
|
||||||
|
}
|
||||||
|
fn query(&self, query: &str, variables: Value) -> io::Result<Value> {
|
||||||
|
let body = json!({"query": query, "variables": variables}).to_string();
|
||||||
|
let mut response = self
|
||||||
|
.agent
|
||||||
|
.post("https://graphql.anilist.co")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.send(body.as_str())
|
||||||
|
.map_err(|_| io::Error::other("AniList network request failed"))?;
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
if status != 200 {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
match status {
|
||||||
|
401 | 403 => io::ErrorKind::PermissionDenied,
|
||||||
|
404 => io::ErrorKind::NotFound,
|
||||||
|
_ => io::ErrorKind::Other,
|
||||||
|
},
|
||||||
|
format!("AniList HTTP {status}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if response.body().mime_type() != Some("application/json") {
|
||||||
|
return Err(invalid("AniList returned non-JSON content"));
|
||||||
|
}
|
||||||
|
let body = crate::read_catalog_body(response.body_mut(), BODY_LIMIT)
|
||||||
|
.map_err(|_| invalid("AniList body unreadable, timed out, or exceeded 2 MiB"))?;
|
||||||
|
decode(&body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn decode(body: &str) -> io::Result<Value> {
|
||||||
|
let mut value: Value =
|
||||||
|
serde_json::from_str(body).map_err(|_| invalid("Malformed AniList JSON"))?;
|
||||||
|
if let Some(errors) = value.get("errors").filter(|v| !v.is_null()) {
|
||||||
|
let errors = errors
|
||||||
|
.as_array()
|
||||||
|
.ok_or_else(|| invalid("Malformed AniList GraphQL errors"))?;
|
||||||
|
if !errors.is_empty() {
|
||||||
|
return Err(io::Error::other(
|
||||||
|
"AniList GraphQL request failed (catalog data not accepted)",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value
|
||||||
|
.get_mut("data")
|
||||||
|
.filter(|v| v.is_object())
|
||||||
|
.map(Value::take)
|
||||||
|
.ok_or_else(|| invalid("AniList response is missing data"))
|
||||||
|
}
|
||||||
|
/// Normalize metadata field names to the existing anime UI, retaining explicit nulls.
|
||||||
|
fn normalize(mut media: Value) -> io::Result<Value> {
|
||||||
|
let id = media
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.and_then(|n| u32::try_from(n).ok())
|
||||||
|
.filter(|n| *n > 0 && *n <= i32::MAX as u32)
|
||||||
|
.ok_or_else(|| invalid("Invalid AniList catalog ID"))?;
|
||||||
|
let object = media
|
||||||
|
.as_object_mut()
|
||||||
|
.ok_or_else(|| invalid("Invalid AniList media"))?;
|
||||||
|
object.insert("id".into(), Value::String(id.to_string()));
|
||||||
|
object.insert("catalog_provider".into(), Value::String("anilist".into()));
|
||||||
|
for (source, target) in [
|
||||||
|
("episodes", "episode_count"),
|
||||||
|
("seasonYear", "season_year"),
|
||||||
|
("duration", "episode_duration_minutes"),
|
||||||
|
("averageScore", "average_score"),
|
||||||
|
("countryOfOrigin", "country_of_origin"),
|
||||||
|
] {
|
||||||
|
let value = object.get(source).cloned().unwrap_or(Value::Null);
|
||||||
|
object.insert(target.into(), value);
|
||||||
|
}
|
||||||
|
let cover = object
|
||||||
|
.get("coverImage")
|
||||||
|
.and_then(|v| {
|
||||||
|
v.get("extraLarge")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.or_else(|| v.get("large").and_then(Value::as_str))
|
||||||
|
})
|
||||||
|
.map(str::to_owned);
|
||||||
|
object.insert(
|
||||||
|
"cover_url".into(),
|
||||||
|
cover.map(Value::String).unwrap_or(Value::Null),
|
||||||
|
);
|
||||||
|
let count = catalog_count(&media)?;
|
||||||
|
media.as_object_mut().unwrap().insert(
|
||||||
|
"catalog_count".into(),
|
||||||
|
count.map(|n| json!(n)).unwrap_or(Value::Null),
|
||||||
|
);
|
||||||
|
Ok(media)
|
||||||
|
}
|
||||||
|
/// Finished totals are catalog numbers; releasing totals remain plans, not aired episodes.
|
||||||
|
pub fn catalog_count(media: &Value) -> io::Result<Option<u32>> {
|
||||||
|
let number = |value: Option<&Value>| -> io::Result<Option<u32>> {
|
||||||
|
match value {
|
||||||
|
None | Some(Value::Null) => Ok(None),
|
||||||
|
Some(value) => value
|
||||||
|
.as_u64()
|
||||||
|
.and_then(|n| u32::try_from(n).ok())
|
||||||
|
.filter(|n| *n <= i32::MAX as u32)
|
||||||
|
.map(Some)
|
||||||
|
.ok_or_else(|| invalid("Invalid AniList episode count")),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let total = number(media.get("episode_count"))?;
|
||||||
|
match media.get("status").and_then(Value::as_str) {
|
||||||
|
Some("FINISHED") => Ok(total),
|
||||||
|
Some("NOT_YET_RELEASED") => Ok(Some(0)),
|
||||||
|
Some("RELEASING") => {
|
||||||
|
let next = number(media.pointer("/nextAiringEpisode/episode"))?;
|
||||||
|
if next == Some(0) {
|
||||||
|
return Err(invalid("Invalid AniList next airing episode"));
|
||||||
|
}
|
||||||
|
let aired = next.map(|n| n - 1);
|
||||||
|
if aired.zip(total).is_some_and(|(aired, total)| aired > total) {
|
||||||
|
return Err(invalid("AniList aired count exceeds planned total"));
|
||||||
|
}
|
||||||
|
Ok(aired)
|
||||||
|
}
|
||||||
|
_ => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn invalid(message: &str) -> io::Error {
|
||||||
|
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn catalog_boundaries_and_graphql_errors() {
|
||||||
|
let finished = normalize(json!({"id":1,"status":"FINISHED","episodes":26})).unwrap();
|
||||||
|
assert_eq!(catalog_count(&finished).unwrap(), Some(26));
|
||||||
|
let ongoing = normalize(
|
||||||
|
json!({"id":2,"status":"RELEASING","episodes":24,"nextAiringEpisode":{"episode":5}}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(catalog_count(&ongoing).unwrap(), Some(4));
|
||||||
|
for status in ["RELEASING", "HIATUS", "CANCELLED", "FINISHED"] {
|
||||||
|
assert_eq!(
|
||||||
|
catalog_count(&normalize(json!({"id":2,"status":status,"episodes":null})).unwrap())
|
||||||
|
.unwrap(),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
normalize(
|
||||||
|
json!({"id":2,"status":"RELEASING","episodes":2,"nextAiringEpisode":{"episode":5}})
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(normalize(json!({"id":0})).is_err());
|
||||||
|
assert!(decode(r#"{"data":{"Media":{"id":1}},"errors":[{"message":"bad"}]}"#).is_err());
|
||||||
|
assert!(decode(r#"{"data":null}"#).is_err());
|
||||||
|
assert!(decode(r#"{"errors":{},"data":{}}"#).is_err());
|
||||||
|
assert!(decode(r#"{"data":{"Media":null}}"#).unwrap()["Media"].is_null());
|
||||||
|
}
|
||||||
|
}
|
||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
//! Stable identities for the two explicitly separate anime catalogs.
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub enum Provider {
|
||||||
|
AniList,
|
||||||
|
#[default]
|
||||||
|
Miruro,
|
||||||
|
}
|
||||||
|
impl Provider {
|
||||||
|
pub fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::AniList => "AniList",
|
||||||
|
Self::Miruro => "Miruro legacy",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn key(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::AniList => "anilist",
|
||||||
|
Self::Miruro => "miruro",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn identity(self, media_id: String, episode: u32) -> io::Result<Identity> {
|
||||||
|
let identity = Identity {
|
||||||
|
provider: self.key().into(),
|
||||||
|
media_id,
|
||||||
|
episode,
|
||||||
|
};
|
||||||
|
identity.validate()?;
|
||||||
|
Ok(identity)
|
||||||
|
}
|
||||||
|
pub fn includes(self, key: &str) -> bool {
|
||||||
|
Identity::from_key(key).is_ok_and(|identity| identity.provider == self.key())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Identity {
|
||||||
|
pub provider: String,
|
||||||
|
pub media_id: String,
|
||||||
|
pub episode: u32,
|
||||||
|
}
|
||||||
|
impl Identity {
|
||||||
|
pub fn miruro(media_id: String, episode: u32) -> io::Result<Self> {
|
||||||
|
Provider::Miruro.identity(media_id, episode)
|
||||||
|
}
|
||||||
|
pub fn anilist(media_id: String, episode: u32) -> io::Result<Self> {
|
||||||
|
Provider::AniList.identity(media_id, episode)
|
||||||
|
}
|
||||||
|
pub fn validate(&self) -> io::Result<()> {
|
||||||
|
if self.episode == 0 || self.episode > i32::MAX as u32 {
|
||||||
|
return Err(invalid("Invalid anime episode number"));
|
||||||
|
}
|
||||||
|
match self.provider.as_str() {
|
||||||
|
"anilist" => {
|
||||||
|
positive_decimal(&self.media_id)?;
|
||||||
|
}
|
||||||
|
"miruro"
|
||||||
|
if !self.media_id.is_empty()
|
||||||
|
&& self.media_id.len() <= 512
|
||||||
|
&& self
|
||||||
|
.media_id
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')) => {}
|
||||||
|
_ => return Err(invalid("Invalid anime catalog identity")),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub fn key(&self) -> String {
|
||||||
|
format!("{}:{}:{}", self.provider, self.media_id, self.episode)
|
||||||
|
}
|
||||||
|
pub fn watch_url(&self) -> io::Result<String> {
|
||||||
|
self.validate()?;
|
||||||
|
Ok(match self.provider.as_str() {
|
||||||
|
"anilist" => format!(
|
||||||
|
"https://zokoanime.video/stream/ani/{}/{}/sub",
|
||||||
|
self.media_id, self.episode
|
||||||
|
),
|
||||||
|
_ => format!(
|
||||||
|
"https://barelystarted.miruro.tv/watch/{}/episode?ep={}",
|
||||||
|
self.media_id, self.episode
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub fn from_key(key: &str) -> io::Result<Self> {
|
||||||
|
let mut parts = key.split(':');
|
||||||
|
let provider = parts.next().unwrap_or("");
|
||||||
|
let media_id = parts.next().unwrap_or("");
|
||||||
|
let episode = positive_decimal(parts.next().unwrap_or(""))?;
|
||||||
|
if parts.next().is_some() {
|
||||||
|
return Err(invalid("Invalid anime episode key"));
|
||||||
|
}
|
||||||
|
let identity = Self {
|
||||||
|
provider: provider.into(),
|
||||||
|
media_id: media_id.into(),
|
||||||
|
episode,
|
||||||
|
};
|
||||||
|
identity.validate()?;
|
||||||
|
Ok(identity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) fn positive_decimal(text: &str) -> io::Result<u32> {
|
||||||
|
if text.starts_with('0') || text.is_empty() || !text.bytes().all(|b| b.is_ascii_digit()) {
|
||||||
|
return Err(invalid("Expected canonical positive decimal Int32"));
|
||||||
|
}
|
||||||
|
text.parse::<u32>()
|
||||||
|
.ok()
|
||||||
|
.filter(|n| *n <= i32::MAX as u32)
|
||||||
|
.ok_or_else(|| invalid("Expected canonical positive decimal Int32"))
|
||||||
|
}
|
||||||
|
fn invalid(message: &str) -> io::Error {
|
||||||
|
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn canonical_provider_isolation() {
|
||||||
|
let ani = Identity::anilist("1".into(), 1).unwrap();
|
||||||
|
assert_eq!(ani.key(), "anilist:1:1");
|
||||||
|
assert_eq!(
|
||||||
|
ani.watch_url().unwrap(),
|
||||||
|
"https://zokoanime.video/stream/ani/1/1/sub"
|
||||||
|
);
|
||||||
|
assert_ne!(ani.key(), Identity::miruro("1".into(), 1).unwrap().key());
|
||||||
|
assert!(!Provider::Miruro.includes(&ani.key()));
|
||||||
|
for key in [
|
||||||
|
"anilist:01:1",
|
||||||
|
"anilist:0:1",
|
||||||
|
"anilist:+1:1",
|
||||||
|
"anilist:1:01",
|
||||||
|
"anilist:1:0",
|
||||||
|
"anilist:2147483648:1",
|
||||||
|
"anilist:1:2147483648",
|
||||||
|
"anilist:1:1:2",
|
||||||
|
"other:1:1",
|
||||||
|
] {
|
||||||
|
assert!(Identity::from_key(key).is_err(), "{key}");
|
||||||
|
}
|
||||||
|
assert_eq!(Identity::from_key(&ani.key()).unwrap(), ani);
|
||||||
|
}
|
||||||
|
}
|
||||||
+177
-603
@@ -9,11 +9,14 @@ use std::{
|
|||||||
|
|
||||||
use toml_edit::{Array, DocumentMut, Item, Table, value};
|
use toml_edit::{Array, DocumentMut, Item, Table, value};
|
||||||
|
|
||||||
|
use crate::keys;
|
||||||
|
|
||||||
#[derive(Default, Debug)]
|
#[derive(Default, Debug)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub channels: BTreeMap<String, String>,
|
pub channels: BTreeMap<String, String>,
|
||||||
pub categories: BTreeMap<String, Vec<String>>,
|
pub categories: BTreeMap<String, Vec<String>>,
|
||||||
pub pinned_categories: Vec<String>,
|
pub pinned_categories: Vec<String>,
|
||||||
|
pub keys: keys::Bindings,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -27,6 +30,23 @@ impl Config {
|
|||||||
channels.dedup();
|
channels.dedup();
|
||||||
channels.into_iter().cloned().collect()
|
channels.into_iter().cloned().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn saved_channels(&self) -> Vec<(String, String)> {
|
||||||
|
let mut labels = BTreeMap::new();
|
||||||
|
// Mappings are sorted by handle, so aliases use the first readable label.
|
||||||
|
for (handle, identity) in &self.channels {
|
||||||
|
labels.entry(identity).or_insert(handle);
|
||||||
|
}
|
||||||
|
for identity in self.categories.values().flatten() {
|
||||||
|
labels.entry(identity).or_insert(identity);
|
||||||
|
}
|
||||||
|
let mut channels: Vec<_> = labels
|
||||||
|
.into_iter()
|
||||||
|
.map(|(identity, label)| (label.clone(), identity.clone()))
|
||||||
|
.collect();
|
||||||
|
channels.sort_unstable();
|
||||||
|
channels
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn config_path() -> io::Result<PathBuf> {
|
pub fn config_path() -> io::Result<PathBuf> {
|
||||||
@@ -53,10 +73,12 @@ pub fn load(path: &Path) -> io::Result<Config> {
|
|||||||
let channels = parse_channels(&document)?;
|
let channels = parse_channels(&document)?;
|
||||||
let categories = parse_categories(&document, &channels)?;
|
let categories = parse_categories(&document, &channels)?;
|
||||||
let pinned_categories = parse_pins(&document)?;
|
let pinned_categories = parse_pins(&document)?;
|
||||||
|
let keys = keys::Bindings::from_toml(document.get("keys"))?;
|
||||||
Ok(Config {
|
Ok(Config {
|
||||||
channels,
|
channels,
|
||||||
categories,
|
categories,
|
||||||
pinned_categories,
|
pinned_categories,
|
||||||
|
keys,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +101,7 @@ pub fn set_channel_tags(
|
|||||||
// Validate the whole file before changing it or creating directories.
|
// Validate the whole file before changing it or creating directories.
|
||||||
parse_categories(&document, &mappings)?;
|
parse_categories(&document, &mappings)?;
|
||||||
let pinned_categories = parse_pins(&document)?;
|
let pinned_categories = parse_pins(&document)?;
|
||||||
|
let keys = keys::Bindings::from_toml(document.get("keys"))?;
|
||||||
match mappings.get(&handle) {
|
match mappings.get(&handle) {
|
||||||
Some(existing) if existing != &channel => {
|
Some(existing) if existing != &channel => {
|
||||||
return Err(invalid(format!(
|
return Err(invalid(format!(
|
||||||
@@ -145,6 +168,7 @@ pub fn set_channel_tags(
|
|||||||
channels: mappings,
|
channels: mappings,
|
||||||
categories,
|
categories,
|
||||||
pinned_categories,
|
pinned_categories,
|
||||||
|
keys,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +184,7 @@ pub fn set_category_tags(
|
|||||||
let mappings = parse_channels(&document)?;
|
let mappings = parse_channels(&document)?;
|
||||||
let parsed_categories = parse_categories(&document, &mappings)?;
|
let parsed_categories = parse_categories(&document, &mappings)?;
|
||||||
let pinned_categories = parse_pins(&document)?;
|
let pinned_categories = parse_pins(&document)?;
|
||||||
|
let keys = keys::Bindings::from_toml(document.get("keys"))?;
|
||||||
let targets = parsed_categories
|
let targets = parsed_categories
|
||||||
.get(source)
|
.get(source)
|
||||||
.ok_or_else(|| invalid(format!("Unknown source category: {source:?}")))?;
|
.ok_or_else(|| invalid(format!("Unknown source category: {source:?}")))?;
|
||||||
@@ -255,6 +280,7 @@ pub fn set_category_tags(
|
|||||||
channels: mappings,
|
channels: mappings,
|
||||||
categories,
|
categories,
|
||||||
pinned_categories,
|
pinned_categories,
|
||||||
|
keys,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,14 +350,22 @@ pub fn set_category_pinned(path: &Path, name: &str, pinned: bool) -> io::Result<
|
|||||||
let channels = parse_channels(&document)?;
|
let channels = parse_channels(&document)?;
|
||||||
let categories = parse_categories(&document, &channels)?;
|
let categories = parse_categories(&document, &channels)?;
|
||||||
let pins = parse_pins(&document)?;
|
let pins = parse_pins(&document)?;
|
||||||
|
let keys = keys::Bindings::from_toml(document.get("keys"))?;
|
||||||
if !categories.contains_key(name) {
|
if !categories.contains_key(name) {
|
||||||
return Err(invalid(format!("Unknown category: {name:?}")));
|
return Err(invalid(format!("Unknown category: {name:?}")));
|
||||||
}
|
}
|
||||||
if !document.contains_key("pinned_categories") {
|
if !document.contains_key("settings") {
|
||||||
document.insert("pinned_categories", value(Array::new()));
|
document.insert("settings", Item::Table(Table::new()));
|
||||||
}
|
}
|
||||||
let array = document["pinned_categories"]
|
let settings = document["settings"]
|
||||||
.as_array_mut()
|
.as_table_like_mut()
|
||||||
|
.expect("settings were validated");
|
||||||
|
if !settings.contains_key("pinned_categories") {
|
||||||
|
settings.insert("pinned_categories", value(Array::new()));
|
||||||
|
}
|
||||||
|
let array = settings
|
||||||
|
.get_mut("pinned_categories")
|
||||||
|
.and_then(Item::as_array_mut)
|
||||||
.expect("pins were validated");
|
.expect("pins were validated");
|
||||||
if let Some(index) = pins.iter().position(|pin| pin == name) {
|
if let Some(index) = pins.iter().position(|pin| pin == name) {
|
||||||
if !pinned {
|
if !pinned {
|
||||||
@@ -346,11 +380,145 @@ pub fn set_category_pinned(path: &Path, name: &str, pinned: bool) -> io::Result<
|
|||||||
channels,
|
channels,
|
||||||
categories,
|
categories,
|
||||||
pinned_categories,
|
pinned_categories,
|
||||||
|
keys,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_category(path: &Path, name: &str, channels: &[String]) -> io::Result<Config> {
|
||||||
|
let name = category_name(name)?;
|
||||||
|
let (original, mut document) = read_document(path)?;
|
||||||
|
let mappings = parse_channels(&document)?;
|
||||||
|
let mut categories = parse_categories(&document, &mappings)?;
|
||||||
|
let pinned_categories = parse_pins(&document)?;
|
||||||
|
let keys = keys::Bindings::from_toml(document.get("keys"))?;
|
||||||
|
if categories.contains_key(name) {
|
||||||
|
return Err(invalid(format!("Category already exists: {name:?}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut labels = BTreeMap::new();
|
||||||
|
// As in saved_channels, use the first mapped handle for each identity.
|
||||||
|
for (handle, identity) in &mappings {
|
||||||
|
labels.entry(identity.as_str()).or_insert(handle.as_str());
|
||||||
|
}
|
||||||
|
let identities: BTreeSet<_> = channels
|
||||||
|
.iter()
|
||||||
|
.map(|channel| resolve_channel(channel, &mappings))
|
||||||
|
.collect::<io::Result<_>>()?;
|
||||||
|
let mut entries = Array::new();
|
||||||
|
for identity in &identities {
|
||||||
|
let readable = labels.get(identity.as_str()).copied().unwrap_or_else(|| {
|
||||||
|
identity
|
||||||
|
.strip_prefix("https://www.youtube.com/")
|
||||||
|
.filter(|address| address.starts_with('@'))
|
||||||
|
.unwrap_or(identity)
|
||||||
|
});
|
||||||
|
entries.push(readable);
|
||||||
|
}
|
||||||
|
if !document.contains_key("categories") {
|
||||||
|
document.insert("categories", Item::Table(Table::new()));
|
||||||
|
}
|
||||||
|
document["categories"]
|
||||||
|
.as_table_like_mut()
|
||||||
|
.expect("categories were validated")
|
||||||
|
.insert(name, value(entries));
|
||||||
|
categories.insert(name.to_owned(), identities.into_iter().collect());
|
||||||
|
save(path, original.as_deref(), document.to_string().as_bytes())?;
|
||||||
|
Ok(Config {
|
||||||
|
channels: mappings,
|
||||||
|
categories,
|
||||||
|
pinned_categories,
|
||||||
|
keys,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rename_category(path: &Path, old: &str, new: &str) -> io::Result<Config> {
|
||||||
|
let old = category_name(old)?;
|
||||||
|
let new = category_name(new)?;
|
||||||
|
let (original, mut document) = read_document(path)?;
|
||||||
|
let channels = parse_channels(&document)?;
|
||||||
|
let categories = parse_categories(&document, &channels)?;
|
||||||
|
let pinned_categories = parse_pins(&document)?;
|
||||||
|
let keys = keys::Bindings::from_toml(document.get("keys"))?;
|
||||||
|
if !categories.contains_key(old) {
|
||||||
|
return Err(invalid(format!("Unknown source category: {old:?}")));
|
||||||
|
}
|
||||||
|
if old == new {
|
||||||
|
return Ok(Config {
|
||||||
|
channels,
|
||||||
|
categories,
|
||||||
|
pinned_categories,
|
||||||
|
keys,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if categories.contains_key(new) {
|
||||||
|
return Err(invalid(format!("Category already exists: {new:?}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyMut only edits formatting. Move entries through formatted insertion to
|
||||||
|
// rename the key without changing its position or any membership formatting.
|
||||||
|
match &mut document["categories"] {
|
||||||
|
Item::Table(table) => {
|
||||||
|
let names: Vec<_> = table.iter().map(|(name, _)| name.to_owned()).collect();
|
||||||
|
for name in names {
|
||||||
|
let (mut key, item) = table.remove_entry(&name).expect("category was validated");
|
||||||
|
if key.get().trim() == old {
|
||||||
|
key = toml_edit::Key::new(new)
|
||||||
|
.with_leaf_decor(key.leaf_decor().clone())
|
||||||
|
.with_dotted_decor(key.dotted_decor().clone());
|
||||||
|
}
|
||||||
|
table.insert_formatted(&key, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Item::Value(toml_edit::Value::InlineTable(table)) => {
|
||||||
|
let names: Vec<_> = table.iter().map(|(name, _)| name.to_owned()).collect();
|
||||||
|
for name in names {
|
||||||
|
let (mut key, item) = table.remove_entry(&name).expect("category was validated");
|
||||||
|
if key.get().trim() == old {
|
||||||
|
key = toml_edit::Key::new(new)
|
||||||
|
.with_leaf_decor(key.leaf_decor().clone())
|
||||||
|
.with_dotted_decor(key.dotted_decor().clone());
|
||||||
|
}
|
||||||
|
table.insert_formatted(&key, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => unreachable!("categories were validated"),
|
||||||
|
}
|
||||||
|
if let Some(array) = document
|
||||||
|
.get_mut("settings")
|
||||||
|
.and_then(Item::as_table_like_mut)
|
||||||
|
.and_then(|settings| settings.get_mut("pinned_categories"))
|
||||||
|
.and_then(Item::as_array_mut)
|
||||||
|
{
|
||||||
|
for pin in array.iter_mut() {
|
||||||
|
if pin.as_str().expect("pin was validated").trim() == old {
|
||||||
|
let decor = pin.decor().clone();
|
||||||
|
*pin = new.into();
|
||||||
|
*pin.decor_mut() = decor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let categories = parse_categories(&document, &channels)?;
|
||||||
|
let pinned_categories = parse_pins(&document)?;
|
||||||
|
save(path, original.as_deref(), document.to_string().as_bytes())?;
|
||||||
|
Ok(Config {
|
||||||
|
channels,
|
||||||
|
categories,
|
||||||
|
pinned_categories,
|
||||||
|
keys,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_pins(document: &DocumentMut) -> io::Result<Vec<String>> {
|
fn parse_pins(document: &DocumentMut) -> io::Result<Vec<String>> {
|
||||||
let Some(item) = document.get("pinned_categories") else {
|
if document.contains_key("pinned_categories") {
|
||||||
|
return Err(invalid("Move top-level pinned_categories into [settings]"));
|
||||||
|
}
|
||||||
|
let Some(settings) = document.get("settings") else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
let settings = settings
|
||||||
|
.as_table_like()
|
||||||
|
.ok_or_else(|| invalid("settings must be a TOML table"))?;
|
||||||
|
let Some(item) = settings.get("pinned_categories") else {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
};
|
};
|
||||||
let array = item
|
let array = item
|
||||||
@@ -383,7 +551,7 @@ fn category_name(name: &str) -> io::Result<&str> {
|
|||||||
Ok(name.trim())
|
Ok(name.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_channel(input: &str) -> io::Result<String> {
|
pub(crate) fn normalize_channel(input: &str) -> io::Result<String> {
|
||||||
if input.chars().any(char::is_control) {
|
if input.chars().any(char::is_control) {
|
||||||
return Err(invalid(
|
return Err(invalid(
|
||||||
"Channel addresses must contain no control characters",
|
"Channel addresses must contain no control characters",
|
||||||
@@ -395,14 +563,13 @@ fn normalize_channel(input: &str) -> io::Result<String> {
|
|||||||
.or_else(|| input.strip_prefix("https://youtube.com/"));
|
.or_else(|| input.strip_prefix("https://youtube.com/"));
|
||||||
let address = url_address.unwrap_or(input);
|
let address = url_address.unwrap_or(input);
|
||||||
if let Some(handle) = address.strip_prefix('@') {
|
if let Some(handle) = address.strip_prefix('@') {
|
||||||
|
// Validate address safety, not creation rules: existing handles can have edge separators.
|
||||||
let length = handle.chars().count();
|
let length = handle.chars().count();
|
||||||
if length == 0
|
if length == 0
|
||||||
|| length > 30
|
|| length > 30
|
||||||
|| !handle
|
|| !handle
|
||||||
.chars()
|
.chars()
|
||||||
.all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.'))
|
.all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.'))
|
||||||
|| !handle.starts_with(char::is_alphanumeric)
|
|
||||||
|| !handle.ends_with(char::is_alphanumeric)
|
|
||||||
{
|
{
|
||||||
return Err(invalid("Invalid YouTube channel handle"));
|
return Err(invalid("Invalid YouTube channel handle"));
|
||||||
}
|
}
|
||||||
@@ -591,598 +758,5 @@ fn save(path: &Path, original: Option<&[u8]>, contents: &[u8]) -> io::Result<()>
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "../tests/unit/config.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
|
|
||||||
struct TestDirectory(PathBuf);
|
|
||||||
|
|
||||||
impl TestDirectory {
|
|
||||||
fn new() -> Self {
|
|
||||||
loop {
|
|
||||||
let path = std::env::temp_dir().join(format!(
|
|
||||||
"tubular-config-test-{}-{}",
|
|
||||||
std::process::id(),
|
|
||||||
NEXT_TEMP.fetch_add(1, Ordering::Relaxed)
|
|
||||||
));
|
|
||||||
match fs::create_dir(&path) {
|
|
||||||
Ok(()) => return Self(path),
|
|
||||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
|
|
||||||
Err(error) => panic!("Cannot create test directory: {error}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for TestDirectory {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = fs::remove_dir_all(&self.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn category_pins_persist_without_changing_memberships_or_manual_preferences() {
|
|
||||||
let directory = TestDirectory::new();
|
|
||||||
let path = directory.0.join("config.toml");
|
|
||||||
fs::write(&path, "# favorites\npinned_categories = ['Japanese'] # pin note\n[other]\nvalue = 42\n[categories]\nJapanese = []\nChinese = []\n").unwrap();
|
|
||||||
let pinned = set_category_pinned(&path, "Chinese", true).unwrap();
|
|
||||||
assert_eq!(pinned.pinned_categories, ["Japanese", "Chinese"]);
|
|
||||||
let tags = BTreeMap::from([("Japanese".into(), true)]);
|
|
||||||
let saved = set_channel_tags(&path, &tags, "@example", "UC4QobU6STFB0P71PMvOGN5A").unwrap();
|
|
||||||
assert_eq!(saved.pinned_categories, ["Japanese", "Chinese"]);
|
|
||||||
assert_eq!(
|
|
||||||
saved.categories["Japanese"],
|
|
||||||
["https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A"]
|
|
||||||
);
|
|
||||||
let unpinned = set_category_pinned(&path, "Japanese", false).unwrap();
|
|
||||||
assert_eq!(unpinned.pinned_categories, ["Chinese"]);
|
|
||||||
assert_eq!(load(&path).unwrap().pinned_categories, ["Chinese"]);
|
|
||||||
let content = fs::read_to_string(&path).unwrap();
|
|
||||||
assert!(content.contains("# favorites") && content.contains("# pin note"));
|
|
||||||
assert_eq!(
|
|
||||||
content.parse::<DocumentMut>().unwrap()["other"]["value"].as_integer(),
|
|
||||||
Some(42)
|
|
||||||
);
|
|
||||||
assert!(set_category_pinned(&path, "Missing", true).is_err());
|
|
||||||
assert_eq!(fs::read_to_string(&path).unwrap(), content);
|
|
||||||
for invalid in [
|
|
||||||
"pinned_categories = true",
|
|
||||||
"pinned_categories = [4]",
|
|
||||||
"pinned_categories = ['A', ' A ']",
|
|
||||||
] {
|
|
||||||
fs::write(&path, format!("{invalid}\n[categories]\nA = []\n")).unwrap();
|
|
||||||
let before = fs::read(&path).unwrap();
|
|
||||||
assert!(set_category_pinned(&path, "A", true).is_err());
|
|
||||||
assert!(
|
|
||||||
set_channel_tags(&path, &tags, "@example", "UC4QobU6STFB0P71PMvOGN5A").is_err()
|
|
||||||
);
|
|
||||||
assert_eq!(fs::read(&path).unwrap(), before);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn category_tags_apply_exact_set_and_preserve_unrelated_entries_and_preferences() {
|
|
||||||
let directory = TestDirectory::new();
|
|
||||||
let path = directory.0.join("config.toml");
|
|
||||||
let first = "UC4QobU6STFB0P71PMvOGN5A";
|
|
||||||
let second = "UCabcdefghijklmnopqrstuv";
|
|
||||||
let direct = "UC0123456789012345678901";
|
|
||||||
let first_url = format!("https://www.youtube.com/channel/{first}");
|
|
||||||
let second_url = format!("https://www.youtube.com/channel/{second}");
|
|
||||||
let direct_url = format!("https://www.youtube.com/channel/{direct}");
|
|
||||||
let manual_url = "https://www.youtube.com/@manual".to_owned();
|
|
||||||
let original = format!(
|
|
||||||
"# manual preferences\npinned_categories = ['Source'] # pin note\nplayer = 'mpv'\n[channels]\n'@alias' = '{first}' # alias mapping\n'@second' = '{second_url}'\n[other]\nvalue = 42\n[categories]\n' Source ' = [\n '@alias', # source channel\n '{first_url}',\n '{second}',\n 'https://youtube.com/@manual',\n '@manual',\n '{direct}', # direct channel\n] # source note\nKeep = ['@alias', '@unrelated'] # keep note\nDrop = [\n '@manual', # removed channel note\n 'https://youtube.com/@alias',\n '@unrelated', # unrelated note\n]\nUntouched = ['@unrelated']\n"
|
|
||||||
);
|
|
||||||
fs::write(&path, &original).unwrap();
|
|
||||||
let opened = load(&path).unwrap();
|
|
||||||
let before = original.parse::<DocumentMut>().unwrap();
|
|
||||||
// This category was not present in the dialog's tags.
|
|
||||||
fs::write(
|
|
||||||
&path,
|
|
||||||
format!("{original}Late = ['@alias', '@unrelated'] # late note\n"),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let tags = BTreeMap::from([
|
|
||||||
("Source".to_owned(), false),
|
|
||||||
("Keep".to_owned(), true),
|
|
||||||
("Drop".to_owned(), false),
|
|
||||||
(" New ".to_owned(), true),
|
|
||||||
]);
|
|
||||||
let saved =
|
|
||||||
set_category_tags(&path, " Source ", &opened.categories["Source"], &tags).unwrap();
|
|
||||||
assert!(saved.categories["Source"].is_empty());
|
|
||||||
for name in ["Drop", "Late", "Untouched"] {
|
|
||||||
assert_eq!(
|
|
||||||
saved.categories[name],
|
|
||||||
["https://www.youtube.com/@unrelated"]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
saved.categories["Keep"],
|
|
||||||
[
|
|
||||||
first_url.clone(),
|
|
||||||
"https://www.youtube.com/@unrelated".to_owned(),
|
|
||||||
manual_url.clone(),
|
|
||||||
direct_url.clone(),
|
|
||||||
second_url.clone(),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
saved.categories["New"],
|
|
||||||
[manual_url, direct_url, first_url, second_url]
|
|
||||||
);
|
|
||||||
assert_eq!(saved.channels, opened.channels);
|
|
||||||
assert_eq!(saved.pinned_categories, ["Source"]);
|
|
||||||
let content = fs::read_to_string(&path).unwrap();
|
|
||||||
let document = content.parse::<DocumentMut>().unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
document["channels"].to_string(),
|
|
||||||
before["channels"].to_string()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
document["pinned_categories"].to_string(),
|
|
||||||
before["pinned_categories"].to_string()
|
|
||||||
);
|
|
||||||
assert_eq!(document["other"]["value"].as_integer(), Some(42));
|
|
||||||
assert_eq!(document["player"].as_str(), Some("mpv"));
|
|
||||||
let new = document["categories"]["New"].as_array().unwrap();
|
|
||||||
let raw: Vec<_> = new.iter().map(|entry| entry.as_str().unwrap()).collect();
|
|
||||||
assert_eq!(raw, ["@manual", direct, "@alias", "@second"]);
|
|
||||||
let keep = document["categories"]["Keep"].as_array().unwrap();
|
|
||||||
assert_eq!(keep.get(0).unwrap().as_str(), Some("@alias"));
|
|
||||||
assert_eq!(keep.get(1).unwrap().as_str(), Some("@unrelated"));
|
|
||||||
for comment in [
|
|
||||||
"# manual preferences",
|
|
||||||
"# pin note",
|
|
||||||
"# alias mapping",
|
|
||||||
"# source channel",
|
|
||||||
"# direct channel",
|
|
||||||
"# source note",
|
|
||||||
"# keep note",
|
|
||||||
"# removed channel note",
|
|
||||||
"# unrelated note",
|
|
||||||
"# late note",
|
|
||||||
] {
|
|
||||||
assert!(content.contains(comment), "lost {comment}");
|
|
||||||
}
|
|
||||||
let reloaded = load(&path).unwrap();
|
|
||||||
assert_eq!(reloaded.categories, saved.categories);
|
|
||||||
assert_eq!(reloaded.subscriptions(), saved.subscriptions());
|
|
||||||
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn category_tags_compare_identity_sets_not_source_order_or_alias_spelling() {
|
|
||||||
let directory = TestDirectory::new();
|
|
||||||
let path = directory.0.join("config.toml");
|
|
||||||
let id = "UC4QobU6STFB0P71PMvOGN5A";
|
|
||||||
let url = format!("https://www.youtube.com/channel/{id}");
|
|
||||||
let registry = format!("[channels]\n'@old' = '{id}'\n'@alias' = '{url}'\n");
|
|
||||||
fs::write(
|
|
||||||
&path,
|
|
||||||
format!("{registry}[categories]\nSource = ['@old', '@manual']\n"),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let expected = load(&path).unwrap().categories.remove("Source").unwrap();
|
|
||||||
fs::write(
|
|
||||||
&path,
|
|
||||||
format!(
|
|
||||||
"{registry}[categories]\nSource = ['https://youtube.com/@manual', '{url}', '@alias'] # alias note\n"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let tags = BTreeMap::from([("Source".to_owned(), true), ("New".to_owned(), true)]);
|
|
||||||
let saved = set_category_tags(&path, "Source", &expected, &tags).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
saved.categories["Source"],
|
|
||||||
["https://www.youtube.com/@manual".to_owned(), url.clone()]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
saved.categories["New"],
|
|
||||||
["https://www.youtube.com/@manual".to_owned(), url]
|
|
||||||
);
|
|
||||||
let content = fs::read_to_string(&path).unwrap();
|
|
||||||
let document = content.parse::<DocumentMut>().unwrap();
|
|
||||||
let source = document["categories"]["Source"].as_array().unwrap();
|
|
||||||
assert_eq!(source.len(), 3);
|
|
||||||
assert_eq!(source.get(2).unwrap().as_str(), Some("@alias"));
|
|
||||||
let new = document["categories"]["New"].as_array().unwrap();
|
|
||||||
assert_eq!(new.get(1).unwrap().as_str(), Some("@alias"));
|
|
||||||
assert!(content.contains("# alias note"));
|
|
||||||
set_category_tags(&path, "Source", &expected, &tags).unwrap();
|
|
||||||
assert_eq!(fs::read_to_string(&path).unwrap(), content);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn category_tags_reject_stale_sources_invalid_files_and_tags_without_writing() {
|
|
||||||
let directory = TestDirectory::new();
|
|
||||||
let path = directory.0.join("config.toml");
|
|
||||||
let tags = BTreeMap::from([("New".to_owned(), true)]);
|
|
||||||
let expected = vec!["https://www.youtube.com/@manual".to_owned()];
|
|
||||||
for contents in [
|
|
||||||
"[categories]\nOther = ['@manual']\n",
|
|
||||||
"[categories]\nSource = []\n",
|
|
||||||
"[categories]\nSource = ['@different']\n",
|
|
||||||
"[categories]\nSource = ['@manual', '@different']\n",
|
|
||||||
"[channels]\n'@manual' = 'UC4QobU6STFB0P71PMvOGN5A'\n[categories]\nSource = ['@manual']\n",
|
|
||||||
"[categories\n",
|
|
||||||
"categories = true\n",
|
|
||||||
"[categories]\nSource = ['@manual']\nInvalid = [7]\n",
|
|
||||||
"[categories]\nSource = ['@manual']\n' Source ' = []\n",
|
|
||||||
"channels = true\n[categories]\nSource = ['@manual']\n",
|
|
||||||
"[channels]\n'@manual' = '@different'\n[categories]\nSource = ['@manual']\n",
|
|
||||||
"pinned_categories = [7]\n[categories]\nSource = ['@manual']\n",
|
|
||||||
] {
|
|
||||||
fs::write(&path, contents).unwrap();
|
|
||||||
assert!(set_category_tags(&path, "Source", &expected, &tags).is_err());
|
|
||||||
assert_eq!(fs::read_to_string(&path).unwrap(), contents);
|
|
||||||
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
|
|
||||||
}
|
|
||||||
fs::write(&path, [0xff]).unwrap();
|
|
||||||
assert!(set_category_tags(&path, "Source", &expected, &tags).is_err());
|
|
||||||
assert_eq!(fs::read(&path).unwrap(), [0xff]);
|
|
||||||
let valid = "[categories]\nSource = ['@manual']\n";
|
|
||||||
fs::write(&path, valid).unwrap();
|
|
||||||
for invalid_tags in [
|
|
||||||
BTreeMap::from([(" ".to_owned(), true)]),
|
|
||||||
BTreeMap::from([("bad\nname".to_owned(), true)]),
|
|
||||||
BTreeMap::from([("New".to_owned(), true), (" New ".to_owned(), false)]),
|
|
||||||
] {
|
|
||||||
assert!(set_category_tags(&path, "Source", &expected, &invalid_tags).is_err());
|
|
||||||
assert_eq!(fs::read_to_string(&path).unwrap(), valid);
|
|
||||||
}
|
|
||||||
assert!(set_category_tags(&path, "bad\nname", &expected, &tags).is_err());
|
|
||||||
assert_eq!(fs::read_to_string(&path).unwrap(), valid);
|
|
||||||
let missing = directory.0.join("missing/config.toml");
|
|
||||||
assert!(set_category_tags(&missing, "Source", &expected, &tags).is_err());
|
|
||||||
assert!(!missing.parent().unwrap().exists());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn category_tags_only_clear_all_tags_when_every_target_remains_registered() {
|
|
||||||
let directory = TestDirectory::new();
|
|
||||||
let path = directory.0.join("config.toml");
|
|
||||||
let id = "UC4QobU6STFB0P71PMvOGN5A";
|
|
||||||
let registry = format!(
|
|
||||||
"pinned_categories = ['Source']\n[channels]\n'@mapped' = '{id}'\n[categories]\n"
|
|
||||||
);
|
|
||||||
for source in ["'@mapped', '@manual'", "'UCabcdefghijklmnopqrstuv'"] {
|
|
||||||
let original = format!("{registry}Source = [{source}]\n");
|
|
||||||
fs::write(&path, &original).unwrap();
|
|
||||||
let opened = load(&path).unwrap();
|
|
||||||
for tags in [
|
|
||||||
BTreeMap::new(),
|
|
||||||
BTreeMap::from([("Source".to_owned(), false)]),
|
|
||||||
] {
|
|
||||||
let error = set_category_tags(&path, "Source", &opened.categories["Source"], &tags)
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(error.to_string().contains("not registered in [channels]"));
|
|
||||||
assert_eq!(fs::read_to_string(&path).unwrap(), original);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let original = format!(
|
|
||||||
"{registry}Source = ['@mapped', '{id}'] # source note\nOther = ['https://youtube.com/@mapped', '@unrelated']\n"
|
|
||||||
);
|
|
||||||
fs::write(&path, &original).unwrap();
|
|
||||||
let opened = load(&path).unwrap();
|
|
||||||
let saved = set_category_tags(
|
|
||||||
&path,
|
|
||||||
"Source",
|
|
||||||
&opened.categories["Source"],
|
|
||||||
&BTreeMap::new(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(saved.categories["Source"].is_empty());
|
|
||||||
assert_eq!(
|
|
||||||
saved.categories["Other"],
|
|
||||||
["https://www.youtube.com/@unrelated"]
|
|
||||||
);
|
|
||||||
assert_eq!(saved.channels, opened.channels);
|
|
||||||
assert_eq!(saved.pinned_categories, ["Source"]);
|
|
||||||
assert_eq!(
|
|
||||||
saved.subscriptions(),
|
|
||||||
[
|
|
||||||
"https://www.youtube.com/@unrelated",
|
|
||||||
"https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A",
|
|
||||||
]
|
|
||||||
);
|
|
||||||
assert!(fs::read_to_string(&path).unwrap().contains("# source note"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn edits_preserve_manual_config_and_refuse_invalid_or_changed_files() {
|
|
||||||
let directory = TestDirectory::new();
|
|
||||||
let path = directory.0.join("nested/config.toml");
|
|
||||||
let id = "UC4QobU6STFB0P71PMvOGN5A";
|
|
||||||
assert!(load(&path).unwrap().categories.is_empty());
|
|
||||||
assert!(!path.parent().unwrap().exists());
|
|
||||||
let tags = BTreeMap::from([(" Music ".to_owned(), true)]);
|
|
||||||
let config = set_channel_tags(&path, &tags, "@example", id).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
config.channels["@example"],
|
|
||||||
format!("https://www.youtube.com/channel/{id}")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
config.categories["Music"],
|
|
||||||
[format!("https://www.youtube.com/channel/{id}")]
|
|
||||||
);
|
|
||||||
|
|
||||||
let manual = "# manual preferences\nplayer = 'mpv' # keep me\n[other]\nvalue = 42\n[categories]\n# favorites\n' Music ' = ['@example', 'https://www.youtube.com/@example'] # channel note\nEmpty = []\n";
|
|
||||||
fs::write(&path, manual).unwrap();
|
|
||||||
let tags = BTreeMap::from([("Music".to_owned(), true)]);
|
|
||||||
let config = set_channel_tags(&path, &tags, "https://youtube.com/@example", id).unwrap();
|
|
||||||
assert_eq!(config.categories["Music"].len(), 1);
|
|
||||||
assert!(config.categories["Empty"].is_empty());
|
|
||||||
let id = "UCabcdefghijklmnopqrstuv";
|
|
||||||
let config = set_channel_tags(&path, &tags, "@another", id).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
config.categories["Music"][1],
|
|
||||||
format!("https://www.youtube.com/channel/{id}")
|
|
||||||
);
|
|
||||||
let saved = fs::read_to_string(&path).unwrap();
|
|
||||||
for comment in [
|
|
||||||
"# manual preferences",
|
|
||||||
"# keep me",
|
|
||||||
"# favorites",
|
|
||||||
"# channel note",
|
|
||||||
] {
|
|
||||||
assert!(saved.contains(comment));
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
saved.parse::<DocumentMut>().unwrap()["other"]["value"].as_integer(),
|
|
||||||
Some(42)
|
|
||||||
);
|
|
||||||
|
|
||||||
for malformed in [
|
|
||||||
"[categories\n",
|
|
||||||
"categories = 3\n",
|
|
||||||
"[categories]\nMusic = [3]\n",
|
|
||||||
"[categories]\nMusic = ['https://example.com/@example']\n",
|
|
||||||
"[categories]\nMusic = []\n' Music ' = []\n",
|
|
||||||
"channels = 3\n",
|
|
||||||
"[channels]\n'@example' = 3\n",
|
|
||||||
"[channels]\n'@example' = 'https://example.com/channel/UCabcdefghijklmnopqrstuv'\n",
|
|
||||||
"[channels]\n'@example' = '@another'\n",
|
|
||||||
"[channels]\n'not-a-handle' = 'UCabcdefghijklmnopqrstuv'\n",
|
|
||||||
"[channels]\n'@example' = 'UCabcdefghijklmnopqrstuv'\n'https://youtube.com/@example' = 'UCabcdefghijklmnopqrstuv'\n",
|
|
||||||
] {
|
|
||||||
fs::write(&path, malformed).unwrap();
|
|
||||||
assert!(load(&path).is_err());
|
|
||||||
assert!(set_channel_tags(&path, &tags, "@example", id).is_err());
|
|
||||||
assert_eq!(fs::read_to_string(&path).unwrap(), malformed);
|
|
||||||
}
|
|
||||||
fs::write(&path, manual).unwrap();
|
|
||||||
for tags in [
|
|
||||||
BTreeMap::from([(" ".to_owned(), true)]),
|
|
||||||
BTreeMap::from([("bad\nname".to_owned(), false)]),
|
|
||||||
BTreeMap::from([("Music".to_owned(), true), (" Music ".to_owned(), false)]),
|
|
||||||
] {
|
|
||||||
assert!(set_channel_tags(&path, &tags, "@example", id).is_err());
|
|
||||||
assert_eq!(fs::read_to_string(&path).unwrap(), manual);
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
set_channel_tags(&path, &tags, "https://www.youtube.com/@example/videos", id).is_err()
|
|
||||||
);
|
|
||||||
assert_eq!(fs::read_to_string(&path).unwrap(), manual);
|
|
||||||
assert!(save(&path, Some(b"previous contents"), b"replacement").is_err());
|
|
||||||
assert_eq!(fs::read_to_string(&path).unwrap(), manual);
|
|
||||||
assert_eq!(fs::read_dir(path.parent().unwrap()).unwrap().count(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn handles_resolve_to_stable_ids_and_resaving_replaces_opaque_entries() {
|
|
||||||
let directory = TestDirectory::new();
|
|
||||||
let path = directory.0.join("config.toml");
|
|
||||||
let id = "UC4QobU6STFB0P71PMvOGN5A";
|
|
||||||
let url = format!("https://www.youtube.com/channel/{id}");
|
|
||||||
let manual = format!(
|
|
||||||
"[channels]\n'@old' = '{url}'\n[categories]\nJapanese = [\n '{url}', # keep this entry comment\n '@old',\n]\nOther = ['@old']\nDirect = ['@RustVideos']\n"
|
|
||||||
);
|
|
||||||
fs::write(&path, manual).unwrap();
|
|
||||||
let config = load(&path).unwrap();
|
|
||||||
assert_eq!(config.categories["Japanese"], std::slice::from_ref(&url));
|
|
||||||
assert_eq!(
|
|
||||||
config.categories["Direct"],
|
|
||||||
["https://www.youtube.com/@RustVideos"]
|
|
||||||
);
|
|
||||||
let tags = BTreeMap::from([("Japanese".to_owned(), true)]);
|
|
||||||
let config = set_channel_tags(&path, &tags, "@jawed", id).unwrap();
|
|
||||||
assert_eq!(config.categories["Japanese"], std::slice::from_ref(&url));
|
|
||||||
assert_eq!(config.categories["Other"], std::slice::from_ref(&url));
|
|
||||||
let saved = fs::read_to_string(&path).unwrap();
|
|
||||||
let document = saved.parse::<DocumentMut>().unwrap();
|
|
||||||
let entries = document["categories"]["Japanese"].as_array().unwrap();
|
|
||||||
assert!(entries.iter().all(|entry| entry.as_str() == Some("@jawed")));
|
|
||||||
assert_eq!(entries.len(), 2);
|
|
||||||
assert_eq!(document["channels"]["@jawed"].as_str(), Some(url.as_str()));
|
|
||||||
assert_eq!(document["channels"]["@old"].as_str(), Some(url.as_str()));
|
|
||||||
assert!(saved.contains("# keep this entry comment"));
|
|
||||||
set_channel_tags(&path, &tags, "@jawed", id).unwrap();
|
|
||||||
assert_eq!(fs::read_to_string(&path).unwrap(), saved);
|
|
||||||
assert!(set_channel_tags(&path, &tags, "@jawed", "UCabcdefghijklmnopqrstuv").is_err());
|
|
||||||
assert_eq!(fs::read_to_string(&path).unwrap(), saved);
|
|
||||||
assert_eq!(load(&path).unwrap().categories["Japanese"], [url]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn overlapping_tags_remove_stable_aliases_without_touching_other_channels() {
|
|
||||||
let directory = TestDirectory::new();
|
|
||||||
let path = directory.0.join("config.toml");
|
|
||||||
let id = "UC4QobU6STFB0P71PMvOGN5A";
|
|
||||||
let url = format!("https://www.youtube.com/channel/{id}");
|
|
||||||
let other_id = "UCabcdefghijklmnopqrstuv";
|
|
||||||
let other_url = format!("https://www.youtube.com/channel/{other_id}");
|
|
||||||
let manual = format!(
|
|
||||||
"# subscriptions\n[channels]\n'@old' = '{url}' # old alias\n'@other' = '{other_url}' # other mapping\n[categories]\n' First ' = [\n '@other', # unrelated creator\n '{id}',\n '@old',\n 'https://youtube.com/@old',\n '{url}',\n] # first tag\nSecond = ['@other']\nOmitted = ['@old'] # untouched tag\n"
|
|
||||||
);
|
|
||||||
fs::write(&path, &manual).unwrap();
|
|
||||||
let tags = BTreeMap::from([
|
|
||||||
("First".to_owned(), true),
|
|
||||||
("Second".to_owned(), true),
|
|
||||||
("New empty".to_owned(), false),
|
|
||||||
]);
|
|
||||||
let config = set_channel_tags(&path, &tags, "@current", id).unwrap();
|
|
||||||
assert_eq!(config.categories["First"], [other_url.clone(), url.clone()]);
|
|
||||||
assert_eq!(
|
|
||||||
config.categories["Second"],
|
|
||||||
[other_url.clone(), url.clone()]
|
|
||||||
);
|
|
||||||
assert!(config.categories["New empty"].is_empty());
|
|
||||||
let saved = fs::read_to_string(&path).unwrap();
|
|
||||||
let document = saved.parse::<DocumentMut>().unwrap();
|
|
||||||
let entries = document["categories"][" First "].as_array().unwrap();
|
|
||||||
assert_eq!(entries.len(), 5);
|
|
||||||
assert!(
|
|
||||||
entries
|
|
||||||
.iter()
|
|
||||||
.skip(1)
|
|
||||||
.all(|entry| entry.as_str() == Some("@current"))
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
document["categories"]["Omitted"]
|
|
||||||
.as_array()
|
|
||||||
.unwrap()
|
|
||||||
.get(0)
|
|
||||||
.unwrap()
|
|
||||||
.as_str(),
|
|
||||||
Some("@old")
|
|
||||||
);
|
|
||||||
assert_eq!(document["channels"]["@old"].as_str(), Some(url.as_str()));
|
|
||||||
assert_eq!(
|
|
||||||
document["channels"]["@other"].as_str(),
|
|
||||||
Some(other_url.as_str())
|
|
||||||
);
|
|
||||||
|
|
||||||
// Restore manually written aliases so deselection exercises every accepted spelling.
|
|
||||||
fs::write(&path, &manual).unwrap();
|
|
||||||
let tags = BTreeMap::from([
|
|
||||||
("First".to_owned(), false),
|
|
||||||
("Second".to_owned(), true),
|
|
||||||
("New empty".to_owned(), false),
|
|
||||||
]);
|
|
||||||
let config = set_channel_tags(&path, &tags, "@current", id).unwrap();
|
|
||||||
assert_eq!(config.categories["First"], std::slice::from_ref(&other_url));
|
|
||||||
assert_eq!(
|
|
||||||
config.categories["Second"],
|
|
||||||
[other_url.clone(), url.clone()]
|
|
||||||
);
|
|
||||||
assert_eq!(config.categories["Omitted"], std::slice::from_ref(&url));
|
|
||||||
assert!(config.categories["New empty"].is_empty());
|
|
||||||
let saved = fs::read_to_string(&path).unwrap();
|
|
||||||
for comment in [
|
|
||||||
"# subscriptions",
|
|
||||||
"# old alias",
|
|
||||||
"# other mapping",
|
|
||||||
"# unrelated creator",
|
|
||||||
"# first tag",
|
|
||||||
"# untouched tag",
|
|
||||||
] {
|
|
||||||
assert!(saved.contains(comment), "lost {comment}");
|
|
||||||
}
|
|
||||||
let tags = BTreeMap::from([
|
|
||||||
("First".to_owned(), false),
|
|
||||||
("Second".to_owned(), false),
|
|
||||||
("Omitted".to_owned(), false),
|
|
||||||
]);
|
|
||||||
set_channel_tags(&path, &tags, "@current", id).unwrap();
|
|
||||||
let config = load(&path).unwrap();
|
|
||||||
assert_eq!(config.categories["First"], std::slice::from_ref(&other_url));
|
|
||||||
assert_eq!(config.categories["Second"], [other_url]);
|
|
||||||
assert!(config.categories["Omitted"].is_empty());
|
|
||||||
assert!(config.categories["New empty"].is_empty());
|
|
||||||
assert!(
|
|
||||||
fs::read_to_string(&path)
|
|
||||||
.unwrap()
|
|
||||||
.contains("# unrelated creator")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn subscriptions_include_untagged_registry_and_manual_channels_after_tag_removal() {
|
|
||||||
let directory = TestDirectory::new();
|
|
||||||
let path = directory.0.join("config.toml");
|
|
||||||
let id = "UC4QobU6STFB0P71PMvOGN5A";
|
|
||||||
let url = format!("https://www.youtube.com/channel/{id}");
|
|
||||||
let other_id = "UCabcdefghijklmnopqrstuv";
|
|
||||||
let other_url = format!("https://www.youtube.com/channel/{other_id}");
|
|
||||||
let registry =
|
|
||||||
format!("[channels]\n'https://youtube.com/@old' = '{id}'\n'@alias' = '{url}'\n");
|
|
||||||
fs::write(&path, ®istry).unwrap();
|
|
||||||
let config = load(&path).unwrap();
|
|
||||||
assert!(config.categories.is_empty());
|
|
||||||
assert_eq!(config.channels["@old"], url);
|
|
||||||
assert_eq!(config.subscriptions(), std::slice::from_ref(&url));
|
|
||||||
|
|
||||||
let config = set_channel_tags(&path, &BTreeMap::new(), "@untagged", other_id).unwrap();
|
|
||||||
assert!(config.categories.is_empty());
|
|
||||||
assert_eq!(config.subscriptions(), [url.clone(), other_url.clone()]);
|
|
||||||
assert_eq!(load(&path).unwrap().subscriptions(), config.subscriptions());
|
|
||||||
|
|
||||||
fs::write(
|
|
||||||
&path,
|
|
||||||
format!(
|
|
||||||
"{registry}[categories]\nTagged = ['@old', '@alias', '{url}', '{id}']\nManual = ['@RustVideos', 'https://youtube.com/@RustVideos', '{other_id}', '{other_url}']\n"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let expected = vec![
|
|
||||||
"https://www.youtube.com/@RustVideos".to_owned(),
|
|
||||||
url.clone(),
|
|
||||||
other_url,
|
|
||||||
];
|
|
||||||
let config = load(&path).unwrap();
|
|
||||||
assert_eq!(config.categories["Tagged"], std::slice::from_ref(&url));
|
|
||||||
assert_eq!(config.subscriptions(), expected);
|
|
||||||
let tags = BTreeMap::from([("Tagged".to_owned(), false)]);
|
|
||||||
let config = set_channel_tags(&path, &tags, "@current", id).unwrap();
|
|
||||||
assert!(config.categories["Tagged"].is_empty());
|
|
||||||
assert_eq!(config.subscriptions(), expected);
|
|
||||||
assert_eq!(load(&path).unwrap().subscriptions(), expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn paths_and_channel_addresses_are_strict() {
|
|
||||||
let home = Some(OsString::from("/home/test"));
|
|
||||||
let fallback = PathBuf::from("/home/test/.config/tubular/config.toml");
|
|
||||||
for xdg in [
|
|
||||||
None,
|
|
||||||
Some(OsString::new()),
|
|
||||||
Some(OsString::from("relative")),
|
|
||||||
] {
|
|
||||||
assert_eq!(resolve_path(xdg, home.clone()).unwrap(), fallback);
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
resolve_path(Some("/config".into()), None).unwrap(),
|
|
||||||
PathBuf::from("/config/tubular/config.toml")
|
|
||||||
);
|
|
||||||
assert!(resolve_path(None, None).is_err());
|
|
||||||
assert!(resolve_path(None, Some(OsString::new())).is_err());
|
|
||||||
for channel in [
|
|
||||||
"",
|
|
||||||
"@",
|
|
||||||
"@bad/name",
|
|
||||||
"@bad?query",
|
|
||||||
"@bad#fragment",
|
|
||||||
"@bad\n",
|
|
||||||
"https://www.youtube.com.evil/@example",
|
|
||||||
"http://www.youtube.com/@example",
|
|
||||||
"https://www.youtube.com/channel/UCshort",
|
|
||||||
"channel/UCshort",
|
|
||||||
"https://www.youtube.com/@example/videos",
|
|
||||||
"https://www.youtube.com/@example?x=1",
|
|
||||||
"https://www.youtube.com/@example/../other",
|
|
||||||
"https://www.youtube.com/@example//",
|
|
||||||
] {
|
|
||||||
assert!(normalize_channel(channel).is_err(), "accepted {channel:?}");
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
normalize_channel("@example").unwrap(),
|
|
||||||
"https://www.youtube.com/@example"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
normalize_channel("https://youtube.com/channel/UCabcdefghijklmnopqrstuv").unwrap(),
|
|
||||||
"https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv"
|
|
||||||
);
|
|
||||||
assert!(category_name(" \t ").is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+839
@@ -0,0 +1,839 @@
|
|||||||
|
use std::{fmt::Write as _, io};
|
||||||
|
|
||||||
|
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
use ratatui::{
|
||||||
|
style::Stylize,
|
||||||
|
text::{Line, Span, Text},
|
||||||
|
};
|
||||||
|
use toml_edit::{Array, Item};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
#[repr(usize)]
|
||||||
|
pub enum Context {
|
||||||
|
Normal,
|
||||||
|
Info,
|
||||||
|
Description,
|
||||||
|
CollectionNormal,
|
||||||
|
CollectionInfo,
|
||||||
|
CollectionDescription,
|
||||||
|
SearchInput,
|
||||||
|
SelectorNormal,
|
||||||
|
SelectorInput,
|
||||||
|
TagList,
|
||||||
|
TagFilter,
|
||||||
|
TagInput,
|
||||||
|
CategoryNameInput,
|
||||||
|
Help,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL: &[Context] = &[
|
||||||
|
Context::Normal,
|
||||||
|
Context::Info,
|
||||||
|
Context::Description,
|
||||||
|
Context::CollectionNormal,
|
||||||
|
Context::CollectionInfo,
|
||||||
|
Context::CollectionDescription,
|
||||||
|
Context::SearchInput,
|
||||||
|
Context::SelectorNormal,
|
||||||
|
Context::SelectorInput,
|
||||||
|
Context::TagList,
|
||||||
|
Context::TagFilter,
|
||||||
|
Context::TagInput,
|
||||||
|
Context::CategoryNameInput,
|
||||||
|
Context::Help,
|
||||||
|
];
|
||||||
|
const VIEW: &[Context] = &[
|
||||||
|
Context::Normal,
|
||||||
|
Context::Info,
|
||||||
|
Context::Description,
|
||||||
|
Context::CollectionNormal,
|
||||||
|
Context::CollectionInfo,
|
||||||
|
Context::CollectionDescription,
|
||||||
|
];
|
||||||
|
const COLLECTION: &[Context] = &[
|
||||||
|
Context::CollectionNormal,
|
||||||
|
Context::CollectionInfo,
|
||||||
|
Context::CollectionDescription,
|
||||||
|
];
|
||||||
|
const TEXT: &[Context] = &[
|
||||||
|
Context::SearchInput,
|
||||||
|
Context::SelectorInput,
|
||||||
|
Context::TagFilter,
|
||||||
|
Context::TagInput,
|
||||||
|
Context::CategoryNameInput,
|
||||||
|
];
|
||||||
|
const NON_TEXT: &[Context] = &[
|
||||||
|
Context::Normal,
|
||||||
|
Context::Info,
|
||||||
|
Context::Description,
|
||||||
|
Context::CollectionNormal,
|
||||||
|
Context::CollectionInfo,
|
||||||
|
Context::CollectionDescription,
|
||||||
|
Context::SelectorNormal,
|
||||||
|
Context::TagList,
|
||||||
|
Context::Help,
|
||||||
|
];
|
||||||
|
|
||||||
|
struct Definition {
|
||||||
|
action: Action,
|
||||||
|
name: &'static str,
|
||||||
|
description: &'static str,
|
||||||
|
contexts: &'static [Context],
|
||||||
|
defaults: &'static [&'static str],
|
||||||
|
}
|
||||||
|
|
||||||
|
// The enum order, storage indices, documentation and default TOML all share this registry.
|
||||||
|
macro_rules! actions {
|
||||||
|
($($action:ident, $name:literal, $description:literal, $contexts:expr, [$($key:literal),*];)*) => {
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
#[repr(usize)]
|
||||||
|
pub enum Action { $($action),* }
|
||||||
|
|
||||||
|
const DEFINITIONS: &[Definition] = &[
|
||||||
|
$(Definition {
|
||||||
|
action: Action::$action,
|
||||||
|
name: $name,
|
||||||
|
description: $description,
|
||||||
|
contexts: $contexts,
|
||||||
|
defaults: &[$($key),*],
|
||||||
|
}),*
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
actions! {
|
||||||
|
Interrupt, "app.interrupt", "Exit immediately", ALL, ["ctrl+c"];
|
||||||
|
Quit, "app.quit", "Quit the application", &[Context::Normal, Context::Info, Context::Description, Context::CollectionNormal, Context::CollectionInfo, Context::CollectionDescription, Context::Help], ["q"];
|
||||||
|
ProviderChoose, "app.provider", "Choose the browsing provider", &[Context::Normal, Context::CollectionNormal], ["p"];
|
||||||
|
Help, "help.toggle", "Open or close keyboard help", NON_TEXT, ["?"];
|
||||||
|
InputSubmit, "input.submit", "Submit search, browse selector, leave tag filter, stage new tag or save category name", TEXT, ["enter"];
|
||||||
|
InputCancel, "input.cancel", "Leave text input without submitting", TEXT, ["escape"];
|
||||||
|
DeleteCharacter, "input.delete.character", "Delete the previous character", TEXT, ["backspace"];
|
||||||
|
DeleteWord, "input.delete.word", "Delete the previous word", TEXT, ["alt+backspace", "ctrl+w"];
|
||||||
|
DeleteLine, "input.delete.line", "Clear the input line", TEXT, ["super+backspace", "ctrl+u", "alt+super+backspace"];
|
||||||
|
SearchEdit, "search.edit", "Edit general or current-category search", VIEW, ["i"];
|
||||||
|
ViewSearch, "view.search", "Go to general YouTube Search", VIEW, ["/"];
|
||||||
|
ViewFeed, "view.feed", "Go to the combined subscription Feed", VIEW, ["F"];
|
||||||
|
ViewPrevious, "view.history.previous", "Go back in view history", VIEW, ["-"];
|
||||||
|
ViewNext, "view.history.next", "Go forward in view history", VIEW, ["+"];
|
||||||
|
ViewTabNext, "view.tab.next", "Select the next view tab", VIEW, ["tab", "ctrl+tab"];
|
||||||
|
ViewTabPrevious, "view.tab.previous", "Select the previous view tab", VIEW, ["shift+tab"];
|
||||||
|
ViewRefresh, "view.refresh", "Refresh the current view and configuration", VIEW, ["r"];
|
||||||
|
VideoPlay, "video.play", "Play checked videos in check order, or the selected video", VIEW, ["enter"];
|
||||||
|
VideoEnqueue, "video.enqueue", "Append checked videos in check order, or the selected video, to Terebi's queue", VIEW, ["ctrl+enter"];
|
||||||
|
VideoOpen, "video.open", "Open the selected YouTube video in the default browser", VIEW, ["o"];
|
||||||
|
VideoCopyUrl, "video.copy_url", "Copy the selected video's URL to the terminal clipboard", VIEW, ["y"];
|
||||||
|
VideoNext, "video.select.next", "Select the next video", VIEW, ["j", "down", "ctrl+j"];
|
||||||
|
VideoPrevious, "video.select.previous", "Select the previous video", VIEW, ["k", "up", "ctrl+k"];
|
||||||
|
VideoFirst, "video.select.first", "Select the first video", VIEW, ["g", "home"];
|
||||||
|
VideoLast, "video.select.last", "Select the last video", VIEW, ["G", "end"];
|
||||||
|
VideoToggle, "video.select.toggle", "Toggle the selected video's playback check", VIEW, ["space"];
|
||||||
|
VideoClear, "video.select.clear", "Discard playback checks in the current view", VIEW, ["escape", "ctrl+space"];
|
||||||
|
InfoOverview, "video.info.overview", "Show the video overview", VIEW, ["d"];
|
||||||
|
InfoDescription, "video.info.description", "Show the video description", VIEW, ["d"];
|
||||||
|
InfoHide, "video.info.hide", "Close the video preview", &[Context::Info, Context::Description, Context::CollectionInfo, Context::CollectionDescription], ["escape"];
|
||||||
|
CategoryBrowse, "category.browse", "Browse categories", VIEW, ["c"];
|
||||||
|
ChannelBrowse, "channel.browse", "Browse subscribed channels", VIEW, ["u"];
|
||||||
|
VideoChannel, "video.channel", "Open the selected video's channel", VIEW, ["U"];
|
||||||
|
ChannelPlaylists, "channel.playlists", "Browse the current channel's playlists", &[Context::Normal], ["L"];
|
||||||
|
ChannelTags, "channel.tags", "Edit the selected YouTube channel's or anime show's category tags", VIEW, ["s"];
|
||||||
|
CategoryTags, "category.tags", "Edit category tags for all channels in this category", VIEW, ["S"];
|
||||||
|
CategoryRename, "category.rename", "Rename the current or selected category", &[Context::Normal, Context::CollectionNormal, Context::SelectorNormal, Context::TagList], ["R"];
|
||||||
|
CategorySave, "category.save", "Save the combined category view as a new category", VIEW, ["P"];
|
||||||
|
WatchLaterSave, "video.watch_later.save", "Save the selected video to watch later", VIEW, ["w"];
|
||||||
|
WatchLaterBrowse, "view.watch_later.browse", "Choose watch later across all categories or one category", VIEW, ["W"];
|
||||||
|
WatchLaterBrowseCategory, "view.watch_later.category", "Browse the current category's watch later", VIEW, ["alt+w"];
|
||||||
|
WatchLaterFilter, "view.watch_later.filter", "Cycle watch-later filter: all, unwatched, watched", &[Context::Normal, Context::CollectionNormal], ["f"];
|
||||||
|
CollectionRemoveSelected, "view.collection.remove.selected", "Remove the selected video from the current collection (also in its previews)", COLLECTION, ["x"];
|
||||||
|
CollectionRemoveBulk, "view.collection.remove.bulk", "Confirm removal of the current collection subset", COLLECTION, ["X"];
|
||||||
|
SavedVideoSave, "video.saved.save", "Save the selected video (also in previews)", VIEW, ["v"];
|
||||||
|
SavedVideoBrowse, "view.saved.browse", "Choose saved videos across all categories or one category", VIEW, ["V"];
|
||||||
|
SavedVideoBrowseCategory, "view.saved.category", "Browse the current category's saved videos", VIEW, ["alt+v"];
|
||||||
|
WatchingBrowse, "view.watching.browse", "Browse started, unfinished videos", VIEW, ["C"];
|
||||||
|
WatchStatusWatching, "video.status.watching", "Mark the selected video as watching", VIEW, ["m"];
|
||||||
|
WatchStatusWatched, "video.status.watched", "Mark the selected video as watched", VIEW, ["M"];
|
||||||
|
SelectorNext, "selector.select.next", "Select the next category, channel, or playlist", &[Context::SelectorNormal], ["j", "down"];
|
||||||
|
SelectorPrevious, "selector.select.previous", "Select the previous category, channel, or playlist", &[Context::SelectorNormal], ["k", "up"];
|
||||||
|
SelectorInputNext, "selector.input.next", "Select the next match while editing the filter", &[Context::SelectorInput], ["down", "ctrl+j"];
|
||||||
|
SelectorInputPrevious, "selector.input.previous", "Select the previous match while editing the filter", &[Context::SelectorInput], ["up", "ctrl+k"];
|
||||||
|
SelectorEdit, "selector.edit", "Edit the category, channel, playlist, or collection filter", &[Context::SelectorNormal], ["i"];
|
||||||
|
SelectorOpen, "selector.open", "Open the selected category, channel, playlist, or collection", &[Context::SelectorNormal], ["enter"];
|
||||||
|
SelectorPin, "selector.pin", "Toggle category pinning (category picker only)", &[Context::SelectorNormal], ["p"];
|
||||||
|
SelectorToggle, "selector.toggle", "Check a category for combined browsing (category picker only)", &[Context::SelectorNormal], ["space"];
|
||||||
|
SelectorClose, "selector.close", "Close the category, channel, playlist, or collection picker", &[Context::SelectorNormal], ["escape"];
|
||||||
|
TagNext, "tags.select.next", "Select the next category tag", &[Context::TagList], ["j", "down"];
|
||||||
|
TagPrevious, "tags.select.previous", "Select the previous category tag", &[Context::TagList], ["k", "up"];
|
||||||
|
TagEdit, "tags.edit", "Edit the category tag filter", &[Context::TagList], ["i"];
|
||||||
|
TagInputNext, "tags.input.next", "Select the next tag while editing the filter", &[Context::TagFilter], ["down", "ctrl+j"];
|
||||||
|
TagInputPrevious, "tags.input.previous", "Select the previous tag while editing the filter", &[Context::TagFilter], ["up", "ctrl+k"];
|
||||||
|
TagToggle, "tags.toggle", "Toggle the selected category tag", &[Context::TagList], ["space"];
|
||||||
|
TagNew, "tags.new", "Enter a new category tag name", &[Context::TagList], ["n"];
|
||||||
|
TagSave, "tags.save", "Save category tag changes", &[Context::TagList], ["enter"];
|
||||||
|
TagCancel, "tags.cancel", "Discard category tag changes and close", &[Context::TagList], ["escape"];
|
||||||
|
DescriptionNext, "video.description.scroll.next", "Scroll the description down one line", &[Context::Description, Context::CollectionDescription], ["down", "ctrl+j"];
|
||||||
|
DescriptionPrevious, "video.description.scroll.previous", "Scroll the description up one line", &[Context::Description, Context::CollectionDescription], ["up", "ctrl+k"];
|
||||||
|
DescriptionPageNext, "video.description.page.next", "Scroll the description down one page", &[Context::Description, Context::CollectionDescription], ["pagedown"];
|
||||||
|
DescriptionPagePrevious, "video.description.page.previous", "Scroll the description up one page", &[Context::Description, Context::CollectionDescription], ["pageup"];
|
||||||
|
HelpNext, "help.scroll.next", "Scroll help down one line", &[Context::Help], ["j", "down", "ctrl+j"];
|
||||||
|
HelpPrevious, "help.scroll.previous", "Scroll help up one line", &[Context::Help], ["k", "up", "ctrl+k"];
|
||||||
|
HelpPageNext, "help.page.next", "Scroll help down one page", &[Context::Help], ["pagedown"];
|
||||||
|
HelpPagePrevious, "help.page.previous", "Scroll help up one page", &[Context::Help], ["pageup"];
|
||||||
|
HelpClose, "help.close", "Close keyboard help", &[Context::Help], ["escape"];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
struct Key {
|
||||||
|
code: KeyCode,
|
||||||
|
modifiers: KeyModifiers,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Key {
|
||||||
|
fn normalized(mut self) -> Self {
|
||||||
|
if self.code == KeyCode::BackTab {
|
||||||
|
self.code = KeyCode::Tab;
|
||||||
|
self.modifiers.insert(KeyModifiers::SHIFT);
|
||||||
|
}
|
||||||
|
if let KeyCode::Char(character) = self.code
|
||||||
|
&& !character.is_control()
|
||||||
|
&& self.modifiers.contains(KeyModifiers::SHIFT)
|
||||||
|
{
|
||||||
|
// Terminals usually report the already-shifted character. A single-character
|
||||||
|
// uppercase mapping also accepts synthetic shift+i as I without allocating.
|
||||||
|
let mut uppercase = character.to_uppercase();
|
||||||
|
if let Some(first) = uppercase.next()
|
||||||
|
&& uppercase.next().is_none()
|
||||||
|
{
|
||||||
|
self.code = KeyCode::Char(first);
|
||||||
|
}
|
||||||
|
self.modifiers.remove(KeyModifiers::SHIFT);
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(description: &str) -> Result<Self, String> {
|
||||||
|
let mut remaining = description;
|
||||||
|
let mut modifiers = KeyModifiers::NONE;
|
||||||
|
loop {
|
||||||
|
if remaining.chars().count() == 1 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let Some((modifier, key)) = remaining.split_once('+') else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let flag = match modifier.to_ascii_lowercase().as_str() {
|
||||||
|
"ctrl" | "control" => KeyModifiers::CONTROL,
|
||||||
|
"alt" | "option" => KeyModifiers::ALT,
|
||||||
|
"super" | "cmd" | "command" => KeyModifiers::SUPER,
|
||||||
|
"shift" => KeyModifiers::SHIFT,
|
||||||
|
_ => {
|
||||||
|
return Err(format!(
|
||||||
|
"unknown modifier {modifier:?}; use ctrl, alt, super or shift"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if modifiers.contains(flag) {
|
||||||
|
return Err(format!("repeated modifier {modifier:?}"));
|
||||||
|
}
|
||||||
|
modifiers.insert(flag);
|
||||||
|
remaining = key;
|
||||||
|
}
|
||||||
|
let mut characters = remaining.chars();
|
||||||
|
let code = match (characters.next(), characters.next()) {
|
||||||
|
(Some(character), None) if !character.is_control() => KeyCode::Char(character),
|
||||||
|
_ => match remaining.to_ascii_lowercase().as_str() {
|
||||||
|
"escape" | "esc" => KeyCode::Esc,
|
||||||
|
"enter" | "return" => KeyCode::Enter,
|
||||||
|
"tab" => KeyCode::Tab,
|
||||||
|
"backtab" => KeyCode::BackTab,
|
||||||
|
"up" => KeyCode::Up,
|
||||||
|
"down" => KeyCode::Down,
|
||||||
|
"left" => KeyCode::Left,
|
||||||
|
"right" => KeyCode::Right,
|
||||||
|
"home" => KeyCode::Home,
|
||||||
|
"end" => KeyCode::End,
|
||||||
|
"pageup" => KeyCode::PageUp,
|
||||||
|
"pagedown" => KeyCode::PageDown,
|
||||||
|
"backspace" => KeyCode::Backspace,
|
||||||
|
"delete" => KeyCode::Delete,
|
||||||
|
"space" => KeyCode::Char(' '),
|
||||||
|
name => {
|
||||||
|
let function = name
|
||||||
|
.strip_prefix('f')
|
||||||
|
.and_then(|number| number.parse::<u8>().ok());
|
||||||
|
match function {
|
||||||
|
Some(number @ 1..=24) => KeyCode::F(number),
|
||||||
|
_ => {
|
||||||
|
return Err(format!(
|
||||||
|
"unknown key {remaining:?}; use one character, a named key or f1..f24"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Ok(Self { code, modifiers }.normalized())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label(self) -> String {
|
||||||
|
let mut text = String::new();
|
||||||
|
for (flag, name) in [
|
||||||
|
(KeyModifiers::CONTROL, "Ctrl+"),
|
||||||
|
(KeyModifiers::ALT, "Alt+"),
|
||||||
|
(KeyModifiers::SUPER, "Super+"),
|
||||||
|
(KeyModifiers::SHIFT, "Shift+"),
|
||||||
|
] {
|
||||||
|
if self.modifiers.contains(flag) {
|
||||||
|
text.push_str(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match self.code {
|
||||||
|
KeyCode::Char(' ') => text.push_str("Space"),
|
||||||
|
KeyCode::Char(character) => text.push(character),
|
||||||
|
KeyCode::F(number) => {
|
||||||
|
let _ = write!(text, "F{number}");
|
||||||
|
}
|
||||||
|
code => text.push_str(match code {
|
||||||
|
KeyCode::Esc => "Esc",
|
||||||
|
KeyCode::Enter => "Enter",
|
||||||
|
KeyCode::Tab => "Tab",
|
||||||
|
KeyCode::Up => "↑",
|
||||||
|
KeyCode::Down => "↓",
|
||||||
|
KeyCode::Left => "←",
|
||||||
|
KeyCode::Right => "→",
|
||||||
|
KeyCode::Home => "Home",
|
||||||
|
KeyCode::End => "End",
|
||||||
|
KeyCode::PageUp => "PgUp",
|
||||||
|
KeyCode::PageDown => "PgDn",
|
||||||
|
KeyCode::Backspace => "Backspace",
|
||||||
|
KeyCode::Delete => "Del",
|
||||||
|
_ => unreachable!("only supported normalized keys have labels"),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Bindings {
|
||||||
|
labels: Vec<Vec<String>>,
|
||||||
|
contexts: [Vec<(Key, Action)>; ALL.len()],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Bindings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::build(default_keys()).expect("the default key registry must be valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_keys() -> Vec<Vec<Key>> {
|
||||||
|
DEFINITIONS
|
||||||
|
.iter()
|
||||||
|
.map(|definition| {
|
||||||
|
definition
|
||||||
|
.defaults
|
||||||
|
.iter()
|
||||||
|
.map(|key| Key::parse(key).expect("default key descriptions must be valid"))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid(message: impl Into<String>) -> io::Error {
|
||||||
|
io::Error::new(io::ErrorKind::InvalidData, message.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bindings {
|
||||||
|
pub fn from_toml(item: Option<&Item>) -> io::Result<Self> {
|
||||||
|
let mut keys = default_keys();
|
||||||
|
if let Some(item) = item {
|
||||||
|
let mut overridden = vec![false; DEFINITIONS.len()];
|
||||||
|
Self::read_overrides(item, "", &mut keys, &mut overridden)?;
|
||||||
|
}
|
||||||
|
Self::build(keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_overrides(
|
||||||
|
item: &Item,
|
||||||
|
path: &str,
|
||||||
|
keys: &mut [Vec<Key>],
|
||||||
|
overridden: &mut [bool],
|
||||||
|
) -> io::Result<()> {
|
||||||
|
if let Some(definition) = DEFINITIONS
|
||||||
|
.iter()
|
||||||
|
.find(|definition| definition.name == path)
|
||||||
|
{
|
||||||
|
if std::mem::replace(&mut overridden[definition.action as usize], true) {
|
||||||
|
return Err(invalid(format!(
|
||||||
|
"keys.{path}: duplicate action override; use only one quoted or nested dotted path for this action"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let mut aliases = Vec::new();
|
||||||
|
let mut add = |description: &str| -> io::Result<()> {
|
||||||
|
let key = Key::parse(description).map_err(|error| {
|
||||||
|
invalid(format!(
|
||||||
|
"keys.{path}: invalid binding {description:?}: {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if !aliases.contains(&key) {
|
||||||
|
aliases.push(key);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
if let Some(description) = item.as_str() {
|
||||||
|
add(description)?;
|
||||||
|
} else if let Some(array) = item.as_array() {
|
||||||
|
for (index, value) in array.iter().enumerate() {
|
||||||
|
let description = value.as_str().ok_or_else(|| {
|
||||||
|
invalid(format!(
|
||||||
|
"keys.{path}[{index}]: expected a key description string"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
add(description)?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Err(invalid(format!(
|
||||||
|
"keys.{path}: expected a string or array of strings; use [] to disable"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
keys[definition.action as usize] = aliases;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if !path.is_empty()
|
||||||
|
&& !DEFINITIONS.iter().any(|definition| {
|
||||||
|
definition
|
||||||
|
.name
|
||||||
|
.strip_prefix(path)
|
||||||
|
.is_some_and(|suffix| suffix.starts_with('.'))
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err(invalid(format!(
|
||||||
|
"keys.{path}: unknown action or action group; consult --default-config for valid paths"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let table = item.as_table_like().ok_or_else(|| {
|
||||||
|
invalid(format!(
|
||||||
|
"keys{}: expected a table of action bindings",
|
||||||
|
if path.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(".{path}")
|
||||||
|
}
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
for (name, value) in table.iter() {
|
||||||
|
let child = if path.is_empty() {
|
||||||
|
name.to_owned()
|
||||||
|
} else {
|
||||||
|
format!("{path}.{name}")
|
||||||
|
};
|
||||||
|
Self::read_overrides(value, &child, keys, overridden)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build(keys: Vec<Vec<Key>>) -> io::Result<Self> {
|
||||||
|
let mut contexts: [Vec<(Key, Action)>; ALL.len()] = std::array::from_fn(|_| Vec::new());
|
||||||
|
for definition in DEFINITIONS {
|
||||||
|
for &context in definition.contexts {
|
||||||
|
let entries = &mut contexts[context as usize];
|
||||||
|
for &key in &keys[definition.action as usize] {
|
||||||
|
if let Some((_, previous)) = entries.iter_mut().find(|(bound, _)| *bound == key)
|
||||||
|
{
|
||||||
|
*previous = overlap_winner(context, *previous, definition.action).ok_or_else(|| {
|
||||||
|
invalid(format!(
|
||||||
|
"keys: binding {} collides in {context:?} between {} and {}; rebind or disable one action",
|
||||||
|
key.label(), DEFINITIONS[*previous as usize].name, definition.name,
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
} else {
|
||||||
|
entries.push((key, definition.action));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
labels: keys
|
||||||
|
.into_iter()
|
||||||
|
.map(|aliases| aliases.into_iter().map(Key::label).collect())
|
||||||
|
.collect(),
|
||||||
|
contexts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve(&self, context: Context, event: KeyEvent) -> Option<Action> {
|
||||||
|
let key = Key {
|
||||||
|
code: event.code,
|
||||||
|
modifiers: event.modifiers,
|
||||||
|
}
|
||||||
|
.normalized();
|
||||||
|
self.contexts[context as usize]
|
||||||
|
.iter()
|
||||||
|
.find_map(|(bound, action)| (*bound == key).then_some(*action))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(&self, action: Action) -> &str {
|
||||||
|
self.labels[action as usize]
|
||||||
|
.first()
|
||||||
|
.map_or("unbound", String::as_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn help_text(&self) -> Text<'static> {
|
||||||
|
use Action::*;
|
||||||
|
type Row = (&'static [Action], &'static str);
|
||||||
|
let sections: &[(&str, &[Row])] = &[
|
||||||
|
(
|
||||||
|
"VIEWS AND VIDEOS",
|
||||||
|
&[
|
||||||
|
(&[VideoNext, VideoPrevious], "Select next / previous video"),
|
||||||
|
(
|
||||||
|
&[VideoPlay],
|
||||||
|
"Play checked videos in check order, or the selected video",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&[VideoEnqueue],
|
||||||
|
"Append checked videos in check order, or the selected video, to Terebi's queue",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&[VideoToggle],
|
||||||
|
"Check / uncheck the selected video for playback",
|
||||||
|
),
|
||||||
|
(&[VideoClear], "Clear playback checks in the current view"),
|
||||||
|
(
|
||||||
|
&[VideoOpen],
|
||||||
|
"Open the selected YouTube video in the default browser",
|
||||||
|
),
|
||||||
|
(&[VideoCopyUrl], "Copy the selected video's URL"),
|
||||||
|
(
|
||||||
|
&[WatchStatusWatching],
|
||||||
|
"Mark selected as watching (clear watched)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&[WatchStatusWatched],
|
||||||
|
"Mark selected as watched (leave watching)",
|
||||||
|
),
|
||||||
|
(&[SearchEdit], "Edit general or current-category search"),
|
||||||
|
(&[ViewSearch], "Go to general YouTube Search"),
|
||||||
|
(&[ViewFeed], "Go to the combined subscription Feed"),
|
||||||
|
(
|
||||||
|
&[InfoOverview, InfoDescription],
|
||||||
|
"Info → full description → info",
|
||||||
|
),
|
||||||
|
(&[VideoChannel], "Browse the selected video's channel"),
|
||||||
|
(&[ChannelBrowse], "Choose a saved channel"),
|
||||||
|
(
|
||||||
|
&[ChannelPlaylists],
|
||||||
|
"Browse the current channel's playlists",
|
||||||
|
),
|
||||||
|
(&[CategoryBrowse], "Choose a saved category"),
|
||||||
|
(&[ChannelTags], "Edit the selected channel's tags"),
|
||||||
|
(&[CategoryTags], "Edit all channels in a category"),
|
||||||
|
(
|
||||||
|
&[CategoryRename],
|
||||||
|
"Rename the current category (including scoped collections)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&[CategorySave],
|
||||||
|
"Save a combined category view as a new category",
|
||||||
|
),
|
||||||
|
(&[ViewTabNext, ViewTabPrevious], "Cycle visible tabs"),
|
||||||
|
(&[ViewPrevious], "Back (preview close takes priority)"),
|
||||||
|
(&[ViewNext], "Forward after going back"),
|
||||||
|
(&[VideoFirst], "First loaded video"),
|
||||||
|
(&[VideoLast], "Last loaded video"),
|
||||||
|
(&[ViewRefresh], "Reload config and refresh"),
|
||||||
|
(
|
||||||
|
&[ProviderChoose],
|
||||||
|
"Choose the browsing provider (NORMAL only)",
|
||||||
|
),
|
||||||
|
(&[Help], "Open / close this reference"),
|
||||||
|
(&[Quit, Interrupt], "Quit"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"WATCH LATER",
|
||||||
|
&[
|
||||||
|
(&[WatchLaterSave], "Save the selected video"),
|
||||||
|
(
|
||||||
|
&[WatchLaterBrowse],
|
||||||
|
"Choose all-categories or category watch later",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&[WatchLaterBrowseCategory],
|
||||||
|
"Browse current category's watch later",
|
||||||
|
),
|
||||||
|
(&[WatchLaterFilter], "Cycle all / unwatched / watched"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"SAVED VIDEOS",
|
||||||
|
&[
|
||||||
|
(
|
||||||
|
&[SavedVideoSave],
|
||||||
|
"Save the selected video (also in previews)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&[SavedVideoBrowse],
|
||||||
|
"Choose all-categories or category saved videos",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&[SavedVideoBrowseCategory],
|
||||||
|
"Browse current category's saved videos",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"CURRENTLY WATCHING",
|
||||||
|
&[(&[WatchingBrowse], "Browse started, unfinished videos")],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"COLLECTION LISTS",
|
||||||
|
&[
|
||||||
|
(
|
||||||
|
&[CollectionRemoveSelected],
|
||||||
|
"Remove selected from the current collection only",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&[CollectionRemoveBulk],
|
||||||
|
"Confirm removal of current list / category / filtered subset",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&[],
|
||||||
|
"Previews inherit their underlying collection's removal scope",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&[],
|
||||||
|
"Bulk confirmation: y / Enter confirms; n / Esc cancels",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"FULL DESCRIPTION",
|
||||||
|
&[
|
||||||
|
(&[DescriptionNext, DescriptionPrevious], "Scroll lines"),
|
||||||
|
(
|
||||||
|
&[DescriptionPageNext, DescriptionPagePrevious],
|
||||||
|
"Scroll pages",
|
||||||
|
),
|
||||||
|
(&[VideoNext, VideoPrevious], "Change the selected video"),
|
||||||
|
(&[InfoOverview], "Return to video info"),
|
||||||
|
(&[InfoHide], "Close the preview"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"CHANNEL / CATEGORY / PLAYLIST / COLLECTION SELECTOR",
|
||||||
|
&[
|
||||||
|
(&[SelectorNext, SelectorPrevious], "Move in NORMAL mode"),
|
||||||
|
(&[SelectorEdit], "Enter filter INSERT mode"),
|
||||||
|
(
|
||||||
|
&[SelectorInputNext, SelectorInputPrevious],
|
||||||
|
"Move while filtering",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&[SelectorOpen, InputSubmit],
|
||||||
|
"Browse checked categories, or the selected match",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&[SelectorToggle],
|
||||||
|
"Check categories for a combined feed (NORMAL only)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&[SelectorPin],
|
||||||
|
"Pin / unpin a category (category picker only)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&[CategoryRename],
|
||||||
|
"Rename the selected category, not combined / channel rows",
|
||||||
|
),
|
||||||
|
(&[InputCancel, SelectorClose], "INSERT → NORMAL → close"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"CHANNEL TAGS",
|
||||||
|
&[
|
||||||
|
(&[TagNext, TagPrevious], "Move between tags"),
|
||||||
|
(&[TagEdit], "Enter filter INSERT mode"),
|
||||||
|
(&[TagInputNext, TagInputPrevious], "Move while filtering"),
|
||||||
|
(
|
||||||
|
&[InputSubmit, InputCancel],
|
||||||
|
"Leave tag filter without saving",
|
||||||
|
),
|
||||||
|
(&[TagToggle], "Toggle a tag"),
|
||||||
|
(&[TagNew], "Enter a new tag name"),
|
||||||
|
(
|
||||||
|
&[CategoryRename],
|
||||||
|
"Rename a tag; existing category renames save immediately",
|
||||||
|
),
|
||||||
|
(&[TagSave], "Save tags"),
|
||||||
|
(&[InputSubmit], "Stage a new tag name"),
|
||||||
|
(&[TagCancel], "Cancel tag changes"),
|
||||||
|
(&[InputCancel], "Abandon a new tag name"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"TEXT INPUT",
|
||||||
|
&[
|
||||||
|
(
|
||||||
|
&[InputSubmit],
|
||||||
|
"Submit search / stage tag / save category name",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&[InputCancel],
|
||||||
|
"Leave INSERT / abandon tag or category name",
|
||||||
|
),
|
||||||
|
(&[DeleteCharacter], "Delete one character"),
|
||||||
|
(&[DeleteWord], "Delete previous word"),
|
||||||
|
(&[DeleteLine], "Clear the line"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"HELP WINDOW",
|
||||||
|
&[
|
||||||
|
(&[HelpNext, HelpPrevious], "Scroll lines"),
|
||||||
|
(&[HelpPageNext, HelpPagePrevious], "Scroll pages"),
|
||||||
|
(&[Help, HelpClose], "Close help without changing view"),
|
||||||
|
(&[Interrupt], "Quit from any mode or popup"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let mut text = Text::default();
|
||||||
|
for &(heading, rows) in sections {
|
||||||
|
text.push_line("");
|
||||||
|
text.push_line(Line::from(Span::raw(heading).bold()));
|
||||||
|
text.push_line("");
|
||||||
|
for &(actions, description) in rows {
|
||||||
|
if actions == [InfoOverview, InfoDescription]
|
||||||
|
&& self.labels[InfoOverview as usize] != self.labels[InfoDescription as usize]
|
||||||
|
{
|
||||||
|
for (action, description) in [
|
||||||
|
(InfoOverview, "Open video info"),
|
||||||
|
(InfoDescription, "Open the full description"),
|
||||||
|
] {
|
||||||
|
text.push_line(format!(
|
||||||
|
"{:<18} {}",
|
||||||
|
self.help_keys(&[action]),
|
||||||
|
description
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
text.push_line(format!("{:<18} {}", self.help_keys(actions), description));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
|
fn help_keys(&self, actions: &[Action]) -> String {
|
||||||
|
use Action::*;
|
||||||
|
let matches = |action: Action, expected: &[&str]| {
|
||||||
|
self.labels[action as usize]
|
||||||
|
.iter()
|
||||||
|
.map(String::as_str)
|
||||||
|
.eq(expected.iter().copied())
|
||||||
|
};
|
||||||
|
let movement = match actions {
|
||||||
|
[VideoNext, VideoPrevious] | [HelpNext, HelpPrevious] => {
|
||||||
|
matches(actions[0], &["j", "↓", "Ctrl+j"])
|
||||||
|
&& matches(actions[1], &["k", "↑", "Ctrl+k"])
|
||||||
|
}
|
||||||
|
[SelectorNext, SelectorPrevious] | [TagNext, TagPrevious] => {
|
||||||
|
matches(actions[0], &["j", "↓"]) && matches(actions[1], &["k", "↑"])
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if movement {
|
||||||
|
return if actions == [HelpNext, HelpPrevious] {
|
||||||
|
"j/k ↑/↓ Ctrl-J/K".into()
|
||||||
|
} else {
|
||||||
|
"j / k or arrows".into()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if matches!(
|
||||||
|
actions,
|
||||||
|
[DescriptionNext, DescriptionPrevious]
|
||||||
|
| [SelectorInputNext, SelectorInputPrevious]
|
||||||
|
| [TagInputNext, TagInputPrevious]
|
||||||
|
) && matches(actions[0], &["↓", "Ctrl+j"])
|
||||||
|
&& matches(actions[1], &["↑", "Ctrl+k"])
|
||||||
|
{
|
||||||
|
return "↑/↓, Ctrl-J/K".into();
|
||||||
|
}
|
||||||
|
if actions == [ViewTabNext, ViewTabPrevious]
|
||||||
|
&& matches(ViewTabNext, &["Tab", "Ctrl+Tab"])
|
||||||
|
&& matches(ViewTabPrevious, &["Shift+Tab"])
|
||||||
|
{
|
||||||
|
return "Tab / Shift-Tab".into();
|
||||||
|
}
|
||||||
|
let mut groups = Vec::new();
|
||||||
|
for &action in actions {
|
||||||
|
let labels = &self.labels[action as usize];
|
||||||
|
let group = if labels.is_empty() {
|
||||||
|
"unbound".into()
|
||||||
|
} else {
|
||||||
|
labels.join(", ")
|
||||||
|
};
|
||||||
|
if !groups.contains(&group) {
|
||||||
|
groups.push(group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groups.join(" / ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn overlap_winner(context: Context, first: Action, second: Action) -> Option<Action> {
|
||||||
|
use Action::*;
|
||||||
|
let pair = |a, b| (first == a && second == b) || (first == b && second == a);
|
||||||
|
if first == second {
|
||||||
|
return Some(first);
|
||||||
|
}
|
||||||
|
if pair(InfoOverview, InfoDescription) && VIEW.contains(&context) {
|
||||||
|
return Some(
|
||||||
|
if matches!(context, Context::Info | Context::CollectionInfo) {
|
||||||
|
InfoDescription
|
||||||
|
} else {
|
||||||
|
InfoOverview
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (pair(InfoHide, ViewPrevious) || pair(InfoHide, VideoClear))
|
||||||
|
&& matches!(
|
||||||
|
context,
|
||||||
|
Context::Info
|
||||||
|
| Context::Description
|
||||||
|
| Context::CollectionInfo
|
||||||
|
| Context::CollectionDescription
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return Some(InfoHide);
|
||||||
|
}
|
||||||
|
if matches!(
|
||||||
|
context,
|
||||||
|
Context::Description | Context::CollectionDescription
|
||||||
|
) {
|
||||||
|
if pair(DescriptionNext, VideoNext) {
|
||||||
|
return Some(DescriptionNext);
|
||||||
|
}
|
||||||
|
if pair(DescriptionPrevious, VideoPrevious) {
|
||||||
|
return Some(DescriptionPrevious);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_config() -> String {
|
||||||
|
let mut text = String::from(
|
||||||
|
"[settings]\npinned_categories = []\n\n[channels]\n\n[categories]\n\n[keys]\n",
|
||||||
|
);
|
||||||
|
for definition in DEFINITIONS {
|
||||||
|
let mut aliases = Array::new();
|
||||||
|
for &key in definition.defaults {
|
||||||
|
aliases.push(key);
|
||||||
|
}
|
||||||
|
let _ = writeln!(text, "# {}", definition.description);
|
||||||
|
let _ = writeln!(text, "{} = {}", definition.name, aliases);
|
||||||
|
}
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../tests/unit/keys.rs"]
|
||||||
|
mod tests;
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
pub mod anilist;
|
||||||
|
pub mod anime;
|
||||||
|
pub mod miruro;
|
||||||
|
|
||||||
|
/// Bound both wire bytes and decoded text: ureq's limit precedes decompression.
|
||||||
|
fn read_catalog_body(body: &mut ureq::Body, limit: u64) -> std::io::Result<String> {
|
||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
let mut text = String::new();
|
||||||
|
body.with_config()
|
||||||
|
.limit(limit)
|
||||||
|
.reader()
|
||||||
|
.take(limit + 1)
|
||||||
|
.read_to_string(&mut text)?;
|
||||||
|
if text.len() as u64 > limit {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"Catalog response exceeded decoded body limit",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compressed_catalog_bodies_keep_the_decoded_size_limit() {
|
||||||
|
use std::{io::Write, net::TcpListener, thread};
|
||||||
|
|
||||||
|
// Gzip of 64 spaces: fits the 32-byte wire limit, exceeds the decoded limit.
|
||||||
|
const GZIP: &[u8] = &[
|
||||||
|
31, 139, 8, 0, 0, 0, 0, 0, 2, 255, 83, 80, 160, 12, 0, 0, 95, 100, 117, 189, 64, 0, 0,
|
||||||
|
0,
|
||||||
|
];
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||||
|
let server = thread::spawn(move || {
|
||||||
|
use std::io::{BufRead, BufReader};
|
||||||
|
for _ in 0..2 {
|
||||||
|
let (mut stream, _) = listener.accept().unwrap();
|
||||||
|
stream
|
||||||
|
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
|
||||||
|
.unwrap();
|
||||||
|
let mut request = BufReader::new(&mut stream);
|
||||||
|
let mut line = String::new();
|
||||||
|
while request.read_line(&mut line).unwrap() > 0 {
|
||||||
|
if line == "\r\n" {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
line.clear();
|
||||||
|
}
|
||||||
|
write!(stream, "HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", GZIP.len()).unwrap();
|
||||||
|
stream.write_all(GZIP).unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let agent = ureq::Agent::config_builder()
|
||||||
|
.timeout_global(Some(std::time::Duration::from_secs(5)))
|
||||||
|
.build()
|
||||||
|
.new_agent();
|
||||||
|
let mut response = agent.get(&url).call().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
read_catalog_body(response.body_mut(), 64).unwrap(),
|
||||||
|
" ".repeat(64)
|
||||||
|
);
|
||||||
|
let mut response = agent.get(&url).call().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
read_catalog_body(response.body_mut(), 32)
|
||||||
|
.unwrap_err()
|
||||||
|
.kind(),
|
||||||
|
std::io::ErrorKind::InvalidData
|
||||||
|
);
|
||||||
|
server.join().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
+2928
-2156
File diff suppressed because one or more lines are too long
+315
@@ -0,0 +1,315 @@
|
|||||||
|
//! Blocking access to the deployed Miruro catalog and stable episode identities.
|
||||||
|
//!
|
||||||
|
//! See `docs/miruro-api.md`. Terebi owns playback source resolution, headers,
|
||||||
|
//! subtitles and playback metadata.
|
||||||
|
use std::{
|
||||||
|
io,
|
||||||
|
mem::size_of,
|
||||||
|
sync::atomic::{AtomicUsize, Ordering},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
use crate::anime::Identity;
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
// Miruro 2.0 links from https://www.miruro.com/#domains; www hosts are legacy.
|
||||||
|
const MIRRORS: &[&str] = &[
|
||||||
|
"https://miruro.cx",
|
||||||
|
"https://miruro.pw",
|
||||||
|
"https://miruro.ws",
|
||||||
|
"https://miruro.to",
|
||||||
|
"https://miruro.tv",
|
||||||
|
"https://miruro.bz",
|
||||||
|
];
|
||||||
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(25);
|
||||||
|
const BODY_LIMIT: u64 = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
pub struct Client {
|
||||||
|
agent: ureq::Agent,
|
||||||
|
preferred: AtomicUsize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Catalog metadata and available episode metadata, not a playback guarantee.
|
||||||
|
pub struct Detail {
|
||||||
|
pub media: Value,
|
||||||
|
pub episodes: Vec<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Client {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
agent: ureq::Agent::config_builder()
|
||||||
|
.timeout_global(Some(REQUEST_TIMEOUT))
|
||||||
|
.http_status_as_error(false)
|
||||||
|
.max_redirects(0)
|
||||||
|
.build()
|
||||||
|
.new_agent(),
|
||||||
|
preferred: AtomicUsize::new(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
/// Autocomplete summaries only: the deployed endpoint rejects cursor pagination.
|
||||||
|
pub fn search(&self, query: &str) -> io::Result<Vec<Value>> {
|
||||||
|
let query = query.trim();
|
||||||
|
if query.is_empty() {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
"Empty Miruro query",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let body = self.get(
|
||||||
|
"/api/search/browse",
|
||||||
|
&[("q", query), ("limit", "8"), ("type", "ANIME")],
|
||||||
|
&["application/json"],
|
||||||
|
)?;
|
||||||
|
let mut value: Value = serde_json::from_str(&body).map_err(invalid)?;
|
||||||
|
match value.get_mut("data").map(Value::take) {
|
||||||
|
Some(Value::Array(items)) => Ok(items),
|
||||||
|
_ => Err(invalid("Miruro search did not return a data array")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `id` is Miruro's opaque ID; `slug` follows the deployed canonical link helper.
|
||||||
|
/// Resolves the episode promise without decoding unrelated sidebar promises.
|
||||||
|
pub fn detail(&self, id: &str, slug: &str) -> io::Result<Detail> {
|
||||||
|
validate_segment(id)?;
|
||||||
|
validate_segment(slug)?;
|
||||||
|
let body = self.get(
|
||||||
|
&format!("/info/{id}/{slug}/__data.json"),
|
||||||
|
&[],
|
||||||
|
&["application/json", "text/sveltekit-data"],
|
||||||
|
)?;
|
||||||
|
let detail = decode_detail(&body)?;
|
||||||
|
if detail.media.get("id").and_then(Value::as_str) != Some(id) {
|
||||||
|
return Err(invalid("Miruro detail ID did not match the requested ID"));
|
||||||
|
}
|
||||||
|
Ok(detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, path: &str, query: &[(&str, &str)], types: &[&str]) -> io::Result<String> {
|
||||||
|
self.get_from(MIRRORS, path, query, types)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_from(
|
||||||
|
&self,
|
||||||
|
mirrors: &[&str],
|
||||||
|
path: &str,
|
||||||
|
query: &[(&str, &str)],
|
||||||
|
types: &[&str],
|
||||||
|
) -> io::Result<String> {
|
||||||
|
let started = Instant::now();
|
||||||
|
let first = self.preferred.load(Ordering::Relaxed) % mirrors.len();
|
||||||
|
let mut last_error = io::Error::new(io::ErrorKind::TimedOut, "Miruro request timed out");
|
||||||
|
let mut attempts = 0;
|
||||||
|
for offset in 0..mirrors.len() {
|
||||||
|
let Some(remaining) = REQUEST_TIMEOUT.checked_sub(started.elapsed()) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let index = (first + offset) % mirrors.len();
|
||||||
|
let base = mirrors[index];
|
||||||
|
attempts += 1;
|
||||||
|
let response = self
|
||||||
|
.agent
|
||||||
|
.get(format!("{base}{path}"))
|
||||||
|
.query_pairs(query.iter().copied())
|
||||||
|
.config()
|
||||||
|
.timeout_global(Some(remaining.min(Duration::from_secs(5))))
|
||||||
|
.build()
|
||||||
|
.call();
|
||||||
|
let mut response = match response {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(_) => {
|
||||||
|
last_error = io::Error::other(format!("{base}: HTTP transport failed"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let mime = response.body().mime_type().unwrap_or("missing");
|
||||||
|
if status != 200 {
|
||||||
|
let kind = match status {
|
||||||
|
401 | 403 => io::ErrorKind::PermissionDenied,
|
||||||
|
404 => io::ErrorKind::NotFound,
|
||||||
|
_ => io::ErrorKind::Other,
|
||||||
|
};
|
||||||
|
last_error = io::Error::new(kind, format!("{base}: HTTP {status} ({mime})"));
|
||||||
|
// Do not rotate hosts to work around rate limits or invalid requests.
|
||||||
|
if !matches!(status, 301..=308 | 403 | 404 | 408 | 500..=599) {
|
||||||
|
return Err(last_error);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !types.contains(&mime) {
|
||||||
|
last_error = invalid(format!("{base}: unexpected Content-Type {mime}"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match crate::read_catalog_body(response.body_mut(), BODY_LIMIT) {
|
||||||
|
Ok(body) => {
|
||||||
|
self.preferred.store(index, Ordering::Relaxed);
|
||||||
|
return Ok(body);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
last_error = invalid(format!(
|
||||||
|
"{base}: response unreadable, timed out, or exceeded 2 MiB"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(io::Error::new(
|
||||||
|
last_error.kind(),
|
||||||
|
format!(
|
||||||
|
"Miruro request failed on {attempts}/{} mirrors; last error: {last_error}",
|
||||||
|
mirrors.len()
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid(message: impl std::fmt::Display) -> io::Error {
|
||||||
|
io::Error::new(io::ErrorKind::InvalidData, message.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_segment(segment: &str) -> io::Result<()> {
|
||||||
|
if segment.is_empty()
|
||||||
|
|| !segment
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
|
||||||
|
{
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
"Invalid Miruro ID or slug path segment",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reference(value: &Value) -> io::Result<usize> {
|
||||||
|
value
|
||||||
|
.as_u64()
|
||||||
|
.and_then(|index| usize::try_from(index).ok())
|
||||||
|
.ok_or_else(|| invalid("Invalid or unsupported devalue reference"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field(table: &[Value], key: &str) -> io::Result<usize> {
|
||||||
|
reference(
|
||||||
|
table
|
||||||
|
.first()
|
||||||
|
.and_then(|root| root.get(key))
|
||||||
|
.ok_or_else(|| invalid(format!("Missing Miruro route field: {key}")))?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_detail(body: &str) -> io::Result<Detail> {
|
||||||
|
let mut lines = body.lines().filter(|line| !line.trim().is_empty());
|
||||||
|
let initial: Value = serde_json::from_str(
|
||||||
|
lines
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| invalid("Empty Miruro route data"))?,
|
||||||
|
)
|
||||||
|
.map_err(invalid)?;
|
||||||
|
if initial.get("type").and_then(Value::as_str) == Some("redirect") {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::NotFound,
|
||||||
|
"Miruro detail returned a route redirect",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if initial.get("type").and_then(Value::as_str) != Some("data") {
|
||||||
|
return Err(invalid("Unexpected Miruro route envelope"));
|
||||||
|
}
|
||||||
|
let node = initial
|
||||||
|
.get("nodes")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.and_then(|nodes| nodes.get(1))
|
||||||
|
.filter(|node| node.get("type").and_then(Value::as_str) == Some("data"))
|
||||||
|
.ok_or_else(|| invalid("Missing Miruro detail route node"))?;
|
||||||
|
let table = node
|
||||||
|
.get("data")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.ok_or_else(|| invalid("Missing Miruro detail value table"))?;
|
||||||
|
let mut budget = BODY_LIMIT as usize;
|
||||||
|
let media = expand(table, field(table, "detail")?, 0, &mut budget)?;
|
||||||
|
let promise = table
|
||||||
|
.get(field(table, "episodes")?)
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.filter(|tag| tag.len() == 2 && tag[0].as_str() == Some("Promise"))
|
||||||
|
.ok_or_else(|| invalid("Missing Miruro episode promise"))?;
|
||||||
|
let promise_id = table
|
||||||
|
.get(reference(&promise[1])?)
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.ok_or_else(|| invalid("Invalid Miruro episode promise ID"))?;
|
||||||
|
let mut episodes = None;
|
||||||
|
for line in lines {
|
||||||
|
let chunk: Value = serde_json::from_str(line).map_err(invalid)?;
|
||||||
|
if chunk.get("type").and_then(Value::as_str) != Some("chunk")
|
||||||
|
|| chunk.get("id").and_then(Value::as_u64) != Some(promise_id)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if episodes.is_some() || chunk.get("error").is_some() {
|
||||||
|
return Err(invalid("Miruro episode promise was duplicated or rejected"));
|
||||||
|
}
|
||||||
|
let table = chunk
|
||||||
|
.get("data")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.ok_or_else(|| invalid("Missing Miruro episode chunk table"))?;
|
||||||
|
episodes = match expand(table, 0, 0, &mut budget)? {
|
||||||
|
Value::Array(items) => Some(items),
|
||||||
|
_ => {
|
||||||
|
return Err(invalid(
|
||||||
|
"Miruro episode promise did not resolve to an array",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Ok(Detail {
|
||||||
|
media,
|
||||||
|
episodes: episodes.ok_or_else(|| invalid("Miruro episode promise never resolved"))?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: JSON-shaped detail/episode tables only; reject other devalue types
|
||||||
|
// rather than silently coerce them. Add tags only if the catalog actually needs them.
|
||||||
|
fn expand(table: &[Value], index: usize, depth: usize, budget: &mut usize) -> io::Result<Value> {
|
||||||
|
if depth >= 64 {
|
||||||
|
return Err(invalid("Miruro devalue reference cycle or excessive depth"));
|
||||||
|
}
|
||||||
|
let value = table
|
||||||
|
.get(index)
|
||||||
|
.ok_or_else(|| invalid("Miruro devalue reference out of bounds"))?;
|
||||||
|
let cost = size_of::<Value>() + value.as_str().map_or(0, str::len);
|
||||||
|
*budget = budget
|
||||||
|
.checked_sub(cost)
|
||||||
|
.ok_or_else(|| invalid("Miruro decoded data exceeded its size budget"))?;
|
||||||
|
match value {
|
||||||
|
Value::Object(object) => {
|
||||||
|
let mut decoded = Map::new();
|
||||||
|
for (key, value) in object {
|
||||||
|
*budget = budget
|
||||||
|
.checked_sub(key.len())
|
||||||
|
.ok_or_else(|| invalid("Miruro decoded keys exceeded their size budget"))?;
|
||||||
|
decoded.insert(
|
||||||
|
key.clone(),
|
||||||
|
expand(table, reference(value)?, depth + 1, budget)?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(Value::Object(decoded))
|
||||||
|
}
|
||||||
|
Value::Array(array) => {
|
||||||
|
if array.first().is_some_and(Value::is_string) {
|
||||||
|
return Err(invalid("Unsupported Miruro devalue tagged type"));
|
||||||
|
}
|
||||||
|
array
|
||||||
|
.iter()
|
||||||
|
.map(|value| expand(table, reference(value)?, depth + 1, budget))
|
||||||
|
.collect::<io::Result<Vec<_>>>()
|
||||||
|
.map(Value::Array)
|
||||||
|
}
|
||||||
|
primitive => Ok(primitive.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../tests/unit/miruro.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
use crate::youtube::Video;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::{
|
||||||
|
io,
|
||||||
|
sync::mpsc::{self, Receiver, SyncSender},
|
||||||
|
thread,
|
||||||
|
};
|
||||||
|
use tubular::{
|
||||||
|
anime::{Identity, Provider},
|
||||||
|
miruro::{Client, Detail},
|
||||||
|
};
|
||||||
|
use unicode_normalization::UnicodeNormalization;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Media {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
// Retain optional/null metadata exactly as returned; it is not a Video.
|
||||||
|
pub metadata: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Episode {
|
||||||
|
pub identity: Identity,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub synopsis: Option<String>,
|
||||||
|
pub aired_on: Option<String>,
|
||||||
|
pub duration_seconds: Option<u64>,
|
||||||
|
pub metadata: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Episode {
|
||||||
|
pub fn snapshot(&self, media: &Media) -> Video {
|
||||||
|
let mut snapshot = video(
|
||||||
|
self.identity.key(),
|
||||||
|
format!(
|
||||||
|
"{} · Episode {}{}",
|
||||||
|
media.title,
|
||||||
|
self.identity.episode,
|
||||||
|
self.title
|
||||||
|
.as_ref()
|
||||||
|
.map(|title| format!(" · {title}"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
),
|
||||||
|
self.aired_on.clone(),
|
||||||
|
);
|
||||||
|
if self.identity.provider == "anilist" {
|
||||||
|
snapshot.channel = Some("AniList".into());
|
||||||
|
}
|
||||||
|
snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Series {
|
||||||
|
pub media: Media,
|
||||||
|
pub episodes: Vec<Episode>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Request {
|
||||||
|
Search(String),
|
||||||
|
Episodes { media_id: String, slug: String },
|
||||||
|
}
|
||||||
|
pub enum Response {
|
||||||
|
Catalog(Vec<Media>),
|
||||||
|
Series(Series),
|
||||||
|
}
|
||||||
|
|
||||||
|
// One running request, no queued network requests. The UI retains only its newest intent.
|
||||||
|
pub struct Worker {
|
||||||
|
sender: SyncSender<(u64, Request)>,
|
||||||
|
receiver: Receiver<(u64, io::Result<Response>)>,
|
||||||
|
pub busy: bool,
|
||||||
|
generation: u64,
|
||||||
|
}
|
||||||
|
impl Worker {
|
||||||
|
pub fn new(provider: Provider) -> Self {
|
||||||
|
let (sender, requests) = mpsc::sync_channel::<(u64, Request)>(1);
|
||||||
|
let (results, receiver) = mpsc::sync_channel(1);
|
||||||
|
thread::spawn(move || {
|
||||||
|
let client = Client::default();
|
||||||
|
let anilist = tubular::anilist::Client::default();
|
||||||
|
while let Ok((generation, request)) = requests.recv() {
|
||||||
|
let result = match (provider, request) {
|
||||||
|
(Provider::Miruro, Request::Search(query)) => client
|
||||||
|
.search(&query)
|
||||||
|
.and_then(catalog)
|
||||||
|
.map(Response::Catalog),
|
||||||
|
(Provider::Miruro, Request::Episodes { media_id, slug }) => client
|
||||||
|
.detail(&media_id, &slug)
|
||||||
|
.and_then(|detail| series(&media_id, detail))
|
||||||
|
.map(Response::Series),
|
||||||
|
(Provider::AniList, Request::Search(query)) => anilist
|
||||||
|
.search(&query)
|
||||||
|
.and_then(catalog)
|
||||||
|
.map(Response::Catalog),
|
||||||
|
(Provider::AniList, Request::Episodes { media_id, .. }) => anilist
|
||||||
|
.detail(&media_id)
|
||||||
|
.and_then(anilist_series)
|
||||||
|
.map(Response::Series),
|
||||||
|
};
|
||||||
|
if results.send((generation, result)).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Self {
|
||||||
|
sender,
|
||||||
|
receiver,
|
||||||
|
busy: false,
|
||||||
|
generation: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn submit(&mut self, generation: u64, request: Request) -> io::Result<()> {
|
||||||
|
if self.busy {
|
||||||
|
return Err(io::Error::other("Anime catalog worker is busy"));
|
||||||
|
}
|
||||||
|
self.sender
|
||||||
|
.try_send((generation, request))
|
||||||
|
.map_err(|_| io::Error::other("Anime catalog worker unavailable"))?;
|
||||||
|
self.busy = true;
|
||||||
|
self.generation = generation;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub fn poll(&mut self) -> Option<(u64, io::Result<Response>)> {
|
||||||
|
match self.receiver.try_recv() {
|
||||||
|
Ok(result) => {
|
||||||
|
self.busy = false;
|
||||||
|
Some(result)
|
||||||
|
}
|
||||||
|
Err(mpsc::TryRecvError::Empty) => None,
|
||||||
|
Err(mpsc::TryRecvError::Disconnected) if self.busy => {
|
||||||
|
self.busy = false;
|
||||||
|
Some((
|
||||||
|
self.generation,
|
||||||
|
Err(io::Error::other("Anime catalog worker stopped")),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid(message: &str) -> io::Error {
|
||||||
|
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||||
|
}
|
||||||
|
fn title(media: &Value) -> io::Result<String> {
|
||||||
|
let get = |language| {
|
||||||
|
media
|
||||||
|
.get("title")
|
||||||
|
.and_then(|v| v.get(language))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(|text| text.trim_matches(js_whitespace))
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
};
|
||||||
|
let selected = match (get("english"), get("romaji")) {
|
||||||
|
(Some(a), Some(b)) => {
|
||||||
|
if a.encode_utf16().count() <= b.encode_utf16().count() {
|
||||||
|
a
|
||||||
|
} else {
|
||||||
|
b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Some(a), None) | (None, Some(a)) => a,
|
||||||
|
_ => media
|
||||||
|
.get("title")
|
||||||
|
.and_then(|value| value.get("native"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.or_else(|| media.get("id").and_then(Value::as_str))
|
||||||
|
.ok_or_else(|| invalid("Miruro title is missing"))?,
|
||||||
|
};
|
||||||
|
Ok(selected.to_owned())
|
||||||
|
}
|
||||||
|
// ECMAScript \s/trim, not Rust's Unicode whitespace (which includes U+0085).
|
||||||
|
fn js_whitespace(ch: char) -> bool {
|
||||||
|
matches!(ch, '\u{0009}'..='\u{000d}' | ' ' | '\u{00a0}' | '\u{1680}'
|
||||||
|
| '\u{2000}'..='\u{200a}' | '\u{2028}' | '\u{2029}' | '\u{202f}'
|
||||||
|
| '\u{205f}' | '\u{3000}' | '\u{feff}')
|
||||||
|
}
|
||||||
|
pub fn slug(title: &str) -> io::Result<String> {
|
||||||
|
let cleaned: String = title
|
||||||
|
.nfd()
|
||||||
|
.filter(|ch| !('\u{0300}'..='\u{036f}').contains(ch))
|
||||||
|
.flat_map(char::to_lowercase)
|
||||||
|
.filter(|ch| {
|
||||||
|
ch.is_ascii_lowercase() || ch.is_ascii_digit() || js_whitespace(*ch) || *ch == '-'
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut slug = String::new();
|
||||||
|
for ch in cleaned.trim_matches(js_whitespace).chars() {
|
||||||
|
if ch.is_ascii_alphanumeric() {
|
||||||
|
slug.push(ch);
|
||||||
|
} else if (js_whitespace(ch) || ch == '-') && !slug.ends_with('-') {
|
||||||
|
slug.push('-');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if slug.is_empty() {
|
||||||
|
Err(invalid("Miruro title has no representable route slug"))
|
||||||
|
} else {
|
||||||
|
Ok(slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn video(id: String, title: String, date: Option<String>) -> Video {
|
||||||
|
Video {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
channel: Some("Miruro".into()),
|
||||||
|
channel_id: None,
|
||||||
|
uploader_id: None,
|
||||||
|
timestamp: None,
|
||||||
|
duration: None,
|
||||||
|
publication_date: date,
|
||||||
|
availability: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn media(metadata: Value) -> io::Result<Media> {
|
||||||
|
let id = metadata
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or_else(|| invalid("Miruro catalog ID is missing"))?
|
||||||
|
.to_owned();
|
||||||
|
Identity::miruro(id.clone(), 1)?;
|
||||||
|
Ok(Media {
|
||||||
|
id,
|
||||||
|
title: title(&metadata)?,
|
||||||
|
metadata,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn catalog(items: Vec<Value>) -> io::Result<Vec<Media>> {
|
||||||
|
if items.len() > 8 {
|
||||||
|
return Err(invalid("Miruro autocomplete exceeded eight results"));
|
||||||
|
}
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
items
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| {
|
||||||
|
let media = media(item)?;
|
||||||
|
if !seen.insert(media.id.clone()) {
|
||||||
|
return Err(invalid("Miruro returned duplicate catalog IDs"));
|
||||||
|
}
|
||||||
|
Ok(media)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn series(media_id: &str, detail: Detail) -> io::Result<Series> {
|
||||||
|
let media = media(detail.media)?;
|
||||||
|
if media.id != media_id {
|
||||||
|
return Err(invalid("Miruro detail ID did not match the requested ID"));
|
||||||
|
}
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
let episodes = detail
|
||||||
|
.episodes
|
||||||
|
.into_iter()
|
||||||
|
.map(|metadata| {
|
||||||
|
let number = metadata
|
||||||
|
.get("episode_number")
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.and_then(|n| u32::try_from(n).ok())
|
||||||
|
.ok_or_else(|| invalid("Miruro episode number is invalid"))?;
|
||||||
|
let identity = Identity::miruro(media_id.to_owned(), number)?;
|
||||||
|
if !seen.insert(number) {
|
||||||
|
return Err(invalid("Miruro returned duplicate episode numbers"));
|
||||||
|
}
|
||||||
|
let text = |field| {
|
||||||
|
metadata
|
||||||
|
.get(field)
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::to_owned)
|
||||||
|
};
|
||||||
|
Ok(Episode {
|
||||||
|
identity,
|
||||||
|
title: text("title"),
|
||||||
|
synopsis: text("synopsis"),
|
||||||
|
aired_on: text("aired_on"),
|
||||||
|
duration_seconds: metadata.get("duration_seconds").and_then(Value::as_u64),
|
||||||
|
metadata,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<io::Result<Vec<_>>>()?;
|
||||||
|
Ok(Series { media, episodes })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn media_provider(media: &Media) -> Provider {
|
||||||
|
if media
|
||||||
|
.metadata
|
||||||
|
.get("catalog_provider")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
== Some("anilist")
|
||||||
|
{
|
||||||
|
Provider::AniList
|
||||||
|
} else {
|
||||||
|
Provider::Miruro
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn anilist_episode(media: &Media, number: u32) -> io::Result<Episode> {
|
||||||
|
let identity = Identity::anilist(media.id.clone(), number)?;
|
||||||
|
Ok(Episode {
|
||||||
|
identity,
|
||||||
|
title: None,
|
||||||
|
synopsis: Some("Catalog numbering does not guarantee a stream. Visible episodes are checked separately for ZokoAnime sub sources; playback always resolves fresh.".into()),
|
||||||
|
aired_on: None,
|
||||||
|
duration_seconds: None,
|
||||||
|
metadata: Value::Null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn anilist_series(metadata: Value) -> io::Result<Series> {
|
||||||
|
let media = media(metadata)?;
|
||||||
|
Identity::anilist(media.id.clone(), 1)?;
|
||||||
|
let count = tubular::anilist::catalog_count(&media.metadata)?;
|
||||||
|
if count.is_some_and(|count| count > 10000) {
|
||||||
|
return Err(invalid(
|
||||||
|
"AniList catalog exceeds the 10000 episode display limit",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let episodes = (1..=count.unwrap_or(0))
|
||||||
|
.map(|number| anilist_episode(&media, number))
|
||||||
|
.collect::<io::Result<Vec<_>>>()?;
|
||||||
|
Ok(Series { media, episodes })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../tests/unit/miruro_browser.rs"]
|
||||||
|
mod tests;
|
||||||
+2504
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,375 @@
|
|||||||
|
//! Session-only availability checks. Never retain resolved media or provider diagnostics.
|
||||||
|
use std::{
|
||||||
|
collections::BTreeMap,
|
||||||
|
ffi::OsStr,
|
||||||
|
io::{self, Read},
|
||||||
|
process::{Child, ChildStdout, Stdio},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tubular::anime::{Identity, Provider};
|
||||||
|
|
||||||
|
const TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
const OUTPUT_LIMIT: usize = 4096;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub(super) enum Status {
|
||||||
|
Available,
|
||||||
|
Unavailable,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
impl Status {
|
||||||
|
pub(super) fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Available => "ZokoAnime · sub",
|
||||||
|
Self::Unavailable => "No source found",
|
||||||
|
Self::Failed => "Source check failed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct Reply {
|
||||||
|
version: u32,
|
||||||
|
provider: String,
|
||||||
|
track: String,
|
||||||
|
status: Status,
|
||||||
|
}
|
||||||
|
fn decode(bytes: &[u8], success: bool) -> Status {
|
||||||
|
if !success || bytes.len() > OUTPUT_LIMIT {
|
||||||
|
return Status::Failed;
|
||||||
|
}
|
||||||
|
let Ok(text) = std::str::from_utf8(bytes) else {
|
||||||
|
return Status::Failed;
|
||||||
|
};
|
||||||
|
let text = text.strip_suffix('\n').unwrap_or(text);
|
||||||
|
if text.contains(['\n', '\r']) {
|
||||||
|
return Status::Failed;
|
||||||
|
}
|
||||||
|
match serde_json::from_str::<Reply>(text) {
|
||||||
|
Ok(reply)
|
||||||
|
if reply.version == 1 && reply.provider == "zokoanime" && reply.track == "sub" =>
|
||||||
|
{
|
||||||
|
reply.status
|
||||||
|
}
|
||||||
|
_ => Status::Failed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Job {
|
||||||
|
child: Child,
|
||||||
|
stdout: ChildStdout,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
deadline: Instant,
|
||||||
|
eof: bool,
|
||||||
|
}
|
||||||
|
impl Job {
|
||||||
|
fn start(player: &OsStr, key: &str, now: Instant) -> io::Result<Self> {
|
||||||
|
let identity = Identity::from_key(key)?;
|
||||||
|
if identity.provider != Provider::AniList.key() {
|
||||||
|
return Err(io::Error::other("Unsupported source check"));
|
||||||
|
}
|
||||||
|
let mut command = crate::youtube::isolated_command(player);
|
||||||
|
command.arg("--check-source").arg(identity.watch_url()?);
|
||||||
|
Self::spawn(&mut command, now)
|
||||||
|
}
|
||||||
|
fn spawn(command: &mut std::process::Command, now: Instant) -> io::Result<Self> {
|
||||||
|
let mut child = command
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn()?;
|
||||||
|
let stdout = child.stdout.take().expect("piped stdout");
|
||||||
|
let mut job = Self {
|
||||||
|
child,
|
||||||
|
stdout,
|
||||||
|
bytes: Vec::new(),
|
||||||
|
deadline: now + TIMEOUT,
|
||||||
|
eof: false,
|
||||||
|
};
|
||||||
|
job.nonblocking()?;
|
||||||
|
Ok(job)
|
||||||
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn nonblocking(&mut self) -> io::Result<()> {
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
let fd = self.stdout.as_raw_fd();
|
||||||
|
// SAFETY: stdout owns this live descriptor; preserve the existing flags.
|
||||||
|
let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
|
||||||
|
if flags < 0 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn nonblocking(&mut self) -> io::Result<()> {
|
||||||
|
Err(io::Error::other(
|
||||||
|
"Source checks require nonblocking process pipes",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
fn poll(&mut self, now: Instant) -> Option<Status> {
|
||||||
|
if now >= self.deadline {
|
||||||
|
return Some(Status::Failed);
|
||||||
|
}
|
||||||
|
// Each poll consumes at most the protocol limit plus one byte, even from a noisy CLI.
|
||||||
|
let mut buffer = [0; OUTPUT_LIMIT + 1];
|
||||||
|
while !self.eof {
|
||||||
|
let remaining = OUTPUT_LIMIT + 1 - self.bytes.len();
|
||||||
|
match self.stdout.read(&mut buffer[..remaining]) {
|
||||||
|
Ok(0) => self.eof = true,
|
||||||
|
Ok(count) => {
|
||||||
|
self.bytes.extend_from_slice(&buffer[..count]);
|
||||||
|
if self.bytes.len() > OUTPUT_LIMIT {
|
||||||
|
return Some(Status::Failed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::WouldBlock => break,
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::Interrupted => break,
|
||||||
|
Err(_) => return Some(Status::Failed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match self.child.try_wait() {
|
||||||
|
Ok(Some(status)) if self.eof => Some(decode(&self.bytes, status.success())),
|
||||||
|
Ok(_) => None,
|
||||||
|
Err(_) => Some(Status::Failed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for Job {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// The isolated group may still have descendants holding stdout after the leader exits.
|
||||||
|
#[cfg(unix)]
|
||||||
|
unsafe {
|
||||||
|
libc::kill(-(self.child.id() as i32), libc::SIGKILL);
|
||||||
|
}
|
||||||
|
let _ = self.child.kill();
|
||||||
|
let _ = self.child.wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Active {
|
||||||
|
generation: u64,
|
||||||
|
key: String,
|
||||||
|
job: Job,
|
||||||
|
}
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(super) struct Checks {
|
||||||
|
cache: BTreeMap<String, Status>,
|
||||||
|
active: Option<Active>,
|
||||||
|
}
|
||||||
|
impl Checks {
|
||||||
|
pub(super) fn cancel(&mut self) {
|
||||||
|
self.active = None;
|
||||||
|
}
|
||||||
|
pub(super) fn clear(&mut self) {
|
||||||
|
self.cancel();
|
||||||
|
self.cache.clear();
|
||||||
|
}
|
||||||
|
pub(super) fn status(&self, key: &str) -> Option<Status> {
|
||||||
|
self.cache.get(key).copied()
|
||||||
|
}
|
||||||
|
pub(super) fn label(&self, key: &str) -> &'static str {
|
||||||
|
if let Some(status) = self.status(key) {
|
||||||
|
status.label()
|
||||||
|
} else if self.active.as_ref().is_some_and(|active| active.key == key) {
|
||||||
|
"Checking source…"
|
||||||
|
} else {
|
||||||
|
"Source check pending"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn next<'a>(&self, visible: &'a [String], selected: Option<&'a str>) -> Option<&'a str> {
|
||||||
|
selected
|
||||||
|
.filter(|key| {
|
||||||
|
visible.iter().any(|item| item.as_str() == *key) && !self.cache.contains_key(*key)
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
visible
|
||||||
|
.iter()
|
||||||
|
.find(|key| !self.cache.contains_key(*key))
|
||||||
|
.map(String::as_str)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn finish(
|
||||||
|
&mut self,
|
||||||
|
generation: u64,
|
||||||
|
result_generation: u64,
|
||||||
|
key: String,
|
||||||
|
status: Status,
|
||||||
|
visible: &[String],
|
||||||
|
) {
|
||||||
|
if generation == result_generation && visible.contains(&key) {
|
||||||
|
self.cache.insert(key, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(super) fn update(
|
||||||
|
&mut self,
|
||||||
|
generation: u64,
|
||||||
|
visible: &[String],
|
||||||
|
selected: Option<&str>,
|
||||||
|
player: &OsStr,
|
||||||
|
now: Instant,
|
||||||
|
) {
|
||||||
|
if self
|
||||||
|
.active
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|active| active.generation != generation || !visible.contains(&active.key))
|
||||||
|
{
|
||||||
|
self.cancel();
|
||||||
|
}
|
||||||
|
// Cursor moves do not starve a check that is still visible. New selection gets the next turn.
|
||||||
|
if let Some(status) = self.active.as_mut().and_then(|active| active.job.poll(now)) {
|
||||||
|
let active = self.active.take().expect("completed check");
|
||||||
|
self.finish(generation, active.generation, active.key, status, visible);
|
||||||
|
}
|
||||||
|
if self.active.is_none()
|
||||||
|
&& let Some(key) = self.next(visible, selected)
|
||||||
|
{
|
||||||
|
let key = key.to_owned();
|
||||||
|
match Job::start(player, &key, now) {
|
||||||
|
Ok(job) => {
|
||||||
|
self.active = Some(Active {
|
||||||
|
generation,
|
||||||
|
key,
|
||||||
|
job,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
self.cache.insert(key, Status::Failed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
fn clock() -> Instant {
|
||||||
|
// All transitions derive from this fixed injected instant; no sleeps or wall-clock assertions.
|
||||||
|
*std::sync::LazyLock::force(&CLOCK)
|
||||||
|
}
|
||||||
|
static CLOCK: std::sync::LazyLock<Instant> = std::sync::LazyLock::new(Instant::now);
|
||||||
|
#[test]
|
||||||
|
fn protocol_distinguishes_missing_from_failure_and_rejects_other_output() {
|
||||||
|
for (status, expected) in [
|
||||||
|
("available", Status::Available),
|
||||||
|
("unavailable", Status::Unavailable),
|
||||||
|
("failed", Status::Failed),
|
||||||
|
] {
|
||||||
|
let text = format!(
|
||||||
|
r#"{{"version":1,"provider":"zokoanime","track":"sub","status":"{status}"}}"#
|
||||||
|
);
|
||||||
|
assert_eq!(decode(text.as_bytes(), true), expected);
|
||||||
|
assert_eq!(decode(text.as_bytes(), false), Status::Failed);
|
||||||
|
}
|
||||||
|
for text in [
|
||||||
|
"",
|
||||||
|
"old CLI help",
|
||||||
|
r#"{"version":2,"provider":"zokoanime","track":"sub","status":"available"}"#,
|
||||||
|
r#"{"version":1,"provider":"other","track":"sub","status":"available"}"#,
|
||||||
|
r#"{"version":1,"provider":"zokoanime","track":"dub","status":"available"}"#,
|
||||||
|
] {
|
||||||
|
assert_eq!(decode(text.as_bytes(), true), Status::Failed);
|
||||||
|
}
|
||||||
|
assert_eq!(decode(&vec![b' '; OUTPUT_LIMIT + 1], true), Status::Failed);
|
||||||
|
assert_eq!(decode(b"{}\n{}", true), Status::Failed);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn selected_then_visible_and_no_retry_until_refresh() {
|
||||||
|
let mut checks = Checks::default();
|
||||||
|
let visible = vec!["anilist:1:3".into(), "anilist:1:4".into()];
|
||||||
|
assert_eq!(
|
||||||
|
checks.next(&visible, Some("anilist:1:4")),
|
||||||
|
Some("anilist:1:4")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
checks.next(&visible, Some("anilist:1:99")),
|
||||||
|
Some("anilist:1:3")
|
||||||
|
);
|
||||||
|
for key in &visible {
|
||||||
|
checks.finish(1, 1, key.clone(), Status::Failed, &visible);
|
||||||
|
}
|
||||||
|
assert_eq!(checks.next(&visible, Some("anilist:1:4")), None);
|
||||||
|
checks.clear();
|
||||||
|
assert_eq!(
|
||||||
|
checks.next(&visible, Some("anilist:1:4")),
|
||||||
|
Some("anilist:1:4")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn late_generation_or_offscreen_results_do_not_enter_cache() {
|
||||||
|
let mut checks = Checks::default();
|
||||||
|
let visible: Vec<String> = vec!["anilist:1:3".into()];
|
||||||
|
checks.finish(2, 1, visible[0].clone(), Status::Available, &visible);
|
||||||
|
checks.finish(2, 2, "anilist:1:2".into(), Status::Available, &visible);
|
||||||
|
assert!(checks.cache.is_empty());
|
||||||
|
checks.finish(2, 2, visible[0].clone(), Status::Unavailable, &visible);
|
||||||
|
assert_eq!(checks.cache.get(&visible[0]), Some(&Status::Unavailable));
|
||||||
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn waiting_job() -> Job {
|
||||||
|
let mut command = crate::youtube::isolated_command(OsStr::new("/bin/sh"));
|
||||||
|
command.args(["-c", "kill -STOP $$"]);
|
||||||
|
Job::spawn(&mut command, clock()).unwrap()
|
||||||
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn injected_timeout_and_drop_reap_the_child() {
|
||||||
|
let mut job = waiting_job();
|
||||||
|
let pid = job.child.id();
|
||||||
|
assert_eq!(job.poll(clock() + TIMEOUT), Some(Status::Failed));
|
||||||
|
drop(job);
|
||||||
|
let mut status = 0;
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { libc::waitpid(pid as i32, &mut status, libc::WNOHANG) },
|
||||||
|
-1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
io::Error::last_os_error().raw_os_error(),
|
||||||
|
Some(libc::ECHILD)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn cancel_offscreen_or_generation_but_not_visible_deselection() {
|
||||||
|
let visible: Vec<String> = vec!["anilist:1:3".into(), "anilist:1:4".into()];
|
||||||
|
let mut checks = Checks {
|
||||||
|
active: Some(Active {
|
||||||
|
generation: 7,
|
||||||
|
key: visible[0].clone(),
|
||||||
|
job: waiting_job(),
|
||||||
|
}),
|
||||||
|
..Checks::default()
|
||||||
|
};
|
||||||
|
checks.update(
|
||||||
|
7,
|
||||||
|
&visible,
|
||||||
|
Some(&visible[1]),
|
||||||
|
OsStr::new("/missing-player"),
|
||||||
|
clock(),
|
||||||
|
);
|
||||||
|
assert_eq!(checks.active.as_ref().unwrap().key, visible[0]);
|
||||||
|
checks.update(7, &[], None, OsStr::new("/missing-player"), clock());
|
||||||
|
assert!(checks.active.is_none());
|
||||||
|
assert!(checks.cache.is_empty());
|
||||||
|
checks.active = Some(Active {
|
||||||
|
generation: 7,
|
||||||
|
key: visible[0].clone(),
|
||||||
|
job: waiting_job(),
|
||||||
|
});
|
||||||
|
checks.update(8, &[], None, OsStr::new("/missing-player"), clock());
|
||||||
|
assert!(checks.active.is_none());
|
||||||
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn old_cli_exit_without_protocol_is_failed_without_blocking_playback_contract() {
|
||||||
|
let mut job = Job::start(OsStr::new("/usr/bin/true"), "anilist:1:1", clock()).unwrap();
|
||||||
|
job.child.wait().unwrap();
|
||||||
|
assert_eq!(job.poll(clock()), Some(Status::Failed));
|
||||||
|
assert!(Job::start(OsStr::new("/usr/bin/true"), "miruro:1:1", clock()).is_err());
|
||||||
|
assert!(Job::start(OsStr::new("/missing-player"), "anilist:1:1", clock()).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
-266
@@ -4,7 +4,7 @@ use std::{
|
|||||||
thread,
|
thread,
|
||||||
};
|
};
|
||||||
|
|
||||||
use image::{ImageFormat, ImageReader, Limits};
|
use image::{ImageReader, Limits};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::{Alignment, Rect, Size},
|
layout::{Alignment, Rect, Size},
|
||||||
@@ -30,6 +30,7 @@ struct ThumbnailImage {
|
|||||||
pub struct Thumbnail {
|
pub struct Thumbnail {
|
||||||
picker: Picker,
|
picker: Picker,
|
||||||
selected: Option<String>,
|
selected: Option<String>,
|
||||||
|
aspect: (u32, u32),
|
||||||
fetch: Option<ThumbnailJob>,
|
fetch: Option<ThumbnailJob>,
|
||||||
decoder: Option<Receiver<Result<ThumbnailImage, String>>>,
|
decoder: Option<Receiver<Result<ThumbnailImage, String>>>,
|
||||||
image: Option<ThumbnailImage>,
|
image: Option<ThumbnailImage>,
|
||||||
@@ -43,6 +44,7 @@ impl Thumbnail {
|
|||||||
Self {
|
Self {
|
||||||
picker: Picker::halfblocks(),
|
picker: Picker::halfblocks(),
|
||||||
selected: None,
|
selected: None,
|
||||||
|
aspect: (4, 3),
|
||||||
fetch: None,
|
fetch: None,
|
||||||
decoder: None,
|
decoder: None,
|
||||||
image: None,
|
image: None,
|
||||||
@@ -61,6 +63,19 @@ impl Thumbnail {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn select(&mut self, id: &str) {
|
pub fn select(&mut self, id: &str) {
|
||||||
|
self.select_with(id, (4, 3), ThumbnailJob::start);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_url(&mut self, url: &str) {
|
||||||
|
self.select_with(url, (2, 3), ThumbnailJob::from_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_with(
|
||||||
|
&mut self,
|
||||||
|
id: &str,
|
||||||
|
aspect: (u32, u32),
|
||||||
|
start: fn(&str) -> std::io::Result<ThumbnailJob>,
|
||||||
|
) {
|
||||||
if self.selected.as_deref() == Some(id) {
|
if self.selected.as_deref() == Some(id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -72,11 +87,12 @@ impl Thumbnail {
|
|||||||
.and_then(|index| self.cached.remove(index));
|
.and_then(|index| self.cached.remove(index));
|
||||||
self.cancel();
|
self.cancel();
|
||||||
self.selected = Some(id.to_owned());
|
self.selected = Some(id.to_owned());
|
||||||
|
self.aspect = aspect;
|
||||||
if let Some(image) = cached {
|
if let Some(image) = cached {
|
||||||
self.image = Some(image);
|
self.image = Some(image);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match ThumbnailJob::start(id) {
|
match start(id) {
|
||||||
Ok(job) => self.fetch = Some(job),
|
Ok(job) => self.fetch = Some(job),
|
||||||
Err(error) => self.error = Some(format!("Could not load thumbnail: {error}")),
|
Err(error) => self.error = Some(format!("Could not load thumbnail: {error}")),
|
||||||
}
|
}
|
||||||
@@ -118,13 +134,17 @@ impl Thumbnail {
|
|||||||
if area.width == 0 || area.height == 0 || self.selected.is_none() {
|
if area.width == 0 || area.height == 0 || self.selected.is_none() {
|
||||||
return area.width;
|
return area.width;
|
||||||
}
|
}
|
||||||
let slot = left_aligned(area, thumbnail_size(area, self.picker.font_size()));
|
let slot = left_aligned(
|
||||||
|
area,
|
||||||
|
thumbnail_size(area, self.picker.font_size(), self.aspect),
|
||||||
|
);
|
||||||
|
|
||||||
let mut message = self.error.as_deref().unwrap_or("Loading thumbnail...");
|
let mut message = self.error.as_deref().unwrap_or("Loading thumbnail...");
|
||||||
if let Some(image) = self.image.as_mut() {
|
if let Some(image) = self.image.as_mut() {
|
||||||
let resize = Resize::Scale(Some(FilterType::Triangle));
|
let resize = Resize::Scale(Some(FilterType::Triangle));
|
||||||
if let Some(size) = image.protocol.size_for(resize.clone(), area.into()) {
|
let bounds = if self.aspect == (4, 3) { area } else { slot };
|
||||||
let image_area = left_aligned(area, size);
|
if let Some(size) = image.protocol.size_for(resize.clone(), bounds.into()) {
|
||||||
|
let image_area = left_aligned(bounds, size);
|
||||||
frame.render_stateful_widget(
|
frame.render_stateful_widget(
|
||||||
StatefulImage::new().resize(resize),
|
StatefulImage::new().resize(resize),
|
||||||
image_area,
|
image_area,
|
||||||
@@ -158,7 +178,7 @@ impl Thumbnail {
|
|||||||
match result {
|
match result {
|
||||||
Ok(bytes) => {
|
Ok(bytes) => {
|
||||||
let picker = self.picker.clone();
|
let picker = self.picker.clone();
|
||||||
let id = self.selected.clone().expect("A fetch has a selected video");
|
let id = self.selected.clone().expect("A fetch has a selected image");
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
match thread::Builder::new()
|
match thread::Builder::new()
|
||||||
.name("thumbnail-decode".into())
|
.name("thumbnail-decode".into())
|
||||||
@@ -243,7 +263,9 @@ fn decode(bytes: Vec<u8>) -> Result<image::DynamicImage, String> {
|
|||||||
if bytes.len() > 2 * 1024 * 1024 {
|
if bytes.len() > 2 * 1024 * 1024 {
|
||||||
return Err("Thumbnail exceeds the 2 MiB download limit.".into());
|
return Err("Thumbnail exceeds the 2 MiB download limit.".into());
|
||||||
}
|
}
|
||||||
let mut reader = ImageReader::with_format(Cursor::new(bytes), ImageFormat::Jpeg);
|
let mut reader = ImageReader::new(Cursor::new(bytes))
|
||||||
|
.with_guessed_format()
|
||||||
|
.map_err(|error| format!("Could not identify thumbnail format: {error}"))?;
|
||||||
let mut limits = Limits::default();
|
let mut limits = Limits::default();
|
||||||
limits.max_image_width = Some(4096);
|
limits.max_image_width = Some(4096);
|
||||||
limits.max_image_height = Some(4096);
|
limits.max_image_height = Some(4096);
|
||||||
@@ -251,21 +273,28 @@ fn decode(bytes: Vec<u8>) -> Result<image::DynamicImage, String> {
|
|||||||
reader.limits(limits);
|
reader.limits(limits);
|
||||||
reader
|
reader
|
||||||
.decode()
|
.decode()
|
||||||
.map_err(|error| format!("Could not decode thumbnail JPEG: {error}"))
|
.map_err(|error| format!("Could not decode thumbnail: {error}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn thumbnail_size(area: Rect, font: FontSize) -> Size {
|
fn thumbnail_size(area: Rect, font: FontSize, aspect: (u32, u32)) -> Size {
|
||||||
if area.width == 0 || area.height == 0 || font.width == 0 || font.height == 0 {
|
if area.width == 0 || area.height == 0 || font.width == 0 || font.height == 0 {
|
||||||
return Size::default();
|
return Size::default();
|
||||||
}
|
}
|
||||||
// HQ thumbnails are 480x360. Match Scale's nearest-pixel rounding, then
|
// Match Scale's nearest-pixel rounding, then round up to cells without
|
||||||
// round up to cells without allocating a placeholder image.
|
// allocating a placeholder image. YouTube is 4:3; series covers reserve 2:3.
|
||||||
|
let (aspect_width, aspect_height) = (u64::from(aspect.0), u64::from(aspect.1));
|
||||||
let width = u64::from(area.width) * u64::from(font.width);
|
let width = u64::from(area.width) * u64::from(font.width);
|
||||||
let height = u64::from(area.height) * u64::from(font.height);
|
let height = u64::from(area.height) * u64::from(font.height);
|
||||||
let (width, height) = if width * 3 <= height * 4 {
|
let (width, height) = if width * aspect_height <= height * aspect_width {
|
||||||
(width, ((width * 3 + 2) / 4).max(1))
|
(
|
||||||
|
width,
|
||||||
|
((width * aspect_height + aspect_width / 2) / aspect_width).max(1),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
(((height * 4 + 1) / 3).max(1), height)
|
(
|
||||||
|
((height * aspect_width + aspect_height / 2) / aspect_height).max(1),
|
||||||
|
height,
|
||||||
|
)
|
||||||
};
|
};
|
||||||
Size::new(
|
Size::new(
|
||||||
width
|
width
|
||||||
@@ -284,255 +313,5 @@ fn left_aligned(area: Rect, size: Size) -> Rect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "../tests/unit/thumbnail.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn loading_thumbnail_keeps_the_same_text_column_as_the_ready_image() {
|
|
||||||
use ratatui::{Terminal, backend::TestBackend};
|
|
||||||
use std::time::Duration;
|
|
||||||
let mut thumbnail = Thumbnail::new();
|
|
||||||
thumbnail.selected = Some("fixture".into());
|
|
||||||
let area = Rect::new(2, 2, 80, 8);
|
|
||||||
let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap();
|
|
||||||
let mut loading_width = 0;
|
|
||||||
terminal
|
|
||||||
.draw(|frame| loading_width = thumbnail.draw(frame, area))
|
|
||||||
.unwrap();
|
|
||||||
let (_decode_tx, decode_rx) = mpsc::channel();
|
|
||||||
thumbnail.decoder = Some(decode_rx);
|
|
||||||
let mut decoding_width = 0;
|
|
||||||
terminal
|
|
||||||
.draw(|frame| decoding_width = thumbnail.draw(frame, area))
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(loading_width, decoding_width);
|
|
||||||
thumbnail.decoder = None;
|
|
||||||
let (requests_tx, requests_rx) = mpsc::channel::<ResizeRequest>();
|
|
||||||
let (replies_tx, replies_rx) = mpsc::channel();
|
|
||||||
thumbnail.image = Some(ThumbnailImage {
|
|
||||||
id: "fixture".into(),
|
|
||||||
protocol: ThreadProtocol::new(
|
|
||||||
requests_tx,
|
|
||||||
Some(
|
|
||||||
thumbnail
|
|
||||||
.picker
|
|
||||||
.new_resize_protocol(image::DynamicImage::new_rgb8(48, 36)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
replies: replies_rx,
|
|
||||||
complete: false,
|
|
||||||
});
|
|
||||||
let mut preparing_width = 0;
|
|
||||||
terminal
|
|
||||||
.draw(|frame| preparing_width = thumbnail.draw(frame, area))
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(loading_width, preparing_width);
|
|
||||||
terminal
|
|
||||||
.draw(|frame| preparing_width = thumbnail.draw(frame, area))
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(loading_width, preparing_width);
|
|
||||||
replies_tx
|
|
||||||
.send(Ok(requests_rx
|
|
||||||
.recv_timeout(Duration::from_secs(2))
|
|
||||||
.unwrap()
|
|
||||||
.resize_encode()
|
|
||||||
.unwrap()))
|
|
||||||
.unwrap();
|
|
||||||
let mut ready_width = 0;
|
|
||||||
terminal
|
|
||||||
.draw(|frame| ready_width = thumbnail.draw(frame, area))
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
loading_width, ready_width,
|
|
||||||
"right-hand content must not move when the image finishes loading"
|
|
||||||
);
|
|
||||||
thumbnail.cancel();
|
|
||||||
thumbnail.select("fixture");
|
|
||||||
terminal
|
|
||||||
.draw(|frame| ready_width = thumbnail.draw(frame, area))
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(loading_width, ready_width);
|
|
||||||
replies_tx
|
|
||||||
.send(Err("fixture renderer failure".into()))
|
|
||||||
.unwrap();
|
|
||||||
let mut error_width = 0;
|
|
||||||
terminal
|
|
||||||
.draw(|frame| error_width = thumbnail.draw(frame, area))
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(loading_width, error_width);
|
|
||||||
assert!(thumbnail.error.is_some());
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn rejects_corrupt_and_oversized_jpeg_input() {
|
|
||||||
assert!(decode(b"not a JPEG".to_vec()).is_err());
|
|
||||||
assert!(decode(vec![0; 2 * 1024 * 1024 + 1]).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cancellation_discards_queued_decode_completion() {
|
|
||||||
let mut thumbnail = Thumbnail::new();
|
|
||||||
let (tx, rx) = mpsc::channel();
|
|
||||||
let (requests_tx, _requests_rx) = mpsc::channel();
|
|
||||||
let (_replies_tx, replies_rx) = mpsc::channel();
|
|
||||||
let protocol = thumbnail
|
|
||||||
.picker
|
|
||||||
.new_resize_protocol(image::DynamicImage::new_rgb8(1, 1));
|
|
||||||
thumbnail.selected = Some("old-video".into());
|
|
||||||
thumbnail.decoder = Some(rx);
|
|
||||||
assert!(
|
|
||||||
tx.send(Ok(ThumbnailImage {
|
|
||||||
id: "old-video".into(),
|
|
||||||
protocol: ThreadProtocol::new(requests_tx, Some(protocol)),
|
|
||||||
replies: replies_rx,
|
|
||||||
complete: false,
|
|
||||||
}))
|
|
||||||
.is_ok()
|
|
||||||
);
|
|
||||||
thumbnail.cancel();
|
|
||||||
thumbnail.selected = Some("new-video".into());
|
|
||||||
thumbnail.poll();
|
|
||||||
assert!(thumbnail.image.is_none());
|
|
||||||
assert!(thumbnail.error.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn thumbnail_hits_survive_a_full_buffer_and_promote_recent_images() {
|
|
||||||
let mut thumbnail = Thumbnail::new();
|
|
||||||
let completed = |id: String| {
|
|
||||||
let (requests, _receiver) = mpsc::channel();
|
|
||||||
let (_sender, replies) = mpsc::channel();
|
|
||||||
ThumbnailImage {
|
|
||||||
id,
|
|
||||||
protocol: ThreadProtocol::new(
|
|
||||||
requests,
|
|
||||||
Some(
|
|
||||||
Picker::halfblocks()
|
|
||||||
.new_resize_protocol(image::DynamicImage::new_rgb8(1, 1)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
replies,
|
|
||||||
complete: true,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
for index in 0..CACHE_SIZE {
|
|
||||||
thumbnail.image = Some(completed(format!("video{index:06}")));
|
|
||||||
thumbnail.cancel();
|
|
||||||
}
|
|
||||||
thumbnail.image = Some(completed("video000008".into()));
|
|
||||||
thumbnail.selected = Some("video000008".into());
|
|
||||||
// This is the oldest cached image: storing the outgoing image first would evict it.
|
|
||||||
thumbnail.select("video000000");
|
|
||||||
assert_eq!(thumbnail.image.as_ref().unwrap().id, "video000000");
|
|
||||||
assert!(thumbnail.fetch.is_none());
|
|
||||||
thumbnail.select("video000001");
|
|
||||||
assert_eq!(thumbnail.image.as_ref().unwrap().id, "video000001");
|
|
||||||
assert!(thumbnail.fetch.is_none());
|
|
||||||
thumbnail.cancel();
|
|
||||||
assert!(
|
|
||||||
!thumbnail
|
|
||||||
.cached
|
|
||||||
.iter()
|
|
||||||
.any(|image| image.id == "video000002")
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
thumbnail
|
|
||||||
.cached
|
|
||||||
.iter()
|
|
||||||
.any(|image| image.id == "video000000")
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
thumbnail
|
|
||||||
.cached
|
|
||||||
.iter()
|
|
||||||
.any(|image| image.id == "video000008")
|
|
||||||
);
|
|
||||||
thumbnail.clear_cache();
|
|
||||||
assert!(thumbnail.cached.is_empty());
|
|
||||||
assert!(thumbnail.image.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn small_thumbnail_fills_popup_proportionally_and_resizes() {
|
|
||||||
use ratatui::{Terminal, backend::TestBackend, style::Color};
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
let mut thumbnail = Thumbnail::new();
|
|
||||||
let font = thumbnail.picker.font_size();
|
|
||||||
let image = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(
|
|
||||||
48,
|
|
||||||
36,
|
|
||||||
image::Rgb([240, 40, 70]),
|
|
||||||
));
|
|
||||||
let (requests_tx, requests_rx) = mpsc::channel::<ResizeRequest>();
|
|
||||||
let (replies_tx, replies_rx) = mpsc::channel();
|
|
||||||
thumbnail.selected = Some("thumbnail".into());
|
|
||||||
thumbnail.image = Some(ThumbnailImage {
|
|
||||||
id: "thumbnail".into(),
|
|
||||||
protocol: ThreadProtocol::new(
|
|
||||||
requests_tx,
|
|
||||||
Some(thumbnail.picker.new_resize_protocol(image)),
|
|
||||||
),
|
|
||||||
replies: replies_rx,
|
|
||||||
complete: false,
|
|
||||||
});
|
|
||||||
let mut terminal = Terminal::new(TestBackend::new(100, 40)).unwrap();
|
|
||||||
for area in [
|
|
||||||
Rect::new(3, 2, 80, 18),
|
|
||||||
Rect::new(7, 4, 30, 8),
|
|
||||||
Rect::new(3, 2, 9, 18),
|
|
||||||
Rect::new(7, 4, 30, 8),
|
|
||||||
] {
|
|
||||||
let mut pending_width = 0;
|
|
||||||
terminal
|
|
||||||
.draw(|frame| {
|
|
||||||
pending_width = thumbnail.draw(frame, area);
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
assert!(thumbnail.is_loading());
|
|
||||||
assert!(
|
|
||||||
!terminal.backend().buffer().content.iter().any(|cell| {
|
|
||||||
matches!(cell.fg, Color::Rgb(..)) || matches!(cell.bg, Color::Rgb(..))
|
|
||||||
}),
|
|
||||||
"a resize must not render stale pixels"
|
|
||||||
);
|
|
||||||
let response = requests_rx
|
|
||||||
.recv_timeout(Duration::from_secs(2))
|
|
||||||
.unwrap()
|
|
||||||
.resize_encode()
|
|
||||||
.unwrap();
|
|
||||||
replies_tx.send(Ok(response)).unwrap();
|
|
||||||
let mut occupied_width = 0;
|
|
||||||
terminal
|
|
||||||
.draw(|frame| {
|
|
||||||
occupied_width = thumbnail.draw(frame, area);
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
assert!(!thumbnail.is_loading());
|
|
||||||
assert_eq!(pending_width, occupied_width);
|
|
||||||
let buffer = terminal.backend().buffer();
|
|
||||||
let pixels: Vec<_> = (0..40)
|
|
||||||
.flat_map(|y| (0..100).map(move |x| (x, y)))
|
|
||||||
.filter(|&(x, y)| {
|
|
||||||
let cell = &buffer[(x, y)];
|
|
||||||
matches!(cell.fg, Color::Rgb(..)) || matches!(cell.bg, Color::Rgb(..))
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let left = pixels.iter().map(|&(x, _)| x).min().unwrap();
|
|
||||||
let right = pixels.iter().map(|&(x, _)| x).max().unwrap() + 1;
|
|
||||||
let top = pixels.iter().map(|&(_, y)| y).min().unwrap();
|
|
||||||
let bottom = pixels.iter().map(|&(_, y)| y).max().unwrap() + 1;
|
|
||||||
assert!(left >= area.x && right <= area.right());
|
|
||||||
assert_eq!(left, area.x);
|
|
||||||
assert_eq!(occupied_width, right - left);
|
|
||||||
assert!(top >= area.y && bottom <= area.bottom());
|
|
||||||
assert!(right - left == area.width || bottom - top == area.height);
|
|
||||||
assert!((top - area.y).abs_diff(area.bottom() - bottom) <= 1);
|
|
||||||
let width = u32::from(right - left) * u32::from(font.width);
|
|
||||||
let height = u32::from(bottom - top) * u32::from(font.height);
|
|
||||||
assert!(
|
|
||||||
width.abs_diff(height * 4 / 3)
|
|
||||||
<= u32::from(font.width) + u32::from(font.height) * 4 / 3
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+517
@@ -0,0 +1,517 @@
|
|||||||
|
use std::{
|
||||||
|
collections::VecDeque,
|
||||||
|
ops::Range,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
use ratatui::text::Span;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
|
|
||||||
|
use crate::youtube::{self, Job, Video};
|
||||||
|
|
||||||
|
const CACHE_SIZE: usize = 256;
|
||||||
|
const STEP: Duration = Duration::from_millis(20);
|
||||||
|
|
||||||
|
struct CachedTitle {
|
||||||
|
id: String,
|
||||||
|
// Cache failures too, so a failed extractor is not restarted every frame.
|
||||||
|
title: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Replacement {
|
||||||
|
columns: usize,
|
||||||
|
old_end: usize,
|
||||||
|
target_end: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Animation {
|
||||||
|
id: String,
|
||||||
|
target: String,
|
||||||
|
target_len: usize,
|
||||||
|
display: String,
|
||||||
|
replacements: Vec<Replacement>,
|
||||||
|
replaced: usize,
|
||||||
|
started: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Animation {
|
||||||
|
fn new(id: String, mut old: String, mut target: String, now: Instant) -> Self {
|
||||||
|
// Use the same grapheme widths as Ratatui, keeping byte offsets only for splicing.
|
||||||
|
let boundaries = |text: &str| {
|
||||||
|
let mut columns = 0;
|
||||||
|
let mut ends: Vec<(usize, usize)> = Vec::new();
|
||||||
|
for (offset, grapheme) in text.grapheme_indices(true) {
|
||||||
|
columns += Span::raw(grapheme).width();
|
||||||
|
let end = offset + grapheme.len();
|
||||||
|
if let Some(previous) = ends.last_mut()
|
||||||
|
&& previous.0 == columns
|
||||||
|
{
|
||||||
|
// Keep zero-width graphemes with the group at their column.
|
||||||
|
previous.1 = end;
|
||||||
|
} else {
|
||||||
|
ends.push((columns, end));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ends
|
||||||
|
};
|
||||||
|
let mut old_boundaries = boundaries(&old);
|
||||||
|
let mut target_boundaries = boundaries(&target);
|
||||||
|
let old_columns = old_boundaries.last().map_or(0, |boundary| boundary.0);
|
||||||
|
let target_columns = target_boundaries.last().map_or(0, |boundary| boundary.0);
|
||||||
|
let columns = old_columns.max(target_columns);
|
||||||
|
let target_len = target.len();
|
||||||
|
// An exhausted title has a boundary at each remaining padding column.
|
||||||
|
old_boundaries.extend((old_columns + 1..=columns).map(|column| (column, old.len())));
|
||||||
|
for column in target_columns + 1..=columns {
|
||||||
|
target.push(' ');
|
||||||
|
target_boundaries.push((column, target.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut replacements = Vec::new();
|
||||||
|
let (mut left, mut right) = (0, 0);
|
||||||
|
while left < old_boundaries.len() && right < target_boundaries.len() {
|
||||||
|
let (old_column, old_end) = old_boundaries[left];
|
||||||
|
let (target_column, target_end) = target_boundaries[right];
|
||||||
|
match old_column.cmp(&target_column) {
|
||||||
|
std::cmp::Ordering::Less => left += 1,
|
||||||
|
std::cmp::Ordering::Greater => right += 1,
|
||||||
|
std::cmp::Ordering::Equal => {
|
||||||
|
replacements.push(Replacement {
|
||||||
|
columns: old_column,
|
||||||
|
old_end,
|
||||||
|
target_end,
|
||||||
|
});
|
||||||
|
left += 1;
|
||||||
|
right += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Every frame is a target prefix plus an untouched old suffix; reserve once.
|
||||||
|
old.reserve(target.len());
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
target,
|
||||||
|
target_len,
|
||||||
|
display: old,
|
||||||
|
replacements,
|
||||||
|
replaced: 0,
|
||||||
|
started: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return false at the final step: the caller then displays the unpadded canonical title.
|
||||||
|
fn advance(&mut self, now: Instant) -> bool {
|
||||||
|
let columns = now.saturating_duration_since(self.started).as_nanos() / STEP.as_nanos();
|
||||||
|
if self
|
||||||
|
.replacements
|
||||||
|
.last()
|
||||||
|
.is_none_or(|step| columns >= step.columns as u128)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut next = self.replaced;
|
||||||
|
while next < self.replacements.len() && self.replacements[next].columns as u128 <= columns {
|
||||||
|
next += 1;
|
||||||
|
}
|
||||||
|
if next != self.replaced {
|
||||||
|
let (cursor, old_start) = self.replaced.checked_sub(1).map_or((0, 0), |index| {
|
||||||
|
let previous = &self.replacements[index];
|
||||||
|
(previous.target_end, previous.old_end)
|
||||||
|
});
|
||||||
|
let replacement = &self.replacements[next - 1];
|
||||||
|
self.display.replace_range(
|
||||||
|
cursor..cursor + replacement.old_end - old_start,
|
||||||
|
&self.target[cursor..replacement.target_end],
|
||||||
|
);
|
||||||
|
self.replaced = next;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn full_title(output: &str, expected_id: &str) -> Option<String> {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Details {
|
||||||
|
id: String,
|
||||||
|
title: String,
|
||||||
|
}
|
||||||
|
let mut details: Details = serde_json::from_str(output).ok()?;
|
||||||
|
if !youtube::valid_video_id(expected_id) || details.id != expected_id {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Match listing extraction's terminal-safety rules, then reject unusable metadata.
|
||||||
|
details.title.retain(|character| !character.is_control());
|
||||||
|
(!details.title.trim().is_empty()).then_some(details.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Titles {
|
||||||
|
fetch: Option<(String, Job)>,
|
||||||
|
cached: VecDeque<CachedTitle>,
|
||||||
|
animations: Vec<Animation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Titles {
|
||||||
|
/// Poll metadata, apply canonical titles, and animate only the current visible rows.
|
||||||
|
pub fn update(&mut self, videos: &mut [Video], visible: Range<usize>, now: Instant) {
|
||||||
|
#[cfg(not(test))]
|
||||||
|
self.update_with(videos, visible, now, Job::description);
|
||||||
|
// Ordinary app render tests must never launch an external network extractor.
|
||||||
|
#[cfg(test)]
|
||||||
|
self.update_with(videos, visible, now, |_| {
|
||||||
|
Err(std::io::ErrorKind::NotFound.into())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_with(
|
||||||
|
&mut self,
|
||||||
|
videos: &mut [Video],
|
||||||
|
visible: Range<usize>,
|
||||||
|
now: Instant,
|
||||||
|
mut start_job: impl FnMut(&str) -> std::io::Result<Job>,
|
||||||
|
) {
|
||||||
|
let start = visible.start.min(videos.len());
|
||||||
|
let visible = start..visible.end.min(videos.len()).max(start);
|
||||||
|
self.animations.retain_mut(|animation| {
|
||||||
|
videos[visible.clone()].iter().any(|video| {
|
||||||
|
video.id == animation.id && video.title == animation.target[..animation.target_len]
|
||||||
|
}) && animation.advance(now)
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(result) = self.fetch.as_mut().and_then(|(_, job)| job.poll()) {
|
||||||
|
let (id, _) = self.fetch.take().unwrap();
|
||||||
|
self.finish(id, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (index, video) in videos.iter_mut().enumerate() {
|
||||||
|
let Some(title) = self
|
||||||
|
.cached
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.id == video.id)
|
||||||
|
.and_then(|entry| entry.title.as_ref())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if video.title == *title {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let old = std::mem::replace(&mut video.title, title.clone());
|
||||||
|
if visible.contains(&index) {
|
||||||
|
self.animations.retain(|animation| animation.id != video.id);
|
||||||
|
self.animations
|
||||||
|
.push(Animation::new(video.id.clone(), old, title.clone(), now));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.fetch.is_none()
|
||||||
|
&& let Some(video) = videos[visible.clone()].iter().find(|video| {
|
||||||
|
youtube::valid_video_id(&video.id)
|
||||||
|
&& !self.cached.iter().any(|entry| entry.id == video.id)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
match start_job(&video.id) {
|
||||||
|
Ok(job) => self.fetch = Some((video.id.clone(), job)),
|
||||||
|
Err(error) => self.finish(video.id.clone(), Err(error.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep visible successes and failures pinned; even a very tall viewport cannot thrash.
|
||||||
|
let capacity = CACHE_SIZE.max(visible.len());
|
||||||
|
while self.cached.len() > capacity {
|
||||||
|
let index = self
|
||||||
|
.cached
|
||||||
|
.iter()
|
||||||
|
.position(|entry| {
|
||||||
|
!videos[visible.clone()]
|
||||||
|
.iter()
|
||||||
|
.any(|video| video.id == entry.id)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
self.cached.remove(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(&mut self, id: String, output: Result<String, String>) {
|
||||||
|
let title = output.ok().and_then(|output| full_title(&output, &id));
|
||||||
|
self.cached.push_back(CachedTitle { id, title });
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn title<'a>(&'a self, video: &'a Video) -> &'a str {
|
||||||
|
self.animations
|
||||||
|
.iter()
|
||||||
|
.find(|animation| animation.id == video.id)
|
||||||
|
.map_or(&video.title, |animation| animation.display.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pending extraction or visible animation requires another event-loop tick.
|
||||||
|
pub fn active(&self) -> bool {
|
||||||
|
self.fetch.is_some() || !self.animations.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const ID: &str = "abcdefghijk";
|
||||||
|
const OTHER_ID: &str = "lmnopqrstuv";
|
||||||
|
|
||||||
|
fn video(id: &str, title: &str) -> Video {
|
||||||
|
serde_json::from_value(serde_json::json!({ "id": id, "title": title })).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metadata(id: &str, title: &str) -> String {
|
||||||
|
serde_json::json!({ "id": id, "title": title }).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seeded(old: &str, target: &str, now: Instant) -> (Titles, Vec<Video>) {
|
||||||
|
let mut titles = Titles::default();
|
||||||
|
titles.finish(ID.into(), Ok(metadata(ID, target)));
|
||||||
|
let mut videos = vec![video(ID, old)];
|
||||||
|
titles.update(&mut videos, 0..1, now);
|
||||||
|
(titles, videos)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transition_uses_injected_time_and_updates_canonical_title_immediately() {
|
||||||
|
let now = Instant::now();
|
||||||
|
let (mut titles, mut videos) = seeded("abcd", "WXYZ", now);
|
||||||
|
assert_eq!(videos[0].title, "WXYZ");
|
||||||
|
assert_eq!(videos[0].id, ID);
|
||||||
|
assert_eq!(titles.title(&videos[0]), "abcd");
|
||||||
|
titles.update(&mut videos, 0..1, now + Duration::from_millis(19));
|
||||||
|
assert_eq!(titles.title(&videos[0]), "abcd");
|
||||||
|
titles.update(&mut videos, 0..1, now + STEP);
|
||||||
|
assert_eq!(titles.title(&videos[0]), "Wbcd");
|
||||||
|
titles.update(&mut videos, 0..1, now + STEP * 3);
|
||||||
|
assert_eq!(titles.title(&videos[0]), "WXYd");
|
||||||
|
assert!(titles.active());
|
||||||
|
titles.update(&mut videos, 0..1, now + STEP * 4);
|
||||||
|
assert_eq!(titles.title(&videos[0]), "WXYZ");
|
||||||
|
assert!(!titles.active());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shorter_title_blanks_exhausted_columns_then_discards_padding() {
|
||||||
|
let now = Instant::now();
|
||||||
|
let (mut titles, mut videos) = seeded("abcde", "XY", now);
|
||||||
|
for (step, expected) in [
|
||||||
|
(1, "Xbcde"),
|
||||||
|
(2, "XYcde"),
|
||||||
|
(3, "XY de"),
|
||||||
|
(4, "XY e"),
|
||||||
|
(5, "XY"),
|
||||||
|
] {
|
||||||
|
titles.update(&mut videos, 0..1, now + STEP * step);
|
||||||
|
assert_eq!(titles.title(&videos[0]), expected);
|
||||||
|
}
|
||||||
|
assert!(!titles.active());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn longer_title_appends_one_grapheme_per_step() {
|
||||||
|
let now = Instant::now();
|
||||||
|
let (mut titles, mut videos) = seeded("ab", "WXYZ", now);
|
||||||
|
for (step, expected) in [(1, "Wb"), (2, "WX"), (3, "WXY"), (4, "WXYZ")] {
|
||||||
|
titles.update(&mut videos, 0..1, now + STEP * step);
|
||||||
|
assert_eq!(titles.title(&videos[0]), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn combining_marks_and_joined_emoji_stay_whole_at_column_boundaries() {
|
||||||
|
let now = Instant::now();
|
||||||
|
let (mut titles, mut videos) = seeded("a\u{301}👨👩👧👦🇩🇰!", "e\u{301}👩🏽💻🇯🇵?", now);
|
||||||
|
for (step, expected) in [
|
||||||
|
(1, "e\u{301}👨👩👧👦🇩🇰!"),
|
||||||
|
(2, "e\u{301}👨👩👧👦🇩🇰!"),
|
||||||
|
(3, "e\u{301}👩🏽💻🇩🇰!"),
|
||||||
|
(4, "e\u{301}👩🏽💻🇩🇰!"),
|
||||||
|
(5, "e\u{301}👩🏽💻🇯🇵!"),
|
||||||
|
(6, "e\u{301}👩🏽💻🇯🇵?"),
|
||||||
|
] {
|
||||||
|
titles.update(&mut videos, 0..1, now + STEP * step);
|
||||||
|
assert_eq!(titles.title(&videos[0]), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn narrow_and_wide_replacements_wait_for_matching_columns_in_both_directions() {
|
||||||
|
let now = Instant::now();
|
||||||
|
for (old, target, frames) in [
|
||||||
|
(
|
||||||
|
"abcd!",
|
||||||
|
"界語?",
|
||||||
|
["abcd!", "界cd!", "界cd!", "界語!", "界語?"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"界語!",
|
||||||
|
"abcd?",
|
||||||
|
["界語!", "ab語!", "ab語!", "abcd!", "abcd?"],
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let (mut titles, mut videos) = seeded(old, target, now);
|
||||||
|
for (index, expected) in frames.into_iter().enumerate() {
|
||||||
|
titles.update(&mut videos, 0..1, now + STEP * (index as u32 + 1));
|
||||||
|
assert_eq!(titles.title(&videos[0]), expected);
|
||||||
|
if index == 0 {
|
||||||
|
titles.update(&mut videos, 0..1, now + Duration::from_millis(39));
|
||||||
|
assert_eq!(titles.title(&videos[0]), old);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(!titles.active());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nonmatching_boundaries_replace_the_smallest_whole_group_without_blanks() {
|
||||||
|
let now = Instant::now();
|
||||||
|
for (old, target, expected) in [("a界b!", "語cd?", "語cb!"), ("語cd!", "a界b?", "a界d!")]
|
||||||
|
{
|
||||||
|
let (mut titles, mut videos) = seeded(old, target, now);
|
||||||
|
for step in 1..3 {
|
||||||
|
titles.update(&mut videos, 0..1, now + STEP * step);
|
||||||
|
assert_eq!(titles.title(&videos[0]), old);
|
||||||
|
}
|
||||||
|
titles.update(&mut videos, 0..1, now + STEP * 3);
|
||||||
|
assert_eq!(titles.title(&videos[0]), expected);
|
||||||
|
assert_eq!(Span::raw(expected).width(), Span::raw(old).width());
|
||||||
|
titles.update(&mut videos, 0..1, now + STEP * 5);
|
||||||
|
assert_eq!(titles.title(&videos[0]), target);
|
||||||
|
assert!(!titles.active());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wide_shorter_and_longer_tails_use_columns_and_drop_only_final_padding() {
|
||||||
|
let now = Instant::now();
|
||||||
|
for (old, target, frames) in [
|
||||||
|
("界語!", "X", ["界語!", "X 語!", "X 語!", "X !", "X"]),
|
||||||
|
("X", "界語!", ["X", "界", "界", "界語", "界語!"]),
|
||||||
|
] {
|
||||||
|
let (mut titles, mut videos) = seeded(old, target, now);
|
||||||
|
for (index, expected) in frames.into_iter().enumerate() {
|
||||||
|
titles.update(&mut videos, 0..1, now + STEP * (index as u32 + 1));
|
||||||
|
assert_eq!(titles.title(&videos[0]), expected);
|
||||||
|
}
|
||||||
|
assert!(!titles.active());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skipped_frames_apply_one_prefix_without_reallocating() {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut animation = Animation::new(ID.into(), "a界b!".into(), "語cd?".into(), now);
|
||||||
|
let capacity = animation.display.capacity();
|
||||||
|
assert!(animation.advance(now + STEP * 4));
|
||||||
|
assert_eq!(animation.display, "語cd!");
|
||||||
|
assert_eq!(animation.display.capacity(), capacity);
|
||||||
|
assert!(animation.advance(now + STEP * 4));
|
||||||
|
assert_eq!(animation.display, "語cd!");
|
||||||
|
assert!(!animation.advance(now + STEP * 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_width_graphemes_stay_with_their_column_group() {
|
||||||
|
let now = Instant::now();
|
||||||
|
let (mut titles, mut videos) = seeded("a\u{200b}b!", "X\u{200b}Y?", now);
|
||||||
|
titles.update(&mut videos, 0..1, now + STEP);
|
||||||
|
assert_eq!(titles.title(&videos[0]), "X\u{200b}b!");
|
||||||
|
titles.update(&mut videos, 0..1, now + STEP * 2);
|
||||||
|
assert_eq!(titles.title(&videos[0]), "X\u{200b}Y!");
|
||||||
|
titles.update(&mut videos, 0..1, now + STEP * 3);
|
||||||
|
assert_eq!(titles.title(&videos[0]), "X\u{200b}Y?");
|
||||||
|
assert!(!titles.active());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn offscreen_completion_and_scrolling_finish_immediately() {
|
||||||
|
let now = Instant::now();
|
||||||
|
let (mut titles, mut videos) = seeded("old", "Original", now);
|
||||||
|
titles.update(&mut videos, 0..0, now + STEP);
|
||||||
|
assert_eq!(titles.title(&videos[0]), "Original");
|
||||||
|
assert!(!titles.active());
|
||||||
|
titles.finish(OTHER_ID.into(), Ok(metadata(OTHER_ID, "Offscreen")));
|
||||||
|
videos.push(video(OTHER_ID, "flat"));
|
||||||
|
titles.update(&mut videos, 0..1, now + STEP * 2);
|
||||||
|
assert_eq!(videos[1].title, "Offscreen");
|
||||||
|
assert_eq!(titles.title(&videos[1]), "Offscreen");
|
||||||
|
titles.update(&mut videos, 1..2, now + STEP * 3);
|
||||||
|
assert!(!titles.active());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_metadata_and_extractor_errors_preserve_flat_title_without_retries() {
|
||||||
|
let now = Instant::now();
|
||||||
|
for output in [
|
||||||
|
Err("extractor failed".into()),
|
||||||
|
Ok("not json".into()),
|
||||||
|
Ok(metadata(OTHER_ID, "Wrong video")),
|
||||||
|
Ok(metadata(ID, "\u{1b}\n\t")),
|
||||||
|
Ok(metadata(ID, " ")),
|
||||||
|
Ok(serde_json::json!({"id": ID}).to_string()),
|
||||||
|
] {
|
||||||
|
let mut titles = Titles::default();
|
||||||
|
titles.finish(ID.into(), output);
|
||||||
|
let mut videos = vec![video(ID, "Keep this")];
|
||||||
|
titles.update(&mut videos, 0..1, now);
|
||||||
|
titles.update(&mut videos, 0..1, now + STEP * 100);
|
||||||
|
assert_eq!(videos[0].title, "Keep this");
|
||||||
|
assert_eq!(titles.title(&videos[0]), "Keep this");
|
||||||
|
assert!(!titles.active());
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
full_title(&metadata(ID, "Safe\n\u{1b} title"), ID).as_deref(),
|
||||||
|
Some("Safe title")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_is_bounded_and_visible_failures_are_not_evicted() {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut titles = Titles::default();
|
||||||
|
titles.finish(ID.into(), Err("failed".into()));
|
||||||
|
for index in 0..CACHE_SIZE {
|
||||||
|
titles.finish(format!("{index:011}"), Err("failed".into()));
|
||||||
|
}
|
||||||
|
let mut videos = vec![video(ID, "Keep this")];
|
||||||
|
titles.update(&mut videos, 0..1, now);
|
||||||
|
assert_eq!(titles.cached.len(), CACHE_SIZE);
|
||||||
|
assert!(titles.cached.iter().any(|entry| entry.id == ID));
|
||||||
|
assert!(!titles.active());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_ids_and_empty_or_out_of_bounds_viewports_never_fetch() {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut titles = Titles::default();
|
||||||
|
let mut videos = vec![video("not/youtube", "Local")];
|
||||||
|
titles.update(&mut videos, 0..1, now);
|
||||||
|
titles.update(&mut videos, 20..30, now);
|
||||||
|
titles.update(&mut videos, 1..0, now);
|
||||||
|
assert!(!titles.active());
|
||||||
|
assert_eq!(titles.title(&videos[0]), "Local");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scheduling_only_attempts_visible_valid_uncached_ids_once_after_failure() {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut titles = Titles::default();
|
||||||
|
let mut videos = vec![
|
||||||
|
video(ID, "Offscreen"),
|
||||||
|
video("not/youtube", "Local"),
|
||||||
|
video(OTHER_ID, "Visible"),
|
||||||
|
];
|
||||||
|
let mut requested = Vec::new();
|
||||||
|
for step in 0..3 {
|
||||||
|
titles.update_with(&mut videos, 1..3, now + STEP * step, |id| {
|
||||||
|
requested.push(id.to_owned());
|
||||||
|
Err(std::io::ErrorKind::NotFound.into())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert_eq!(requested, vec![OTHER_ID.to_owned()]);
|
||||||
|
assert_eq!(videos[2].title, "Visible");
|
||||||
|
assert!(!titles.active());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use ratatui::{
|
||||||
|
Frame,
|
||||||
|
crossterm::{
|
||||||
|
clipboard::CopyToClipboard,
|
||||||
|
event::{
|
||||||
|
KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
|
||||||
|
},
|
||||||
|
execute,
|
||||||
|
},
|
||||||
|
layout::{Constraint, Layout, Rect},
|
||||||
|
style::{Color, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Paragraph, Wrap},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const ACCENT: Style = Style::new().fg(Color::Cyan);
|
||||||
|
pub const SELECTED: Style = Style::new().fg(Color::Black).bg(Color::Cyan);
|
||||||
|
pub const SELECTION_MARKER: &str = "> ";
|
||||||
|
|
||||||
|
// Keep modified Return distinct from Return on terminals supporting the Kitty protocol.
|
||||||
|
pub struct KeyboardProtocol;
|
||||||
|
|
||||||
|
impl KeyboardProtocol {
|
||||||
|
pub fn enable() -> std::io::Result<Self> {
|
||||||
|
execute!(
|
||||||
|
std::io::stdout(),
|
||||||
|
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
|
||||||
|
)?;
|
||||||
|
Ok(Self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for KeyboardProtocol {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = execute!(std::io::stdout(), PopKeyboardEnhancementFlags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn copy_video_url(id: &str) -> String {
|
||||||
|
let result = crate::youtube::video_url(id)
|
||||||
|
.and_then(|url| execute!(std::io::stdout(), CopyToClipboard::to_clipboard_from(url)));
|
||||||
|
match result {
|
||||||
|
Ok(()) => "Video URL sent to terminal clipboard.".into(),
|
||||||
|
Err(error) => format!("Cannot copy video URL: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn browser_layout(area: Rect, header_height: u16) -> [Rect; 5] {
|
||||||
|
Layout::vertical([
|
||||||
|
Constraint::Length(header_height),
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Min(3),
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Length(1),
|
||||||
|
])
|
||||||
|
.areas(area)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn search(frame: &mut Frame, area: Rect, title: &str, query: &str, editing: bool) {
|
||||||
|
let block =
|
||||||
|
Block::bordered()
|
||||||
|
.title(title)
|
||||||
|
.border_style(if editing { ACCENT } else { Style::new() });
|
||||||
|
let inner = block.inner(area);
|
||||||
|
let query = Line::raw(query);
|
||||||
|
let width = query.width();
|
||||||
|
let scroll = width.saturating_sub(usize::from(inner.width.saturating_sub(1)));
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(query)
|
||||||
|
.scroll((0, scroll.min(u16::MAX as usize) as u16))
|
||||||
|
.block(block),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
if editing && !inner.is_empty() {
|
||||||
|
let cursor = width
|
||||||
|
.saturating_sub(scroll)
|
||||||
|
.min(usize::from(inner.width - 1));
|
||||||
|
frame.set_cursor_position((inner.x + cursor as u16, inner.y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spinner(elapsed: Duration) -> &'static str {
|
||||||
|
const FRAMES: [&str; 10] = ["⠋ ", "⠙ ", "⠹ ", "⠸ ", "⠼ ", "⠴ ", "⠦ ", "⠧ ", "⠇ ", "⠏ "];
|
||||||
|
FRAMES[(elapsed.as_millis() / 100 % FRAMES.len() as u128) as usize]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn info_preview(
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
thumbnail: Option<&mut crate::thumbnail::Thumbnail>,
|
||||||
|
metadata: Vec<Line<'_>>,
|
||||||
|
text: &str,
|
||||||
|
) {
|
||||||
|
let mut details = if let Some(thumbnail) = thumbnail {
|
||||||
|
let [_, image, _, mut details] = Layout::horizontal([
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Fill(3),
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Fill(2),
|
||||||
|
])
|
||||||
|
.areas(area);
|
||||||
|
let reclaimed = image.width - thumbnail.draw(frame, image);
|
||||||
|
details.x -= reclaimed;
|
||||||
|
details.width += reclaimed;
|
||||||
|
details.width = details.width.saturating_sub(2);
|
||||||
|
details
|
||||||
|
} else {
|
||||||
|
area.inner(ratatui::layout::Margin::new(1, 0))
|
||||||
|
};
|
||||||
|
let metadata = Paragraph::new(metadata).wrap(Wrap { trim: true });
|
||||||
|
let height = if details.is_empty() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
metadata
|
||||||
|
.line_count(details.width)
|
||||||
|
.min(details.height as usize) as u16
|
||||||
|
};
|
||||||
|
let mut excerpt = details;
|
||||||
|
let gap = (excerpt.height - height).min(1);
|
||||||
|
excerpt.y += height + gap;
|
||||||
|
excerpt.height -= height + gap;
|
||||||
|
details.height = height;
|
||||||
|
frame.render_widget(metadata, details);
|
||||||
|
let paragraph = Paragraph::new(text).wrap(Wrap { trim: false });
|
||||||
|
let overflow = !text.is_empty()
|
||||||
|
&& !excerpt.is_empty()
|
||||||
|
&& paragraph.line_count(excerpt.width) > usize::from(excerpt.height);
|
||||||
|
frame.render_widget(paragraph, excerpt);
|
||||||
|
if overflow {
|
||||||
|
let buffer = frame.buffer_mut();
|
||||||
|
let y = excerpt.bottom() - 1;
|
||||||
|
let end = (excerpt.x..excerpt.right())
|
||||||
|
.rev()
|
||||||
|
.find_map(|x| {
|
||||||
|
let symbol = buffer
|
||||||
|
.cell((x, y))
|
||||||
|
.expect("excerpt is inside frame")
|
||||||
|
.symbol();
|
||||||
|
(!symbol.trim().is_empty())
|
||||||
|
.then(|| x.saturating_add(Span::raw(symbol).width() as u16))
|
||||||
|
})
|
||||||
|
.unwrap_or(excerpt.x);
|
||||||
|
let mut x = end.min(excerpt.right() - 1);
|
||||||
|
// Do not leave half of a wide character beneath the ellipsis.
|
||||||
|
if x > excerpt.x && Span::raw(buffer.cell((x - 1, y)).unwrap().symbol()).width() > 1 {
|
||||||
|
x -= 1;
|
||||||
|
}
|
||||||
|
buffer
|
||||||
|
.cell_mut((x, y))
|
||||||
|
.unwrap()
|
||||||
|
.set_symbol("…")
|
||||||
|
.set_style(Style::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
+1302
File diff suppressed because it is too large
Load Diff
+767
-376
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,343 @@
|
|||||||
|
use super::*;
|
||||||
|
use serde_json::value::RawValue;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize)]
|
||||||
|
struct Playlist<T> {
|
||||||
|
entries: Vec<T>,
|
||||||
|
channel: Option<String>,
|
||||||
|
channel_id: Option<String>,
|
||||||
|
uploader_id: Option<String>,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
playlist_count: Option<usize>,
|
||||||
|
#[serde(rename = "_type", skip_serializing)]
|
||||||
|
kind: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Playlist<T> {
|
||||||
|
fn validate(&self, start: usize, count: usize) -> Result<(), String> {
|
||||||
|
if self.kind.as_deref().is_some_and(|kind| kind != "playlist") {
|
||||||
|
return Err("Invalid catalog playlist type".into());
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.channel_id
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|id| !valid_channel_id(id))
|
||||||
|
{
|
||||||
|
return Err("Catalog returned an invalid YouTube channel ID".into());
|
||||||
|
}
|
||||||
|
if self.entries.len() > count {
|
||||||
|
return Err("Catalog returned more rows than requested".into());
|
||||||
|
}
|
||||||
|
match self.playlist_count {
|
||||||
|
Some(total) if total.saturating_sub(start - 1).min(count) != self.entries.len() => {
|
||||||
|
Err("Catalog playlist count did not match its rows".into())
|
||||||
|
}
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exhausted_total(&self, start: usize) -> Result<usize, String> {
|
||||||
|
match self.playlist_count {
|
||||||
|
Some(total) => Ok(total),
|
||||||
|
None if start == 1 || !self.entries.is_empty() => (start - 1)
|
||||||
|
.checked_add(self.entries.len())
|
||||||
|
.ok_or_else(|| "Catalog playlist count overflow".into()),
|
||||||
|
None => Err("Exhausted catalog playlist has no total count".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn playlist(output: &str, start: usize, count: usize) -> Result<Playlist<Box<RawValue>>, String> {
|
||||||
|
let playlist: Playlist<Box<RawValue>> = serde_json::from_str(output)
|
||||||
|
.map_err(|error| format!("Invalid catalog response: {error}"))?;
|
||||||
|
playlist.validate(start, count)?;
|
||||||
|
Ok(playlist)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FeedChannel {
|
||||||
|
channel: String,
|
||||||
|
request: FeedRequest,
|
||||||
|
playlist: Option<Playlist<Box<RawValue>>>,
|
||||||
|
releases: Option<ChannelReleases>,
|
||||||
|
job: Option<Job>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct FeedExpansion {
|
||||||
|
channels: Vec<FeedChannel>,
|
||||||
|
uploads_complete: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FeedExpansion {
|
||||||
|
pub(super) fn new(channels: &[String], requests: &[FeedRequest]) -> Self {
|
||||||
|
Self {
|
||||||
|
channels: requests
|
||||||
|
.iter()
|
||||||
|
.map(|&request| FeedChannel {
|
||||||
|
channel: channels[request.channel].clone(),
|
||||||
|
request,
|
||||||
|
playlist: None,
|
||||||
|
releases: None,
|
||||||
|
job: None,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
uploads_complete: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn full_uploads(&self, output: &str) -> Result<bool, String> {
|
||||||
|
// Borrow raw rows on the ordinary uploads-only path; return its output unchanged.
|
||||||
|
let mut playlists =
|
||||||
|
serde_json::Deserializer::from_str(output).into_iter::<Playlist<&RawValue>>();
|
||||||
|
for channel in &self.channels {
|
||||||
|
let playlist = playlists
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| "Feed returned fewer playlists than requested".to_owned())?
|
||||||
|
.map_err(|error| format!("Invalid catalog response: {error}"))?;
|
||||||
|
playlist.validate(channel.request.start, channel.request.count)?;
|
||||||
|
if playlist.entries.len() < channel.request.count {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(extra) = playlists.next() {
|
||||||
|
extra.map_err(|error| format!("Invalid catalog response: {error}"))?;
|
||||||
|
return Err("Feed returned more playlists than requested".into());
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accept_uploads(&mut self, output: &str) -> Result<(), String> {
|
||||||
|
let mut playlists =
|
||||||
|
serde_json::Deserializer::from_str(output).into_iter::<Playlist<Box<RawValue>>>();
|
||||||
|
for channel in &mut self.channels {
|
||||||
|
let playlist = playlists
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| "Feed returned fewer playlists than requested".to_owned())?
|
||||||
|
.map_err(|error| format!("Invalid catalog response: {error}"))?;
|
||||||
|
playlist.validate(channel.request.start, channel.request.count)?;
|
||||||
|
let remaining = channel.request.count - playlist.entries.len();
|
||||||
|
if remaining != 0 {
|
||||||
|
// Beyond-end requests need the extractor's total to locate release tracks.
|
||||||
|
let skip = (channel.request.start - 1)
|
||||||
|
.saturating_sub(playlist.exhausted_total(channel.request.start)?);
|
||||||
|
channel.releases = Some(ChannelReleases::new(
|
||||||
|
channel.channel.clone(),
|
||||||
|
skip,
|
||||||
|
remaining,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
channel.playlist = Some(playlist);
|
||||||
|
}
|
||||||
|
if let Some(extra) = playlists.next() {
|
||||||
|
extra.map_err(|error| format!("Invalid catalog response: {error}"))?;
|
||||||
|
return Err("Feed returned more playlists than requested".into());
|
||||||
|
}
|
||||||
|
self.uploads_complete = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn poll(&mut self, uploads: &mut Job) -> Option<Result<String, String>> {
|
||||||
|
if !self.uploads_complete {
|
||||||
|
let output = match uploads.poll_extractions()? {
|
||||||
|
Ok(output) => output,
|
||||||
|
Err(error) => return Some(Err(error)),
|
||||||
|
};
|
||||||
|
match self.full_uploads(&output) {
|
||||||
|
Ok(true) => return Some(Ok(output)),
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(error) => return Some(Err(error)),
|
||||||
|
}
|
||||||
|
if let Err(error) = self.accept_uploads(&output) {
|
||||||
|
return Some(Err(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match self.poll_releases() {
|
||||||
|
Ok(false) => None,
|
||||||
|
Err(error) => Some(Err(error)),
|
||||||
|
Ok(true) => {
|
||||||
|
let mut output = Vec::new();
|
||||||
|
for channel in &self.channels {
|
||||||
|
if !output.is_empty() {
|
||||||
|
output.push(b'\n');
|
||||||
|
}
|
||||||
|
if let Err(error) =
|
||||||
|
serde_json::to_writer(&mut output, channel.playlist.as_ref().unwrap())
|
||||||
|
{
|
||||||
|
return Some(Err(format!("Cannot encode catalog response: {error}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(String::from_utf8(output).map_err(|error| error.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_releases(&mut self) -> Result<bool, String> {
|
||||||
|
let mut active = self
|
||||||
|
.channels
|
||||||
|
.iter()
|
||||||
|
.filter(|channel| channel.job.is_some())
|
||||||
|
.count();
|
||||||
|
let mut complete = true;
|
||||||
|
for channel in &mut self.channels {
|
||||||
|
let Some(releases) = &mut channel.releases else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Some(job) = &mut channel.job {
|
||||||
|
if let Some(result) = job.poll_extractions() {
|
||||||
|
channel.job = None;
|
||||||
|
active -= 1;
|
||||||
|
match result {
|
||||||
|
Ok(output) => releases.accept(&output)?,
|
||||||
|
Err(error) => releases.accept_error(error)?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if releases.done() {
|
||||||
|
let entries = channel.releases.take().unwrap().into_entries();
|
||||||
|
channel.playlist.as_mut().unwrap().entries.extend(entries);
|
||||||
|
} else {
|
||||||
|
complete = false;
|
||||||
|
if channel.job.is_none() && active < MAX_EXTRACTORS {
|
||||||
|
channel.job = Some(
|
||||||
|
Job::spawn(&mut releases.command().map_err(|error| error.to_string())?)
|
||||||
|
.map_err(|error| error.to_string())?,
|
||||||
|
);
|
||||||
|
active += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(complete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: each Feed page revisits albums; index offsets if traversal becomes costly.
|
||||||
|
struct ChannelReleases {
|
||||||
|
channel: String,
|
||||||
|
skip: usize,
|
||||||
|
count: usize,
|
||||||
|
entries: Vec<Box<RawValue>>,
|
||||||
|
albums: VecDeque<String>,
|
||||||
|
discovery_start: usize,
|
||||||
|
discovery_exhausted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChannelReleases {
|
||||||
|
fn new(channel: String, skip: usize, count: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
channel,
|
||||||
|
skip,
|
||||||
|
count,
|
||||||
|
entries: Vec::new(),
|
||||||
|
albums: VecDeque::new(),
|
||||||
|
discovery_start: 1,
|
||||||
|
discovery_exhausted: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command(&self) -> io::Result<Command> {
|
||||||
|
let (start, count, url) = match self.albums.front() {
|
||||||
|
Some(id) => (
|
||||||
|
self.skip.checked_add(1).ok_or_else(|| {
|
||||||
|
io::Error::new(io::ErrorKind::InvalidInput, "Release track offset overflow")
|
||||||
|
})?,
|
||||||
|
self.count - self.entries.len(),
|
||||||
|
format!("https://www.youtube.com/playlist?list={id}"),
|
||||||
|
),
|
||||||
|
None => (
|
||||||
|
self.discovery_start,
|
||||||
|
PAGE_SIZE,
|
||||||
|
format!("{}/releases", self.channel),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let end = range_end(start, count)?;
|
||||||
|
let mut command = extractor();
|
||||||
|
command.args([
|
||||||
|
"--flat-playlist",
|
||||||
|
"--playlist-start",
|
||||||
|
&start.to_string(),
|
||||||
|
"--playlist-end",
|
||||||
|
&end.to_string(),
|
||||||
|
"--extractor-args",
|
||||||
|
"youtubetab:approximate_date",
|
||||||
|
"--dump-single-json",
|
||||||
|
&url,
|
||||||
|
]);
|
||||||
|
Ok(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accept(&mut self, output: &str) -> Result<(), String> {
|
||||||
|
if self.albums.front().is_some() {
|
||||||
|
let remaining = self.count - self.entries.len();
|
||||||
|
let start = self
|
||||||
|
.skip
|
||||||
|
.checked_add(1)
|
||||||
|
.ok_or("Release track offset overflow")?;
|
||||||
|
let playlist = playlist(output, start, remaining)?;
|
||||||
|
if playlist.entries.len() < remaining {
|
||||||
|
self.skip = self.skip.saturating_sub(playlist.exhausted_total(start)?);
|
||||||
|
}
|
||||||
|
self.entries.extend(playlist.entries);
|
||||||
|
self.albums.pop_front();
|
||||||
|
} else {
|
||||||
|
let playlist = playlist(output, self.discovery_start, PAGE_SIZE)?;
|
||||||
|
let end =
|
||||||
|
range_end(self.discovery_start, PAGE_SIZE).map_err(|error| error.to_string())?;
|
||||||
|
self.discovery_exhausted = playlist.entries.len() < PAGE_SIZE
|
||||||
|
|| playlist.playlist_count.is_some_and(|count| count <= end);
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Album {
|
||||||
|
id: String,
|
||||||
|
}
|
||||||
|
for entry in playlist.entries {
|
||||||
|
let album: Album = serde_json::from_str(entry.get())
|
||||||
|
.map_err(|error| format!("Invalid release album: {error}"))?;
|
||||||
|
if album.id.is_empty()
|
||||||
|
|| album.id.len() > 128
|
||||||
|
|| !album
|
||||||
|
.id
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
|
||||||
|
{
|
||||||
|
return Err("Release album has an invalid YouTube playlist ID".into());
|
||||||
|
}
|
||||||
|
self.albums.push_back(album.id);
|
||||||
|
}
|
||||||
|
if !self.discovery_exhausted {
|
||||||
|
self.discovery_start = end
|
||||||
|
.checked_add(1)
|
||||||
|
.ok_or("Release discovery offset overflow")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accept_error(&mut self, error: String) -> Result<(), String> {
|
||||||
|
// Only the optional tab's exact extractor error is ordinary exhaustion.
|
||||||
|
if self.albums.is_empty()
|
||||||
|
&& error
|
||||||
|
.trim_end()
|
||||||
|
.ends_with("This channel does not have a releases tab")
|
||||||
|
&& !error
|
||||||
|
.lines()
|
||||||
|
.rev()
|
||||||
|
.skip(1)
|
||||||
|
.any(|line| line.trim_start().starts_with("ERROR:"))
|
||||||
|
{
|
||||||
|
self.discovery_exhausted = true;
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn done(&self) -> bool {
|
||||||
|
self.entries.len() == self.count || (self.discovery_exhausted && self.albums.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn into_entries(self) -> Vec<Box<RawValue>> {
|
||||||
|
self.entries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../../tests/unit/youtube_catalog.rs"]
|
||||||
|
mod tests;
|
||||||
+6166
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,709 @@
|
|||||||
|
use super::*;
|
||||||
|
use toml_edit::DocumentMut;
|
||||||
|
|
||||||
|
fn configured(source: &str) -> io::Result<Bindings> {
|
||||||
|
let document = source.parse::<DocumentMut>().unwrap();
|
||||||
|
Bindings::from_toml(document.get("keys"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn press(
|
||||||
|
bindings: &Bindings,
|
||||||
|
context: Context,
|
||||||
|
code: KeyCode,
|
||||||
|
modifiers: KeyModifiers,
|
||||||
|
) -> Option<Action> {
|
||||||
|
bindings.resolve(context, KeyEvent::new(code, modifiers))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn playback_checks_are_configurable_in_views_without_stealing_modal_space() {
|
||||||
|
let defaults = Bindings::default();
|
||||||
|
let overrides = configured("[keys.video.select]\ntoggle = 'f20'\nclear = 'f21'\n").unwrap();
|
||||||
|
for &context in ALL {
|
||||||
|
for (action, modifiers, override_code) in [
|
||||||
|
(Action::VideoToggle, KeyModifiers::NONE, KeyCode::F(20)),
|
||||||
|
(Action::VideoClear, KeyModifiers::CONTROL, KeyCode::F(21)),
|
||||||
|
] {
|
||||||
|
let expected = VIEW.contains(&context).then_some(action);
|
||||||
|
assert_eq!(
|
||||||
|
press(&defaults, context, KeyCode::Char(' '), modifiers)
|
||||||
|
.filter(|resolved| *resolved == action),
|
||||||
|
expected,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
press(&overrides, context, override_code, KeyModifiers::NONE),
|
||||||
|
expected,
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
press(&overrides, context, KeyCode::Char(' '), modifiers),
|
||||||
|
Some(action),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
press(
|
||||||
|
&defaults,
|
||||||
|
Context::TagList,
|
||||||
|
KeyCode::Char(' '),
|
||||||
|
KeyModifiers::NONE
|
||||||
|
),
|
||||||
|
Some(Action::TagToggle),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
press(
|
||||||
|
&defaults,
|
||||||
|
Context::SelectorNormal,
|
||||||
|
KeyCode::Char(' '),
|
||||||
|
KeyModifiers::NONE
|
||||||
|
),
|
||||||
|
Some(Action::SelectorToggle),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enqueue_is_configurable_and_only_active_in_video_views() {
|
||||||
|
let defaults = Bindings::default();
|
||||||
|
let overrides = configured("[keys.video]\nenqueue = 'f22'\n").unwrap();
|
||||||
|
for &context in ALL {
|
||||||
|
let expected = VIEW.contains(&context).then_some(Action::VideoEnqueue);
|
||||||
|
for modifiers in [KeyModifiers::CONTROL] {
|
||||||
|
assert_eq!(
|
||||||
|
press(&defaults, context, KeyCode::Enter, modifiers),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
press(&overrides, context, KeyCode::Enter, modifiers),
|
||||||
|
Some(Action::VideoEnqueue),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
press(&defaults, context, KeyCode::Enter, KeyModifiers::SUPER),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
press(&overrides, context, KeyCode::F(22), KeyModifiers::NONE),
|
||||||
|
expected,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
press(
|
||||||
|
&defaults,
|
||||||
|
Context::Normal,
|
||||||
|
KeyCode::Enter,
|
||||||
|
KeyModifiers::NONE
|
||||||
|
),
|
||||||
|
Some(Action::VideoPlay),
|
||||||
|
);
|
||||||
|
assert!(configured("[keys.video]\nenqueue = 'enter'\n").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_choice_is_configurable_and_normal_only() {
|
||||||
|
let defaults = Bindings::default();
|
||||||
|
let overrides = configured("[keys.app]\nprovider = 'alt+p'\n").unwrap();
|
||||||
|
for &context in ALL {
|
||||||
|
let expected = matches!(context, Context::Normal | Context::CollectionNormal)
|
||||||
|
.then_some(Action::ProviderChoose);
|
||||||
|
assert_eq!(
|
||||||
|
press(&defaults, context, KeyCode::Char('p'), KeyModifiers::NONE)
|
||||||
|
.filter(|action| *action == Action::ProviderChoose),
|
||||||
|
expected,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
press(&overrides, context, KeyCode::Char('p'), KeyModifiers::ALT),
|
||||||
|
expected,
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
press(&overrides, context, KeyCode::Char('p'), KeyModifiers::NONE),
|
||||||
|
Some(Action::ProviderChoose),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn channel_playlists_are_configurable_and_normal_only() {
|
||||||
|
let defaults = Bindings::default();
|
||||||
|
let overrides = configured("[keys.channel]\nplaylists = 'alt+l'\n").unwrap();
|
||||||
|
for &context in ALL {
|
||||||
|
let expected = (context == Context::Normal).then_some(Action::ChannelPlaylists);
|
||||||
|
for (code, modifiers) in [
|
||||||
|
(KeyCode::Char('L'), KeyModifiers::NONE),
|
||||||
|
(KeyCode::Char('L'), KeyModifiers::SHIFT),
|
||||||
|
(KeyCode::Char('l'), KeyModifiers::SHIFT),
|
||||||
|
] {
|
||||||
|
assert_eq!(press(&defaults, context, code, modifiers), expected);
|
||||||
|
assert_ne!(
|
||||||
|
press(&overrides, context, code, modifiers),
|
||||||
|
Some(Action::ChannelPlaylists),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
press(&defaults, context, KeyCode::Char('l'), KeyModifiers::NONE),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
press(&overrides, context, KeyCode::Char('l'), KeyModifiers::ALT),
|
||||||
|
expected,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(default_config().contains("channel.playlists = [\"L\"]"));
|
||||||
|
assert!(defaults.help_text().to_string().lines().any(|line| {
|
||||||
|
line.trim_start().starts_with("L ")
|
||||||
|
&& line.contains("Browse the current channel's playlists")
|
||||||
|
}));
|
||||||
|
assert!(overrides.help_text().to_string().lines().any(|line| {
|
||||||
|
line.trim_start().starts_with("Alt+l ")
|
||||||
|
&& line.contains("Browse the current channel's playlists")
|
||||||
|
}));
|
||||||
|
let collision = configured("[keys.channel]\nplaylists = 'u'\n")
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(collision.contains("channel.playlists") && collision.contains("channel.browse"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_section_headers_are_bold_in_the_grouped_overview() {
|
||||||
|
use ratatui::{
|
||||||
|
buffer::Buffer,
|
||||||
|
layout::Rect,
|
||||||
|
style::Modifier,
|
||||||
|
widgets::{Paragraph, Widget, Wrap},
|
||||||
|
};
|
||||||
|
let bindings = configured("[keys]\nvideo.play = 'f24'\n").unwrap();
|
||||||
|
for width in [21, 64] {
|
||||||
|
let area = Rect::new(0, 0, width, 16);
|
||||||
|
let mut buffer = Buffer::empty(area);
|
||||||
|
Paragraph::new(bindings.help_text())
|
||||||
|
.wrap(Wrap { trim: false })
|
||||||
|
.render(area, &mut buffer);
|
||||||
|
assert!(buffer[(0, 1)].modifier.contains(Modifier::BOLD));
|
||||||
|
for y in 2..area.height {
|
||||||
|
for x in 0..width {
|
||||||
|
assert!(!buffer[(x, y)].modifier.contains(Modifier::BOLD));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let binding_visible = (2..area.height).any(|y| {
|
||||||
|
(0..width)
|
||||||
|
.map(|x| buffer[(x, y)].symbol())
|
||||||
|
.collect::<String>()
|
||||||
|
.contains("F24")
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
binding_visible,
|
||||||
|
"custom shortcut must remain visible after wrapping"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contextual_cycle_and_direct_preview_bindings() {
|
||||||
|
let defaults = Bindings::default();
|
||||||
|
for (context, action) in [
|
||||||
|
(Context::Normal, Action::InfoOverview),
|
||||||
|
(Context::Info, Action::InfoDescription),
|
||||||
|
(Context::Description, Action::InfoOverview),
|
||||||
|
(Context::CollectionNormal, Action::InfoOverview),
|
||||||
|
(Context::CollectionInfo, Action::InfoDescription),
|
||||||
|
(Context::CollectionDescription, Action::InfoOverview),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
press(&defaults, context, KeyCode::Char('d'), KeyModifiers::NONE),
|
||||||
|
Some(action)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let bindings =
|
||||||
|
configured("[keys]\nvideo.info.overview = 'I'\nvideo.info.description = 'd'\n").unwrap();
|
||||||
|
for context in VIEW {
|
||||||
|
assert_eq!(
|
||||||
|
press(&bindings, *context, KeyCode::Char('I'), KeyModifiers::NONE),
|
||||||
|
Some(Action::InfoOverview)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
press(&bindings, *context, KeyCode::Char('d'), KeyModifiers::NONE),
|
||||||
|
Some(Action::InfoDescription)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
press(&defaults, Context::Info, KeyCode::Esc, KeyModifiers::NONE),
|
||||||
|
Some(Action::InfoHide)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
press(
|
||||||
|
&defaults,
|
||||||
|
Context::Description,
|
||||||
|
KeyCode::Char('-'),
|
||||||
|
KeyModifiers::NONE
|
||||||
|
),
|
||||||
|
Some(Action::ViewPrevious)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
press(
|
||||||
|
&defaults,
|
||||||
|
Context::Description,
|
||||||
|
KeyCode::Down,
|
||||||
|
KeyModifiers::NONE
|
||||||
|
),
|
||||||
|
Some(Action::DescriptionNext)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
press(
|
||||||
|
&defaults,
|
||||||
|
Context::Description,
|
||||||
|
KeyCode::Char('j'),
|
||||||
|
KeyModifiers::NONE
|
||||||
|
),
|
||||||
|
Some(Action::VideoNext)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
press(
|
||||||
|
&defaults,
|
||||||
|
Context::Description,
|
||||||
|
KeyCode::Char('j'),
|
||||||
|
KeyModifiers::CONTROL
|
||||||
|
),
|
||||||
|
Some(Action::DescriptionNext)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
press(
|
||||||
|
&defaults,
|
||||||
|
Context::SearchInput,
|
||||||
|
KeyCode::Char('d'),
|
||||||
|
KeyModifiers::NONE
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
press(
|
||||||
|
&defaults,
|
||||||
|
Context::SelectorNormal,
|
||||||
|
KeyCode::Char('j'),
|
||||||
|
KeyModifiers::NONE
|
||||||
|
),
|
||||||
|
Some(Action::SelectorNext)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
press(
|
||||||
|
&defaults,
|
||||||
|
Context::SelectorInput,
|
||||||
|
KeyCode::Char('j'),
|
||||||
|
KeyModifiers::NONE
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
for context in ALL {
|
||||||
|
assert_eq!(
|
||||||
|
press(
|
||||||
|
&defaults,
|
||||||
|
*context,
|
||||||
|
KeyCode::Char('c'),
|
||||||
|
KeyModifiers::CONTROL
|
||||||
|
),
|
||||||
|
Some(Action::Interrupt)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modifier_normalization_is_exact_and_allocation_free() {
|
||||||
|
let bindings = configured("[keys]\nvideo.info.overview = 'shift+i'\nview.history.next = 'control++'\nview.refresh = 'option+F24'\nsearch.edit = 'command+界'\n").unwrap();
|
||||||
|
for (code, modifiers) in [
|
||||||
|
(KeyCode::Char('I'), KeyModifiers::NONE),
|
||||||
|
(KeyCode::Char('I'), KeyModifiers::SHIFT),
|
||||||
|
(KeyCode::Char('i'), KeyModifiers::SHIFT),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
press(&bindings, Context::Normal, code, modifiers),
|
||||||
|
Some(Action::InfoOverview)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
press(
|
||||||
|
&bindings,
|
||||||
|
Context::Normal,
|
||||||
|
KeyCode::Char('i'),
|
||||||
|
KeyModifiers::NONE
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
press(
|
||||||
|
&bindings,
|
||||||
|
Context::Normal,
|
||||||
|
KeyCode::Char('I'),
|
||||||
|
KeyModifiers::ALT
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
press(
|
||||||
|
&bindings,
|
||||||
|
Context::Normal,
|
||||||
|
KeyCode::Char('?'),
|
||||||
|
KeyModifiers::SHIFT
|
||||||
|
),
|
||||||
|
Some(Action::Help)
|
||||||
|
);
|
||||||
|
for (code, modifiers) in [
|
||||||
|
(KeyCode::BackTab, KeyModifiers::NONE),
|
||||||
|
(KeyCode::BackTab, KeyModifiers::SHIFT),
|
||||||
|
(KeyCode::Tab, KeyModifiers::SHIFT),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
press(&bindings, Context::Normal, code, modifiers),
|
||||||
|
Some(Action::ViewTabPrevious)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
press(
|
||||||
|
&bindings,
|
||||||
|
Context::Normal,
|
||||||
|
KeyCode::Char('+'),
|
||||||
|
KeyModifiers::CONTROL
|
||||||
|
),
|
||||||
|
Some(Action::ViewNext)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
press(
|
||||||
|
&bindings,
|
||||||
|
Context::Normal,
|
||||||
|
KeyCode::Char('+'),
|
||||||
|
KeyModifiers::NONE
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
press(
|
||||||
|
&bindings,
|
||||||
|
Context::Normal,
|
||||||
|
KeyCode::F(24),
|
||||||
|
KeyModifiers::ALT
|
||||||
|
),
|
||||||
|
Some(Action::ViewRefresh)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
press(
|
||||||
|
&bindings,
|
||||||
|
Context::Normal,
|
||||||
|
KeyCode::Char('界'),
|
||||||
|
KeyModifiers::SUPER
|
||||||
|
),
|
||||||
|
Some(Action::SearchEdit)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
press(
|
||||||
|
&bindings,
|
||||||
|
Context::Normal,
|
||||||
|
KeyCode::Char('c'),
|
||||||
|
KeyModifiers::CONTROL | KeyModifiers::ALT
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn aliases_replace_defaults_and_can_disable_actions() {
|
||||||
|
let bindings = configured("[keys.view.collection.remove]\nselected = []\n[keys.view]\nrefresh = ['x', 'CTRL+y']\n[keys.app]\nquit = []\n[keys.input]\nsubmit = 'RETURN'\ncancel = 'EsC'\n").unwrap();
|
||||||
|
let quoted = configured("[keys]\n'view.collection.remove.selected' = []\n'view.refresh' = ['x', 'CTRL+y']\n'app.quit' = []\n'input.submit' = 'RETURN'\n'input.cancel' = 'EsC'\n").unwrap();
|
||||||
|
assert_eq!(quoted, bindings);
|
||||||
|
assert_eq!(
|
||||||
|
press(
|
||||||
|
&bindings,
|
||||||
|
Context::Normal,
|
||||||
|
KeyCode::Char('r'),
|
||||||
|
KeyModifiers::NONE
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
press(
|
||||||
|
&bindings,
|
||||||
|
Context::Normal,
|
||||||
|
KeyCode::Char('q'),
|
||||||
|
KeyModifiers::NONE
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
press(
|
||||||
|
&bindings,
|
||||||
|
Context::Normal,
|
||||||
|
KeyCode::Char('x'),
|
||||||
|
KeyModifiers::NONE
|
||||||
|
),
|
||||||
|
Some(Action::ViewRefresh)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
press(
|
||||||
|
&bindings,
|
||||||
|
Context::Normal,
|
||||||
|
KeyCode::Char('y'),
|
||||||
|
KeyModifiers::CONTROL
|
||||||
|
),
|
||||||
|
Some(Action::ViewRefresh)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collection_removal_defaults_are_scoped_and_inherit_view_actions() {
|
||||||
|
let bindings = Bindings::default();
|
||||||
|
for &context in ALL {
|
||||||
|
for (key, action) in [
|
||||||
|
('x', Action::CollectionRemoveSelected),
|
||||||
|
('X', Action::CollectionRemoveBulk),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
press(&bindings, context, KeyCode::Char(key), KeyModifiers::NONE),
|
||||||
|
COLLECTION.contains(&context).then_some(action),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
press(&bindings, context, KeyCode::Char('D'), KeyModifiers::NONE),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (ordinary, collection) in [
|
||||||
|
(Context::Normal, Context::CollectionNormal),
|
||||||
|
(Context::Info, Context::CollectionInfo),
|
||||||
|
(Context::Description, Context::CollectionDescription),
|
||||||
|
] {
|
||||||
|
for &(key, action) in &bindings.contexts[ordinary as usize] {
|
||||||
|
if action == Action::ChannelPlaylists {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
bindings.resolve(collection, KeyEvent::new(key.code, key.modifiers)),
|
||||||
|
Some(action),
|
||||||
|
"{ordinary:?} / {collection:?}: {key:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
bindings.contexts[collection as usize].len(),
|
||||||
|
bindings.contexts[ordinary as usize].len() + 2
|
||||||
|
- usize::from(ordinary == Context::Normal),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collection_overrides_aliases_normalize_and_disable() {
|
||||||
|
let bindings = configured(
|
||||||
|
"[keys.view.collection.remove]\nselected = ['option+x', 'alt+x']\nbulk = 'shift+b'\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let quoted = configured(
|
||||||
|
"[keys]\n'view.collection.remove.selected' = 'alt+x'\n'view.collection.remove.bulk' = 'B'\n",
|
||||||
|
).unwrap();
|
||||||
|
assert_eq!(bindings, quoted);
|
||||||
|
assert_eq!(bindings.label(Action::CollectionRemoveSelected), "Alt+x");
|
||||||
|
assert_eq!(bindings.label(Action::CollectionRemoveBulk), "B");
|
||||||
|
let help = bindings.help_text().to_string();
|
||||||
|
assert!(help.contains("Alt+x"));
|
||||||
|
assert!(help.lines().any(|line| line.trim_start().starts_with("B ")));
|
||||||
|
for &context in ALL {
|
||||||
|
for (code, modifiers, action) in [
|
||||||
|
(
|
||||||
|
KeyCode::Char('x'),
|
||||||
|
KeyModifiers::ALT,
|
||||||
|
Action::CollectionRemoveSelected,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
KeyCode::Char('b'),
|
||||||
|
KeyModifiers::SHIFT,
|
||||||
|
Action::CollectionRemoveBulk,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
KeyCode::Char('B'),
|
||||||
|
KeyModifiers::NONE,
|
||||||
|
Action::CollectionRemoveBulk,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
press(&bindings, context, code, modifiers),
|
||||||
|
COLLECTION.contains(&context).then_some(action),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for key in ['x', 'X'] {
|
||||||
|
assert_eq!(
|
||||||
|
press(&bindings, context, KeyCode::Char(key), KeyModifiers::NONE),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let disabled = configured(
|
||||||
|
"[keys.view.collection.remove]\nselected = []\nbulk = []\n[keys.view]\nrefresh = ['x', 'shift+x']\n",
|
||||||
|
).unwrap();
|
||||||
|
for &context in ALL {
|
||||||
|
for key in ['x', 'X'] {
|
||||||
|
assert_eq!(
|
||||||
|
press(&disabled, context, KeyCode::Char(key), KeyModifiers::NONE),
|
||||||
|
VIEW.contains(&context).then_some(Action::ViewRefresh),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collection_contexts_validate_inherited_binding_conflicts() {
|
||||||
|
for source in [
|
||||||
|
"[keys]\nview.refresh = 'x'",
|
||||||
|
"[keys]\nview.watch_later.browse = 'X'",
|
||||||
|
"[keys]\nview.saved.browse = 'X'",
|
||||||
|
"[keys]\nview.collection.remove.selected = 'q'",
|
||||||
|
"[keys]\nview.collection.remove.bulk = 'down'",
|
||||||
|
] {
|
||||||
|
let error = configured(source).unwrap_err();
|
||||||
|
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
|
||||||
|
assert!(error.to_string().contains("Collection"), "{error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collection_browse_shortcuts_are_distinct_and_view_scoped() {
|
||||||
|
let defaults = Bindings::default();
|
||||||
|
let overrides = configured(
|
||||||
|
"[keys.view.watch_later]\ncategory = ['OpTiOn+b', 'alt+b']\n[keys]\n'view.saved.category' = []\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
for &context in ALL {
|
||||||
|
for (key, modifiers, action) in [
|
||||||
|
('W', KeyModifiers::NONE, Action::WatchLaterBrowse),
|
||||||
|
('w', KeyModifiers::SHIFT, Action::WatchLaterBrowse),
|
||||||
|
('V', KeyModifiers::NONE, Action::SavedVideoBrowse),
|
||||||
|
('v', KeyModifiers::SHIFT, Action::SavedVideoBrowse),
|
||||||
|
('w', KeyModifiers::ALT, Action::WatchLaterBrowseCategory),
|
||||||
|
('v', KeyModifiers::ALT, Action::SavedVideoBrowseCategory),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
press(&defaults, context, KeyCode::Char(key), modifiers),
|
||||||
|
VIEW.contains(&context).then_some(action),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
press(&overrides, context, KeyCode::Char('b'), KeyModifiers::ALT),
|
||||||
|
VIEW.contains(&context)
|
||||||
|
.then_some(Action::WatchLaterBrowseCategory),
|
||||||
|
);
|
||||||
|
for key in ['w', 'v'] {
|
||||||
|
assert_eq!(
|
||||||
|
press(&overrides, context, KeyCode::Char(key), KeyModifiers::ALT),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for key in ['W', 'V'] {
|
||||||
|
assert_eq!(
|
||||||
|
press(&defaults, context, KeyCode::Char(key), KeyModifiers::ALT),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(overrides.label(Action::WatchLaterBrowseCategory), "Alt+b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collection_browsing_and_save_overrides_retain_contexts() {
|
||||||
|
let bindings = configured(
|
||||||
|
"[keys.video.watch_later]\nsave = 'alt+w'\n[keys.video.saved]\nsave = 'alt+v'\n[keys.view.watch_later]\nbrowse = 'shift+x'\ncategory = []\nfilter = 'ctrl+f'\n[keys.view.saved]\nbrowse = 'alt+b'\ncategory = []\n[keys.view.collection.remove]\nbulk = []\n",
|
||||||
|
).unwrap();
|
||||||
|
for &context in ALL {
|
||||||
|
for (key, modifiers, action, enabled) in [
|
||||||
|
(
|
||||||
|
'w',
|
||||||
|
KeyModifiers::ALT,
|
||||||
|
Action::WatchLaterSave,
|
||||||
|
VIEW.contains(&context),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'v',
|
||||||
|
KeyModifiers::ALT,
|
||||||
|
Action::SavedVideoSave,
|
||||||
|
VIEW.contains(&context),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'x',
|
||||||
|
KeyModifiers::SHIFT,
|
||||||
|
Action::WatchLaterBrowse,
|
||||||
|
VIEW.contains(&context),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'f',
|
||||||
|
KeyModifiers::CONTROL,
|
||||||
|
Action::WatchLaterFilter,
|
||||||
|
matches!(context, Context::Normal | Context::CollectionNormal),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'b',
|
||||||
|
KeyModifiers::ALT,
|
||||||
|
Action::SavedVideoBrowse,
|
||||||
|
VIEW.contains(&context),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
press(&bindings, context, KeyCode::Char(key), modifiers),
|
||||||
|
enabled.then_some(action)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn obsolete_removal_names_are_unknown() {
|
||||||
|
for name in [
|
||||||
|
"view.watch_later.remove",
|
||||||
|
"video.watch_later.remove",
|
||||||
|
"video.saved.remove",
|
||||||
|
] {
|
||||||
|
let error = configured(&format!("[keys]\n'{name}' = []")).unwrap_err();
|
||||||
|
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
|
||||||
|
assert!(error.to_string().contains("unknown action"), "{error}");
|
||||||
|
assert!(error.to_string().contains(name), "{error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_entries_and_collisions_have_actionable_errors() {
|
||||||
|
for (source, path) in [
|
||||||
|
("[keys]\nunknown = 'x'", "keys.unknown"),
|
||||||
|
("[keys.unknown]", "keys.unknown"),
|
||||||
|
("keys = []", "keys"),
|
||||||
|
("[keys]\nview = 'x'", "keys.view"),
|
||||||
|
("[keys]\nview.refresh = 1", "keys.view.refresh"),
|
||||||
|
("[keys]\nview.refresh = ['x', 1]", "keys.view.refresh[1]"),
|
||||||
|
("[keys]\nview.refresh = 'hyper+x'", "keys.view.refresh"),
|
||||||
|
(
|
||||||
|
"[keys]\nview.refresh = 'ctrl+control+x'",
|
||||||
|
"keys.view.refresh",
|
||||||
|
),
|
||||||
|
("[keys]\nview.refresh = 'ctrl+'", "keys.view.refresh"),
|
||||||
|
("[keys]\nview.refresh = 'f25'", "keys.view.refresh"),
|
||||||
|
("[keys]\nview.refresh = ''", "keys.view.refresh"),
|
||||||
|
] {
|
||||||
|
let error = configured(source).unwrap_err();
|
||||||
|
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
|
||||||
|
assert!(error.to_string().contains(path), "{error}");
|
||||||
|
}
|
||||||
|
for source in [
|
||||||
|
"[keys]\n'video.info.overview' = 'I'\nvideo.info.overview = 'o'",
|
||||||
|
"[keys]\nvideo.info.overview = 'o'\n'video.info.overview' = 'I'",
|
||||||
|
] {
|
||||||
|
let error = configured(source).unwrap_err();
|
||||||
|
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
|
||||||
|
assert!(
|
||||||
|
error.to_string().contains("keys.video.info.overview"),
|
||||||
|
"{error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for source in [
|
||||||
|
"[keys]\nview.refresh = 'q'",
|
||||||
|
"[keys]\nview.refresh = 'ctrl+c'",
|
||||||
|
"[keys]\nsearch.edit = 'shift+g'",
|
||||||
|
] {
|
||||||
|
let error = configured(source).unwrap_err();
|
||||||
|
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
|
||||||
|
}
|
||||||
|
let collision = configured("[keys]\nview.refresh = 'q'")
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(collision.contains("view.refresh") && collision.contains("app.quit"));
|
||||||
|
// Disjoint contexts can use the same key, including an overridden input command.
|
||||||
|
assert!(configured("[keys]\ninput.submit = 'q'").is_ok());
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn watch_url_preserves_stable_identity_and_validates_mutable_fields() {
|
||||||
|
let identity = Identity::from_key("miruro:OERe_a0YS4h-DlsBDOeXwX7HOO6316tw:1").unwrap();
|
||||||
|
assert_eq!(identity.key(), "miruro:OERe_a0YS4h-DlsBDOeXwX7HOO6316tw:1");
|
||||||
|
assert_eq!(
|
||||||
|
identity.watch_url().unwrap(),
|
||||||
|
"https://barelystarted.miruro.tv/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/episode?ep=1"
|
||||||
|
);
|
||||||
|
assert_eq!(Identity::from_key(&identity.key()).unwrap(), identity);
|
||||||
|
assert_ne!(
|
||||||
|
Identity::miruro(identity.media_id.clone(), 2)
|
||||||
|
.unwrap()
|
||||||
|
.key(),
|
||||||
|
identity.key()
|
||||||
|
);
|
||||||
|
for key in [
|
||||||
|
"miruro:catalog:01",
|
||||||
|
"miruro:catalog:0",
|
||||||
|
"miruro:catalog:2147483648",
|
||||||
|
"miruro:catalog/:1",
|
||||||
|
"miruro:catalog:+1",
|
||||||
|
"miruro::1",
|
||||||
|
] {
|
||||||
|
assert!(Identity::from_key(key).is_err());
|
||||||
|
}
|
||||||
|
for invalid in [
|
||||||
|
Identity {
|
||||||
|
provider: "youtube".into(),
|
||||||
|
..identity.clone()
|
||||||
|
},
|
||||||
|
Identity {
|
||||||
|
media_id: "catalog/".into(),
|
||||||
|
..identity.clone()
|
||||||
|
},
|
||||||
|
Identity {
|
||||||
|
media_id: "a".repeat(513),
|
||||||
|
..identity.clone()
|
||||||
|
},
|
||||||
|
Identity {
|
||||||
|
episode: 0,
|
||||||
|
..identity.clone()
|
||||||
|
},
|
||||||
|
Identity {
|
||||||
|
episode: i32::MAX as u32 + 1,
|
||||||
|
..identity.clone()
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
assert!(invalid.watch_url().is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn episodes_resolve_by_promise_id_with_independent_tables_or_fail_explicitly() {
|
||||||
|
let initial = r#"{"type":"data","nodes":[null,{"type":"data","data":[{"detail":1,"episodes":4},{"id":2,"episode_count":3},"series",12,["Promise",5],9]}]}"#;
|
||||||
|
let unrelated = r#"{"type":"chunk","id":4,"data":["unrelated"]}"#;
|
||||||
|
let episode_chunk = r#"{"type":"chunk","id":9,"data":[[1],{"episode_number":2,"absolute_number":3,"title":4,"canon_type":5},1,49,"Locusts",null]}"#;
|
||||||
|
let detail = decode_detail(&format!("{initial}\n{unrelated}\n{episode_chunk}\n")).unwrap();
|
||||||
|
assert_eq!(detail.media["episode_count"], 12);
|
||||||
|
assert_eq!(detail.episodes[0]["episode_number"], 1);
|
||||||
|
assert_eq!(detail.episodes[0]["absolute_number"], 49);
|
||||||
|
assert_eq!(detail.episodes[0]["title"], "Locusts");
|
||||||
|
assert!(detail.episodes[0]["canon_type"].is_null());
|
||||||
|
for chunk in [
|
||||||
|
"", // The promised episodes never arrive.
|
||||||
|
r#"{"type":"chunk","id":9,"error":["failed"]}"#,
|
||||||
|
r#"{"type":"chunk","id":9,"data":[[999]]}"#,
|
||||||
|
r#"{"type":"chunk","id":9,"data":[[0]]}"#, // Reference cycle.
|
||||||
|
r#"{"type":"chunk","id":9,"data":[["Date","2026-10-12"]]}"#,
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
decode_detail(&format!("{initial}\n{chunk}"))
|
||||||
|
.err()
|
||||||
|
.unwrap()
|
||||||
|
.kind(),
|
||||||
|
io::ErrorKind::InvalidData
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
decode_detail(r#"{"type":"redirect","location":"/"}"#)
|
||||||
|
.err()
|
||||||
|
.unwrap()
|
||||||
|
.kind(),
|
||||||
|
io::ErrorKind::NotFound
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mirror_failover_remembers_success_and_stops_on_rate_limits_and_bad_requests() {
|
||||||
|
use std::{
|
||||||
|
io::{BufRead, BufReader, Write},
|
||||||
|
net::TcpListener,
|
||||||
|
thread,
|
||||||
|
};
|
||||||
|
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||||
|
let server = thread::spawn(move || {
|
||||||
|
for (path, status, mime, body) in [
|
||||||
|
("/one/catalog?q=anime", 403, "text/html", "blocked"),
|
||||||
|
("/two/catalog?q=anime", 200, "text/html", "protection page"),
|
||||||
|
(
|
||||||
|
"/three/catalog?q=anime",
|
||||||
|
200,
|
||||||
|
"application/json",
|
||||||
|
r#"{"data":[{"id":"series"}]}"#,
|
||||||
|
),
|
||||||
|
("/three/detail", 503, "text/html", "unavailable"),
|
||||||
|
(
|
||||||
|
"/one/detail",
|
||||||
|
200,
|
||||||
|
"text/sveltekit-data",
|
||||||
|
"episode metadata",
|
||||||
|
),
|
||||||
|
("/one/limited", 429, "text/html", "rate limited"),
|
||||||
|
("/one/bad-query", 400, "application/json", "{}"),
|
||||||
|
("/one/exhausted", 403, "text/html", "blocked"),
|
||||||
|
("/two/exhausted", 404, "text/html", "gone"),
|
||||||
|
("/three/exhausted", 502, "text/html", "unavailable"),
|
||||||
|
("/one/recovered", 200, "application/json", r#"{"data":[]}"#),
|
||||||
|
] {
|
||||||
|
let (mut stream, _) = listener.accept().unwrap();
|
||||||
|
stream
|
||||||
|
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||||
|
.unwrap();
|
||||||
|
let mut reader = BufReader::new(&stream);
|
||||||
|
let mut line = String::new();
|
||||||
|
reader.read_line(&mut line).unwrap();
|
||||||
|
assert_eq!(line.trim_end(), format!("GET {path} HTTP/1.1"));
|
||||||
|
loop {
|
||||||
|
line.clear();
|
||||||
|
reader.read_line(&mut line).unwrap();
|
||||||
|
if line == "\r\n" {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write!(
|
||||||
|
stream,
|
||||||
|
"HTTP/1.1 {status} Response\r\nContent-Type: {mime}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||||
|
body.len()
|
||||||
|
).unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let bases = [
|
||||||
|
format!("{base}/one"),
|
||||||
|
format!("{base}/two"),
|
||||||
|
format!("{base}/three"),
|
||||||
|
];
|
||||||
|
let mirrors: Vec<_> = bases.iter().map(String::as_str).collect();
|
||||||
|
let client = Client::default();
|
||||||
|
let body = client
|
||||||
|
.get_from(
|
||||||
|
&mirrors,
|
||||||
|
"/catalog",
|
||||||
|
&[("q", "anime")],
|
||||||
|
&["application/json"],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Value>(&body).unwrap()["data"][0]["id"],
|
||||||
|
"series"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.get_from(&mirrors, "/detail", &[], &["text/sveltekit-data"])
|
||||||
|
.unwrap(),
|
||||||
|
"episode metadata"
|
||||||
|
);
|
||||||
|
for (path, status) in [("/limited", 429), ("/bad-query", 400)] {
|
||||||
|
let error = client
|
||||||
|
.get_from(&mirrors, path, &[], &["application/json"])
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(error.to_string().contains(&format!("HTTP {status}")));
|
||||||
|
}
|
||||||
|
let error = client
|
||||||
|
.get_from(&mirrors, "/exhausted", &[], &["application/json"])
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(error.to_string().contains("failed on 3/3 mirrors"));
|
||||||
|
assert!(error.to_string().contains("HTTP 502"));
|
||||||
|
let body = client
|
||||||
|
.get_from(&mirrors, "/recovered", &[], &["application/json"])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Value>(&body).unwrap()["data"],
|
||||||
|
serde_json::json!([])
|
||||||
|
);
|
||||||
|
server.join().unwrap();
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn catalog_and_actual_episode_numbers_remain_distinct() {
|
||||||
|
let media = serde_json::json!({"id":"opaque_ID", "title":{"english":"The Apothecary Diaries", "romaji":"Kusuriya no Hitorigoto"}, "episode_count":12, "episode_counts":{"sub":null}, "episode_duration_minutes":null});
|
||||||
|
let rows = catalog(vec![media.clone()]).unwrap();
|
||||||
|
assert_eq!(rows[0].id, "opaque_ID");
|
||||||
|
assert!(rows[0].metadata["episode_counts"]["sub"].is_null());
|
||||||
|
assert!(catalog(vec![media.clone(), media.clone()]).is_err());
|
||||||
|
let episode = serde_json::json!({"episode_number":3,"absolute_number":49,"title":"Returned","synopsis":"Actual synopsis","aired_on":"2026-10-02","duration_seconds":1500});
|
||||||
|
let detail = || Detail {
|
||||||
|
media: media.clone(),
|
||||||
|
episodes: vec![episode.clone()],
|
||||||
|
};
|
||||||
|
let data = series("opaque_ID", detail()).unwrap();
|
||||||
|
assert_eq!(data.episodes.len(), 1);
|
||||||
|
assert_eq!(data.episodes[0].identity.episode, 3);
|
||||||
|
assert_eq!(data.episodes[0].identity.media_id, "opaque_ID");
|
||||||
|
assert_eq!(data.episodes[0].duration_seconds, Some(1500));
|
||||||
|
assert_eq!(
|
||||||
|
data.episodes[0].synopsis.as_deref(),
|
||||||
|
Some("Actual synopsis")
|
||||||
|
);
|
||||||
|
assert!(data.episodes[0].snapshot(&data.media).channel_id.is_none());
|
||||||
|
assert!(series("other", detail()).is_err());
|
||||||
|
assert!(
|
||||||
|
series(
|
||||||
|
"opaque_ID",
|
||||||
|
Detail {
|
||||||
|
media: media.clone(),
|
||||||
|
episodes: vec![episode.clone(), episode]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
series(
|
||||||
|
"opaque_ID",
|
||||||
|
Detail {
|
||||||
|
media,
|
||||||
|
episodes: vec![serde_json::json!({"absolute_number":49})]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(slug("薬屋").is_err());
|
||||||
|
assert_eq!(slug(" A Test!! -- Title ").unwrap(), "a-test-title");
|
||||||
|
assert_eq!(slug(" --Title-- ").unwrap(), "-title-");
|
||||||
|
assert_eq!(slug("École déjà-vu").unwrap(), "ecole-deja-vu");
|
||||||
|
assert_eq!(slug("Cafe\u{0301}").unwrap(), "cafe");
|
||||||
|
assert_eq!(slug("A薬屋 KK İ").unwrap(), "kk-i");
|
||||||
|
assert_eq!(
|
||||||
|
slug("\u{feff}A\u{00a0}B\u{2003}C\u{feff}").unwrap(),
|
||||||
|
"a-b-c"
|
||||||
|
);
|
||||||
|
assert_eq!(slug("A\u{0085}B").unwrap(), "ab");
|
||||||
|
assert!(slug("!!薬屋!!").is_err());
|
||||||
|
assert_eq!(
|
||||||
|
title(&serde_json::json!({"id":"id","title":{"english":"AB","romaji":"CD"}})).unwrap(),
|
||||||
|
"AB"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
title(&serde_json::json!({"id":"id","title":{"english":"𐀀A","romaji":"BC"}})).unwrap(),
|
||||||
|
"BC"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
title(&serde_json::json!({"id":"id","title":{"native":""}})).unwrap(),
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anilist_catalog_numbers_are_not_planned_totals_or_stream_claims() {
|
||||||
|
let metadata = serde_json::json!({"id":"1","catalog_provider":"anilist","title":{"romaji":"Cowboy Bebop"},"status":"FINISHED","episode_count":26,"catalog_count":26});
|
||||||
|
let series = anilist_series(metadata).unwrap();
|
||||||
|
assert_eq!(series.episodes.len(), 26);
|
||||||
|
assert_eq!(
|
||||||
|
series.episodes.last().unwrap().identity.key(),
|
||||||
|
"anilist:1:26"
|
||||||
|
);
|
||||||
|
let snapshot = series.episodes[0].snapshot(&series.media);
|
||||||
|
assert_eq!(snapshot.channel.as_deref(), Some("AniList"));
|
||||||
|
assert_eq!(
|
||||||
|
crate::youtube::video_url(&snapshot.id).unwrap(),
|
||||||
|
"https://zokoanime.video/stream/ani/1/1/sub"
|
||||||
|
);
|
||||||
|
let ongoing = anilist_series(serde_json::json!({"id":"2","catalog_provider":"anilist","title":{"romaji":"Ongoing"},"status":"RELEASING","episode_count":24,"nextAiringEpisode":{"episode":4},"catalog_count":3})).unwrap();
|
||||||
|
assert_eq!(ongoing.episodes.len(), 3);
|
||||||
|
let unknown = anilist_series(serde_json::json!({"id":"3","catalog_provider":"anilist","title":{"romaji":"Unknown"},"status":"RELEASING","episode_count":null,"catalog_count":null})).unwrap();
|
||||||
|
assert!(unknown.episodes.is_empty());
|
||||||
|
assert!(unknown.media.metadata["catalog_count"].is_null());
|
||||||
|
assert_eq!(
|
||||||
|
anilist_episode(&unknown.media, 42).unwrap().identity.key(),
|
||||||
|
"anilist:3:42"
|
||||||
|
);
|
||||||
|
assert!(anilist_episode(&unknown.media, 0).is_err());
|
||||||
|
assert!(anilist_episode(&unknown.media, i32::MAX as u32 + 1).is_err());
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,331 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loading_thumbnail_keeps_the_same_text_column_as_the_ready_image() {
|
||||||
|
use ratatui::{Terminal, backend::TestBackend};
|
||||||
|
use std::time::Duration;
|
||||||
|
let mut thumbnail = Thumbnail::new();
|
||||||
|
thumbnail.selected = Some("fixture".into());
|
||||||
|
let area = Rect::new(2, 2, 80, 8);
|
||||||
|
let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap();
|
||||||
|
let mut loading_width = 0;
|
||||||
|
terminal
|
||||||
|
.draw(|frame| loading_width = thumbnail.draw(frame, area))
|
||||||
|
.unwrap();
|
||||||
|
let (_decode_tx, decode_rx) = mpsc::channel();
|
||||||
|
thumbnail.decoder = Some(decode_rx);
|
||||||
|
let mut decoding_width = 0;
|
||||||
|
terminal
|
||||||
|
.draw(|frame| decoding_width = thumbnail.draw(frame, area))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(loading_width, decoding_width);
|
||||||
|
thumbnail.decoder = None;
|
||||||
|
let (requests_tx, requests_rx) = mpsc::channel::<ResizeRequest>();
|
||||||
|
let (replies_tx, replies_rx) = mpsc::channel();
|
||||||
|
thumbnail.image = Some(ThumbnailImage {
|
||||||
|
id: "fixture".into(),
|
||||||
|
protocol: ThreadProtocol::new(
|
||||||
|
requests_tx,
|
||||||
|
Some(
|
||||||
|
thumbnail
|
||||||
|
.picker
|
||||||
|
.new_resize_protocol(image::DynamicImage::new_rgb8(48, 36)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
replies: replies_rx,
|
||||||
|
complete: false,
|
||||||
|
});
|
||||||
|
let mut preparing_width = 0;
|
||||||
|
terminal
|
||||||
|
.draw(|frame| preparing_width = thumbnail.draw(frame, area))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(loading_width, preparing_width);
|
||||||
|
terminal
|
||||||
|
.draw(|frame| preparing_width = thumbnail.draw(frame, area))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(loading_width, preparing_width);
|
||||||
|
replies_tx
|
||||||
|
.send(Ok(requests_rx
|
||||||
|
.recv_timeout(Duration::from_secs(2))
|
||||||
|
.unwrap()
|
||||||
|
.resize_encode()
|
||||||
|
.unwrap()))
|
||||||
|
.unwrap();
|
||||||
|
let mut ready_width = 0;
|
||||||
|
terminal
|
||||||
|
.draw(|frame| ready_width = thumbnail.draw(frame, area))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
loading_width, ready_width,
|
||||||
|
"right-hand content must not move when the image finishes loading"
|
||||||
|
);
|
||||||
|
thumbnail.cancel();
|
||||||
|
thumbnail.select("fixture");
|
||||||
|
terminal
|
||||||
|
.draw(|frame| ready_width = thumbnail.draw(frame, area))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(loading_width, ready_width);
|
||||||
|
replies_tx
|
||||||
|
.send(Err("fixture renderer failure".into()))
|
||||||
|
.unwrap();
|
||||||
|
let mut error_width = 0;
|
||||||
|
terminal
|
||||||
|
.draw(|frame| error_width = thumbnail.draw(frame, area))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(loading_width, error_width);
|
||||||
|
assert!(thumbnail.error.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn portrait_cover_keeps_its_slot_and_actual_aspect_when_ready_and_cached() {
|
||||||
|
use ratatui::{Terminal, backend::TestBackend};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
let mut thumbnail = Thumbnail::new();
|
||||||
|
let url = "https://example.test/cover.png";
|
||||||
|
thumbnail.selected = Some(url.into());
|
||||||
|
thumbnail.aspect = (2, 3);
|
||||||
|
let area = Rect::new(2, 2, 80, 8);
|
||||||
|
let slot = thumbnail_size(area, thumbnail.picker.font_size(), (2, 3));
|
||||||
|
let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap();
|
||||||
|
let mut loading_width = 0;
|
||||||
|
terminal
|
||||||
|
.draw(|frame| loading_width = thumbnail.draw(frame, area))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(loading_width, slot.width);
|
||||||
|
// The artwork need not exactly match the placeholder's 2:3 aspect.
|
||||||
|
let protocol = thumbnail
|
||||||
|
.picker
|
||||||
|
.new_resize_protocol(image::DynamicImage::new_rgb8(60, 100));
|
||||||
|
let (requests_tx, requests_rx) = mpsc::channel::<ResizeRequest>();
|
||||||
|
let (replies_tx, replies_rx) = mpsc::channel();
|
||||||
|
thumbnail.image = Some(ThumbnailImage {
|
||||||
|
id: url.into(),
|
||||||
|
protocol: ThreadProtocol::new(requests_tx, Some(protocol)),
|
||||||
|
replies: replies_rx,
|
||||||
|
complete: false,
|
||||||
|
});
|
||||||
|
terminal
|
||||||
|
.draw(|frame| {
|
||||||
|
assert_eq!(thumbnail.draw(frame, area), loading_width);
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let response = requests_rx
|
||||||
|
.recv_timeout(Duration::from_secs(2))
|
||||||
|
.unwrap()
|
||||||
|
.resize_encode()
|
||||||
|
.unwrap();
|
||||||
|
replies_tx.send(Ok(response)).unwrap();
|
||||||
|
terminal
|
||||||
|
.draw(|frame| {
|
||||||
|
assert_eq!(thumbnail.draw(frame, area), loading_width);
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(!thumbnail.is_loading());
|
||||||
|
let size = thumbnail
|
||||||
|
.image
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.protocol
|
||||||
|
.size_for(Resize::Scale(None), slot)
|
||||||
|
.unwrap();
|
||||||
|
assert!(size.width <= slot.width && size.height <= slot.height);
|
||||||
|
let font = thumbnail.picker.font_size();
|
||||||
|
let width = u32::from(size.width) * u32::from(font.width);
|
||||||
|
let height = u32::from(size.height) * u32::from(font.height);
|
||||||
|
assert!(width.abs_diff(height * 3 / 5) <= u32::from(font.width) + u32::from(font.height));
|
||||||
|
thumbnail.cancel();
|
||||||
|
thumbnail.select_url(url);
|
||||||
|
assert!(thumbnail.fetch.is_none());
|
||||||
|
assert_eq!(thumbnail.selected.as_deref(), Some(url));
|
||||||
|
terminal
|
||||||
|
.draw(|frame| {
|
||||||
|
assert_eq!(thumbnail.draw(frame, area), loading_width);
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
thumbnail.clear_cache();
|
||||||
|
assert!(thumbnail.image.is_none() && thumbnail.cached.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn guesses_cover_formats_and_rejects_corrupt_or_oversized_input() {
|
||||||
|
assert!(decode(b"not a JPEG".to_vec()).is_err());
|
||||||
|
assert!(decode(vec![0; 2 * 1024 * 1024 + 1]).is_err());
|
||||||
|
for format in [
|
||||||
|
image::ImageFormat::Jpeg,
|
||||||
|
image::ImageFormat::Png,
|
||||||
|
image::ImageFormat::WebP,
|
||||||
|
] {
|
||||||
|
let mut bytes = Cursor::new(Vec::new());
|
||||||
|
image::DynamicImage::new_rgb8(2, 3)
|
||||||
|
.write_to(&mut bytes, format)
|
||||||
|
.unwrap();
|
||||||
|
let image = decode(bytes.into_inner()).unwrap();
|
||||||
|
assert_eq!((image.width(), image.height()), (2, 3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancellation_discards_queued_decode_completion() {
|
||||||
|
let mut thumbnail = Thumbnail::new();
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
let (requests_tx, _requests_rx) = mpsc::channel();
|
||||||
|
let (_replies_tx, replies_rx) = mpsc::channel();
|
||||||
|
let protocol = thumbnail
|
||||||
|
.picker
|
||||||
|
.new_resize_protocol(image::DynamicImage::new_rgb8(1, 1));
|
||||||
|
thumbnail.selected = Some("old-video".into());
|
||||||
|
thumbnail.decoder = Some(rx);
|
||||||
|
assert!(
|
||||||
|
tx.send(Ok(ThumbnailImage {
|
||||||
|
id: "old-video".into(),
|
||||||
|
protocol: ThreadProtocol::new(requests_tx, Some(protocol)),
|
||||||
|
replies: replies_rx,
|
||||||
|
complete: false,
|
||||||
|
}))
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
thumbnail.cancel();
|
||||||
|
thumbnail.selected = Some("new-video".into());
|
||||||
|
thumbnail.poll();
|
||||||
|
assert!(thumbnail.image.is_none());
|
||||||
|
assert!(thumbnail.error.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn thumbnail_hits_survive_a_full_buffer_and_promote_recent_images() {
|
||||||
|
let mut thumbnail = Thumbnail::new();
|
||||||
|
let completed = |id: String| {
|
||||||
|
let (requests, _receiver) = mpsc::channel();
|
||||||
|
let (_sender, replies) = mpsc::channel();
|
||||||
|
ThumbnailImage {
|
||||||
|
id,
|
||||||
|
protocol: ThreadProtocol::new(
|
||||||
|
requests,
|
||||||
|
Some(Picker::halfblocks().new_resize_protocol(image::DynamicImage::new_rgb8(1, 1))),
|
||||||
|
),
|
||||||
|
replies,
|
||||||
|
complete: true,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for index in 0..CACHE_SIZE {
|
||||||
|
thumbnail.image = Some(completed(format!("video{index:06}")));
|
||||||
|
thumbnail.cancel();
|
||||||
|
}
|
||||||
|
thumbnail.image = Some(completed("video000008".into()));
|
||||||
|
thumbnail.selected = Some("video000008".into());
|
||||||
|
// This is the oldest cached image: storing the outgoing image first would evict it.
|
||||||
|
thumbnail.select("video000000");
|
||||||
|
assert_eq!(thumbnail.image.as_ref().unwrap().id, "video000000");
|
||||||
|
assert!(thumbnail.fetch.is_none());
|
||||||
|
thumbnail.select("video000001");
|
||||||
|
assert_eq!(thumbnail.image.as_ref().unwrap().id, "video000001");
|
||||||
|
assert!(thumbnail.fetch.is_none());
|
||||||
|
thumbnail.cancel();
|
||||||
|
assert!(
|
||||||
|
!thumbnail
|
||||||
|
.cached
|
||||||
|
.iter()
|
||||||
|
.any(|image| image.id == "video000002")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
thumbnail
|
||||||
|
.cached
|
||||||
|
.iter()
|
||||||
|
.any(|image| image.id == "video000000")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
thumbnail
|
||||||
|
.cached
|
||||||
|
.iter()
|
||||||
|
.any(|image| image.id == "video000008")
|
||||||
|
);
|
||||||
|
thumbnail.clear_cache();
|
||||||
|
assert!(thumbnail.cached.is_empty());
|
||||||
|
assert!(thumbnail.image.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn small_thumbnail_fills_popup_proportionally_and_resizes() {
|
||||||
|
use ratatui::{Terminal, backend::TestBackend, style::Color};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
let mut thumbnail = Thumbnail::new();
|
||||||
|
let font = thumbnail.picker.font_size();
|
||||||
|
let image = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(
|
||||||
|
48,
|
||||||
|
36,
|
||||||
|
image::Rgb([240, 40, 70]),
|
||||||
|
));
|
||||||
|
let (requests_tx, requests_rx) = mpsc::channel::<ResizeRequest>();
|
||||||
|
let (replies_tx, replies_rx) = mpsc::channel();
|
||||||
|
thumbnail.selected = Some("thumbnail".into());
|
||||||
|
thumbnail.image = Some(ThumbnailImage {
|
||||||
|
id: "thumbnail".into(),
|
||||||
|
protocol: ThreadProtocol::new(
|
||||||
|
requests_tx,
|
||||||
|
Some(thumbnail.picker.new_resize_protocol(image)),
|
||||||
|
),
|
||||||
|
replies: replies_rx,
|
||||||
|
complete: false,
|
||||||
|
});
|
||||||
|
let mut terminal = Terminal::new(TestBackend::new(100, 40)).unwrap();
|
||||||
|
for area in [
|
||||||
|
Rect::new(3, 2, 80, 18),
|
||||||
|
Rect::new(7, 4, 30, 8),
|
||||||
|
Rect::new(3, 2, 9, 18),
|
||||||
|
Rect::new(7, 4, 30, 8),
|
||||||
|
] {
|
||||||
|
let mut pending_width = 0;
|
||||||
|
terminal
|
||||||
|
.draw(|frame| {
|
||||||
|
pending_width = thumbnail.draw(frame, area);
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(thumbnail.is_loading());
|
||||||
|
assert!(
|
||||||
|
!terminal.backend().buffer().content.iter().any(|cell| {
|
||||||
|
matches!(cell.fg, Color::Rgb(..)) || matches!(cell.bg, Color::Rgb(..))
|
||||||
|
}),
|
||||||
|
"a resize must not render stale pixels"
|
||||||
|
);
|
||||||
|
let response = requests_rx
|
||||||
|
.recv_timeout(Duration::from_secs(2))
|
||||||
|
.unwrap()
|
||||||
|
.resize_encode()
|
||||||
|
.unwrap();
|
||||||
|
replies_tx.send(Ok(response)).unwrap();
|
||||||
|
let mut occupied_width = 0;
|
||||||
|
terminal
|
||||||
|
.draw(|frame| {
|
||||||
|
occupied_width = thumbnail.draw(frame, area);
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(!thumbnail.is_loading());
|
||||||
|
assert_eq!(pending_width, occupied_width);
|
||||||
|
let buffer = terminal.backend().buffer();
|
||||||
|
let pixels: Vec<_> = (0..40)
|
||||||
|
.flat_map(|y| (0..100).map(move |x| (x, y)))
|
||||||
|
.filter(|&(x, y)| {
|
||||||
|
let cell = &buffer[(x, y)];
|
||||||
|
matches!(cell.fg, Color::Rgb(..)) || matches!(cell.bg, Color::Rgb(..))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let left = pixels.iter().map(|&(x, _)| x).min().unwrap();
|
||||||
|
let right = pixels.iter().map(|&(x, _)| x).max().unwrap() + 1;
|
||||||
|
let top = pixels.iter().map(|&(_, y)| y).min().unwrap();
|
||||||
|
let bottom = pixels.iter().map(|&(_, y)| y).max().unwrap() + 1;
|
||||||
|
assert!(left >= area.x && right <= area.right());
|
||||||
|
assert_eq!(left, area.x);
|
||||||
|
assert_eq!(occupied_width, right - left);
|
||||||
|
assert!(top >= area.y && bottom <= area.bottom());
|
||||||
|
assert!(right - left == area.width || bottom - top == area.height);
|
||||||
|
assert!((top - area.y).abs_diff(area.bottom() - bottom) <= 1);
|
||||||
|
let width = u32::from(right - left) * u32::from(font.width);
|
||||||
|
let height = u32::from(bottom - top) * u32::from(font.height);
|
||||||
|
assert!(
|
||||||
|
width.abs_diff(height * 4 / 3)
|
||||||
|
<= u32::from(font.width) + u32::from(font.height) * 4 / 3
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,271 @@
|
|||||||
|
use super::super::{FeedCursor, PAGE_SIZE, feed_results};
|
||||||
|
use super::*;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
const CHANNEL: &str = "https://www.youtube.com/channel/UCaaaaaaaaaaaaaaaaaaaaaa";
|
||||||
|
const CHANNEL_ID: &str = "UCaaaaaaaaaaaaaaaaaaaaaa";
|
||||||
|
|
||||||
|
fn album(index: usize) -> Value {
|
||||||
|
json!({"id": format!("PL{index:032}"), "_type": "url"})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track(id: &str, title: &str, timestamp: i64) -> Value {
|
||||||
|
json!({"id": id, "title": title, "timestamp": timestamp})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn response(entries: Vec<Value>, total: Option<usize>) -> String {
|
||||||
|
json!({"entries": entries, "playlist_count": total}).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accept(releases: &mut ChannelReleases, entries: Vec<Value>, total: Option<usize>) {
|
||||||
|
// Construct, but never execute, the next extractor request.
|
||||||
|
releases.command().unwrap();
|
||||||
|
releases.accept(&response(entries, total)).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn range(releases: &ChannelReleases) -> (usize, usize) {
|
||||||
|
let command = releases.command().unwrap();
|
||||||
|
let args: Vec<_> = command.get_args().collect();
|
||||||
|
let value = |flag: &str| {
|
||||||
|
args.windows(2).find(|pair| pair[0] == flag).unwrap()[1]
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.parse::<usize>()
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
(value("--playlist-start"), value("--playlist-end"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn feed_output(releases: ChannelReleases) -> String {
|
||||||
|
assert!(releases.done());
|
||||||
|
let entries = releases.into_entries();
|
||||||
|
json!({"channel_id": CHANNEL_ID, "entries": entries}).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mixed_feed_merges_uploads_and_release_tracks_without_advancing_other_channels() {
|
||||||
|
let mut cursor = FeedCursor::default();
|
||||||
|
let requests = cursor.plan(2).unwrap();
|
||||||
|
let channels = vec![CHANNEL.into(), "https://www.youtube.com/@ordinary".into()];
|
||||||
|
let mut expansion = FeedExpansion::new(&channels, &requests);
|
||||||
|
let uploads = json!({
|
||||||
|
"channel_id": CHANNEL_ID,
|
||||||
|
"uploader_id": "@artist",
|
||||||
|
"playlist_count": 3,
|
||||||
|
"entries": [
|
||||||
|
track("aaaaaaaaaaa", "Upload", 100),
|
||||||
|
track("ddddddddddd", "Second upload", 100),
|
||||||
|
track("eeeeeeeeeee", "Third upload", 100)
|
||||||
|
]
|
||||||
|
});
|
||||||
|
let ordinary = json!({
|
||||||
|
"entries": (0..30).map(|index| {
|
||||||
|
track(&format!("b{index:010}"), "Ordinary upload", 50)
|
||||||
|
}).collect::<Vec<_>>()
|
||||||
|
});
|
||||||
|
expansion
|
||||||
|
.accept_uploads(&format!("{uploads}\n{ordinary}"))
|
||||||
|
.unwrap();
|
||||||
|
let releases = expansion.channels[0].releases.as_mut().unwrap();
|
||||||
|
accept(releases, vec![album(1)], Some(1));
|
||||||
|
let mut tracks = vec![
|
||||||
|
track("aaaaaaaaaaa", "Release duplicate", 200),
|
||||||
|
track("ccccccccccc", "peppermint parkway", 300),
|
||||||
|
];
|
||||||
|
tracks.resize(27, Value::Null);
|
||||||
|
accept(releases, tracks, Some(27));
|
||||||
|
|
||||||
|
let mut finished_uploads = Job::spawn_all(std::iter::empty::<Command>()).unwrap();
|
||||||
|
let output = expansion.poll(&mut finished_uploads).unwrap().unwrap();
|
||||||
|
let page = feed_results(&output, &requests).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
page.feed_progress
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.count)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[30, 30]
|
||||||
|
);
|
||||||
|
assert_eq!(page.videos.len(), 34);
|
||||||
|
assert_eq!(page.videos[0].id, "ccccccccccc");
|
||||||
|
assert_eq!(page.videos[0].channel_id.as_deref(), Some(CHANNEL_ID));
|
||||||
|
assert_eq!(page.videos[0].uploader_id.as_deref(), Some("@artist"));
|
||||||
|
assert_eq!(page.videos[1].title, "Release duplicate");
|
||||||
|
cursor.accept(&page.feed_progress).unwrap();
|
||||||
|
assert!(
|
||||||
|
cursor
|
||||||
|
.plan(2)
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.all(|request| request.start == 31)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn release_tracks_reach_the_feed_without_refunding_filtered_or_duplicate_rows() {
|
||||||
|
let mut cursor = FeedCursor::default();
|
||||||
|
let requests = cursor.plan(1).unwrap();
|
||||||
|
let mut releases = ChannelReleases::new(CHANNEL.into(), 0, requests[0].count);
|
||||||
|
accept(&mut releases, vec![album(1), album(2)], Some(2));
|
||||||
|
accept(
|
||||||
|
&mut releases,
|
||||||
|
vec![
|
||||||
|
track("aaaaaaaaaaa", "Older upload duplicate", 100),
|
||||||
|
json!({"id": "bbbbbbbbbbb", "title": "Members", "availability": "subscriber_only"}),
|
||||||
|
json!({"id": "invalid", "title": "Invalid video ID"}),
|
||||||
|
],
|
||||||
|
Some(3),
|
||||||
|
);
|
||||||
|
assert!(!releases.done());
|
||||||
|
assert_eq!(range(&releases), (1, PAGE_SIZE - 3));
|
||||||
|
let mut second_album = vec![
|
||||||
|
track("aaaaaaaaaaa", "Newer release duplicate", 200),
|
||||||
|
track("ccccccccccc", "peppermint parkway", 300),
|
||||||
|
];
|
||||||
|
second_album.resize(PAGE_SIZE - 3, Value::Null);
|
||||||
|
accept(&mut releases, second_album, None);
|
||||||
|
|
||||||
|
let page = feed_results(&feed_output(releases), &requests).unwrap();
|
||||||
|
assert_eq!(page.feed_progress[0].count, PAGE_SIZE);
|
||||||
|
assert!(page.has_more);
|
||||||
|
assert_eq!(page.videos.len(), 2);
|
||||||
|
assert_eq!(page.videos[0].id, "ccccccccccc");
|
||||||
|
assert_eq!(page.videos[0].title, "peppermint parkway");
|
||||||
|
assert_eq!(page.videos[1].title, "Newer release duplicate");
|
||||||
|
assert!(
|
||||||
|
page.videos
|
||||||
|
.iter()
|
||||||
|
.all(|video| video.channel_id.as_deref() == Some(CHANNEL_ID))
|
||||||
|
);
|
||||||
|
cursor.accept(&page.feed_progress).unwrap();
|
||||||
|
assert_eq!(cursor.plan(1).unwrap()[0].start, PAGE_SIZE + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn release_skip_subtracts_exhausted_album_totals_even_when_the_requested_start_is_past_end() {
|
||||||
|
for (skip, second_range) in [(3, (1, 2)), (5, (3, 4)), (8, (6, 7))] {
|
||||||
|
let mut releases = ChannelReleases::new(CHANNEL.into(), skip, 2);
|
||||||
|
accept(&mut releases, vec![album(1), album(2), album(3)], Some(3));
|
||||||
|
assert_eq!(range(&releases), (skip + 1, skip + 2));
|
||||||
|
// yt-dlp returns the exact album total even if the range starts beyond it.
|
||||||
|
accept(&mut releases, vec![], Some(3));
|
||||||
|
assert_eq!(range(&releases), second_range);
|
||||||
|
if skip == 8 {
|
||||||
|
accept(&mut releases, vec![], Some(4));
|
||||||
|
assert_eq!(range(&releases), (2, 3));
|
||||||
|
}
|
||||||
|
accept(
|
||||||
|
&mut releases,
|
||||||
|
vec![
|
||||||
|
track("ddddddddddd", "First selected track", 200),
|
||||||
|
track("eeeeeeeeeee", "Second selected track", 100),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let request = super::super::FeedRequest {
|
||||||
|
channel: 0,
|
||||||
|
start: skip + 1,
|
||||||
|
count: 2,
|
||||||
|
};
|
||||||
|
let page = feed_results(&feed_output(releases), &[request]).unwrap();
|
||||||
|
assert_eq!(page.feed_progress[0].count, 2);
|
||||||
|
assert_eq!(
|
||||||
|
page.videos
|
||||||
|
.iter()
|
||||||
|
.map(|video| video.id.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
["ddddddddddd", "eeeeeeeeeee"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discovery_continues_after_sixty_albums_and_keeps_later_tracks() {
|
||||||
|
let mut releases = ChannelReleases::new(CHANNEL.into(), 0, 2);
|
||||||
|
assert_eq!(range(&releases), (1, 60));
|
||||||
|
accept(&mut releases, (0..60).map(album).collect(), None);
|
||||||
|
for _ in 0..60 {
|
||||||
|
assert!(!releases.done());
|
||||||
|
accept(&mut releases, vec![], Some(0));
|
||||||
|
}
|
||||||
|
assert!(!releases.done());
|
||||||
|
assert_eq!(range(&releases), (61, 120));
|
||||||
|
accept(&mut releases, vec![album(60)], Some(61));
|
||||||
|
accept(
|
||||||
|
&mut releases,
|
||||||
|
vec![track("fffffffffff", "Track on album 61", 100)],
|
||||||
|
Some(1),
|
||||||
|
);
|
||||||
|
let request = super::super::FeedRequest {
|
||||||
|
channel: 0,
|
||||||
|
start: 1,
|
||||||
|
count: 2,
|
||||||
|
};
|
||||||
|
let page = feed_results(&feed_output(releases), &[request]).unwrap();
|
||||||
|
assert_eq!(page.videos.len(), 1);
|
||||||
|
assert_eq!(page.videos[0].title, "Track on album 61");
|
||||||
|
assert_eq!(page.feed_progress[0].count, 1);
|
||||||
|
assert!(!page.has_more);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_missing_releases_tab_during_discovery_is_normal_exhaustion() {
|
||||||
|
let missing = "ERROR: [youtube:tab] channel: This channel does not have a releases tab";
|
||||||
|
let mut ordinary = ChannelReleases::new(CHANNEL.into(), 0, 2);
|
||||||
|
ordinary.command().unwrap();
|
||||||
|
ordinary.accept_error(missing.into()).unwrap();
|
||||||
|
assert!(ordinary.done());
|
||||||
|
assert!(ordinary.into_entries().is_empty());
|
||||||
|
|
||||||
|
for error in [
|
||||||
|
"ERROR: network timeout",
|
||||||
|
"ERROR: This channel does not have a releases tab; retry later",
|
||||||
|
] {
|
||||||
|
let mut discovery = ChannelReleases::new(CHANNEL.into(), 0, 2);
|
||||||
|
discovery.command().unwrap();
|
||||||
|
assert_eq!(discovery.accept_error(error.into()).unwrap_err(), error);
|
||||||
|
}
|
||||||
|
for error in [missing, "ERROR: album is private"] {
|
||||||
|
let mut releases = ChannelReleases::new(CHANNEL.into(), 0, 2);
|
||||||
|
accept(&mut releases, vec![album(1)], Some(1));
|
||||||
|
releases.command().unwrap();
|
||||||
|
assert_eq!(releases.accept_error(error.into()).unwrap_err(), error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_envelopes_and_exhaustion_counts_are_errors_not_empty_catalogs() {
|
||||||
|
for output in [
|
||||||
|
"not JSON",
|
||||||
|
"{}",
|
||||||
|
r#"{"entries":null,"playlist_count":0}"#,
|
||||||
|
r#"{"entries":[],"playlist_count":"0"}"#,
|
||||||
|
r#"{"entries":[],"playlist_count":-1}"#,
|
||||||
|
] {
|
||||||
|
let mut releases = ChannelReleases::new(CHANNEL.into(), 0, 2);
|
||||||
|
releases.command().unwrap();
|
||||||
|
assert!(
|
||||||
|
releases.accept(output).is_err(),
|
||||||
|
"accepted malformed discovery: {output}"
|
||||||
|
);
|
||||||
|
let mut releases = ChannelReleases::new(CHANNEL.into(), 0, 2);
|
||||||
|
accept(&mut releases, vec![album(1)], Some(1));
|
||||||
|
releases.command().unwrap();
|
||||||
|
assert!(
|
||||||
|
releases.accept(output).is_err(),
|
||||||
|
"accepted malformed album: {output}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_discovery_needs_no_total_but_skipping_an_empty_album_does() {
|
||||||
|
let mut discovery = ChannelReleases::new(CHANNEL.into(), 0, 2);
|
||||||
|
accept(&mut discovery, vec![], None);
|
||||||
|
assert!(discovery.done());
|
||||||
|
assert!(discovery.into_entries().is_empty());
|
||||||
|
|
||||||
|
let mut releases = ChannelReleases::new(CHANNEL.into(), 5, 2);
|
||||||
|
accept(&mut releases, vec![album(1), album(2)], Some(2));
|
||||||
|
releases.command().unwrap();
|
||||||
|
assert!(releases.accept(&response(vec![], None)).is_err());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user